265 lines
11 KiB
Go
265 lines
11 KiB
Go
package main
|
|
|
|
// The tells to the operator, overdue health and mail_send_status's words (novox/hq ADR 0436 clauses 11,
|
|
// 12, issue 1199).
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// memTell collects every headline a TellOperator was called with, optionally refusing the first n calls
|
|
// (novox/hq issue 1199).
|
|
type memTell struct {
|
|
headlines []string
|
|
refuseN int
|
|
}
|
|
|
|
func (m *memTell) tell(headline string) error {
|
|
if m.refuseN > 0 {
|
|
m.refuseN--
|
|
return errors.New("the channel is down")
|
|
}
|
|
m.headlines = append(m.headlines, headline)
|
|
return nil
|
|
}
|
|
|
|
func tellTestSend(t *testing.T, sends *memSends, outcome string) SendRequest {
|
|
t.Helper()
|
|
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, At: judgedAt}
|
|
req.Decided = &SendDecision{At: judgedAt, Outcome: outcome}
|
|
if _, err := sends.Create(req.ID, req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := addTellPending(sends, req.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return req
|
|
}
|
|
|
|
// 1199/AC1: given a send recorded failed, refused or not proven after the operator's Approve, when the
|
|
// sender looks, then it sets tell to telling by compare-and-set, calls seat:operator-channel.tell once
|
|
// (the headline naming the account and the reason, or for not proven that it may not have been sent; no
|
|
// address in it), and on the channel taking it sets told. A refused or unanswered tell leaves telling, and
|
|
// the next look tells only records left telling. A sent send is never told.
|
|
func TestTellSendTellsOnceAndSettlesToldOnlyOnceTheChannelTakesIt(t *testing.T) {
|
|
sends := newMemSends()
|
|
req := tellTestSend(t, sends, "failed: 550 no such mailbox")
|
|
tell := &memTell{}
|
|
|
|
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
|
t.Fatalf("telling: %v", err)
|
|
}
|
|
if len(tell.headlines) != 1 {
|
|
t.Fatalf("told %d time(s): %v", len(tell.headlines), tell.headlines)
|
|
}
|
|
h := tell.headlines[0]
|
|
if !strings.Contains(h, "mesh") || !strings.Contains(h, "550 no such mailbox") {
|
|
t.Fatalf("headline: %q", h)
|
|
}
|
|
if strings.Contains(h, "@") {
|
|
t.Fatalf("the headline carries an address: %q", h)
|
|
}
|
|
kept, _, ok, err := getSend(sends, req.ID)
|
|
if err != nil || !ok || kept.Decided == nil || kept.Decided.Tell != "told" {
|
|
t.Fatalf("tell not settled told: %v %v %+v", err, ok, kept.Decided)
|
|
}
|
|
ids, err := tellPendingIDs(sends)
|
|
if err != nil || len(ids) != 0 {
|
|
t.Fatalf("still indexed after being told: %v %v", err, ids)
|
|
}
|
|
|
|
// A second look at an already-told send tells nothing more.
|
|
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(2*time.Minute)); err != nil {
|
|
t.Fatalf("telling again: %v", err)
|
|
}
|
|
if len(tell.headlines) != 1 {
|
|
t.Fatalf("told again: %v", tell.headlines)
|
|
}
|
|
}
|
|
|
|
func TestTellSendLeavesTellingOnARefusedChannelAndTriesAgain(t *testing.T) {
|
|
sends := newMemSends()
|
|
req := tellTestSend(t, sends, "not proven")
|
|
tell := &memTell{refuseN: 1}
|
|
|
|
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
|
t.Fatalf("telling: %v", err)
|
|
}
|
|
kept, _, ok, err := getSend(sends, req.ID)
|
|
if err != nil || !ok || kept.Decided == nil || kept.Decided.Tell != "telling" {
|
|
t.Fatalf("not left telling: %v %v %+v", err, ok, kept.Decided)
|
|
}
|
|
ids, err := tellPendingIDs(sends)
|
|
if err != nil || len(ids) != 1 {
|
|
t.Fatalf("no longer indexed despite the refusal: %v %v", err, ids)
|
|
}
|
|
|
|
// The next look succeeds.
|
|
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(2*time.Minute)); err != nil {
|
|
t.Fatalf("telling again: %v", err)
|
|
}
|
|
if len(tell.headlines) != 1 {
|
|
t.Fatalf("told: %v", tell.headlines)
|
|
}
|
|
if !strings.Contains(tell.headlines[0], "may not have been sent") {
|
|
t.Fatalf("not-proven headline: %q", tell.headlines[0])
|
|
}
|
|
}
|
|
|
|
func TestTellSendNeverTellsForASentSend(t *testing.T) {
|
|
sends := newMemSends()
|
|
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
|
req.Decided = &SendDecision{At: judgedAt, Outcome: "sent"}
|
|
if _, err := sends.Create(req.ID, req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tell := &memTell{}
|
|
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
|
t.Fatalf("telling: %v", err)
|
|
}
|
|
if len(tell.headlines) != 0 {
|
|
t.Fatalf("a sent send was told: %v", tell.headlines)
|
|
}
|
|
}
|
|
|
|
// review round 1 finding 3: a send indexed for a tell before its own outcome write has landed (recordOutcome
|
|
// and claimQueuedSend now index first) must not be dropped by a look that finds it still sending, queued or
|
|
// retrying — it is left in the index for the next look, once the real outcome lands.
|
|
func TestTellSendKeepsAnEntryWhoseRecordIsStillInFlight(t *testing.T) {
|
|
for _, outcome := range []string{"sending", "queued", "retrying"} {
|
|
sends := newMemSends()
|
|
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
|
req.Decided = &SendDecision{At: judgedAt, Outcome: outcome}
|
|
if _, err := sends.Create(req.ID, req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := addTellPending(sends, req.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tell := &memTell{}
|
|
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
|
t.Fatalf("%s: telling: %v", outcome, err)
|
|
}
|
|
if len(tell.headlines) != 0 {
|
|
t.Fatalf("%s: told before its outcome landed: %v", outcome, tell.headlines)
|
|
}
|
|
ids, err := tellPendingIDs(sends)
|
|
if err != nil || len(ids) != 1 {
|
|
t.Fatalf("%s: dropped from the index while still in flight: %v %v", outcome, err, ids)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1199/AC2: given a send queued more than 10 minutes ago, or retrying 10 minutes past its next attempt,
|
|
// when mail_health runs (Overdue), then it names the send; a send waiting for its next attempt is never
|
|
// overdue.
|
|
func TestOverdueFindsAQueuedOrRetryingSendPastTenMinutes(t *testing.T) {
|
|
sends := newMemSends()
|
|
queueTestSend := func(outcome string, at, next time.Time) string {
|
|
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
|
req.Decided = &SendDecision{At: at, Outcome: outcome, NextAttempt: next}
|
|
if _, err := sends.Create(req.ID, req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := addSenderPending(sends, req.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return req.ID
|
|
}
|
|
now := judgedAt
|
|
overdueQueued := queueTestSend("queued", now.Add(-11*time.Minute), time.Time{})
|
|
freshQueued := queueTestSend("queued", now.Add(-5*time.Minute), time.Time{})
|
|
overdueRetrying := queueTestSend("retrying", now.Add(-20*time.Minute), now.Add(-11*time.Minute))
|
|
notYetDueRetrying := queueTestSend("retrying", now.Add(-5*time.Minute), now.Add(3*time.Minute))
|
|
sendingNotOverdue := queueTestSend("sending", now.Add(-20*time.Minute), time.Time{})
|
|
|
|
overdue, err := Overdue(sends, now)
|
|
if err != nil {
|
|
t.Fatalf("Overdue: %v", err)
|
|
}
|
|
var ids []string
|
|
for _, r := range overdue {
|
|
ids = append(ids, r.ID)
|
|
}
|
|
want := map[string]bool{overdueQueued: true, overdueRetrying: true}
|
|
if len(ids) != len(want) {
|
|
t.Fatalf("overdue: %v, want exactly %v", ids, want)
|
|
}
|
|
for _, id := range ids {
|
|
if !want[id] {
|
|
t.Fatalf("unexpectedly overdue: %s (fresh %s, not-yet-due %s, sending %s)", id, freshQueued, notYetDueRetrying, sendingNotOverdue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1199/AC3: given an overdue send, when the tools' second ticker looks, then they tell once through
|
|
// waits-tell (telling then told), separate from the sender's own tell.
|
|
func TestTellWaitsTellsOnceThroughItsOwnField(t *testing.T) {
|
|
sends := newMemSends()
|
|
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
|
req.Decided = &SendDecision{At: judgedAt.Add(-20 * time.Minute), Outcome: "queued"}
|
|
if _, err := sends.Create(req.ID, req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tell := &memTell{}
|
|
if err := tellWaits(sends, tell.tell, req.ID, judgedAt); err != nil {
|
|
t.Fatalf("telling waits: %v", err)
|
|
}
|
|
if len(tell.headlines) != 1 || !strings.Contains(tell.headlines[0], "waits") {
|
|
t.Fatalf("waits-tell: %v", tell.headlines)
|
|
}
|
|
kept, _, ok, err := getSend(sends, req.ID)
|
|
if err != nil || !ok || kept.Decided == nil || kept.Decided.WaitsTell != "told" || kept.Decided.Tell != "" {
|
|
t.Fatalf("waits-tell not settled apart from the sender's own tell: %v %v %+v", err, ok, kept.Decided)
|
|
}
|
|
// Told once: a second look tells nothing more.
|
|
if err := tellWaits(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
|
t.Fatalf("telling waits again: %v", err)
|
|
}
|
|
if len(tell.headlines) != 1 {
|
|
t.Fatalf("told again: %v", tell.headlines)
|
|
}
|
|
}
|
|
|
|
// 1199/AC4: given each state, when mail_send_status reads it (reportDecision), then it says: waiting for
|
|
// the operator (covered by send_test.go's own asking tests, which read ReadSendStatus before any decision
|
|
// exists — reportDecision itself only ever sees an already-decided SendDecision), approved and waiting for
|
|
// the sender, being sent, waiting for its next attempt, sent, failed with its reason, refused, not proven,
|
|
// declined, or expired — checking both the status word and the words that actually say which of these it
|
|
// is (novox/hq review round 1 finding 6: checking status alone proves too little).
|
|
func TestReportDecisionCoversTheWholeStatusVocabulary(t *testing.T) {
|
|
for name, c := range map[string]struct {
|
|
d SendDecision
|
|
wantStatus string
|
|
wantInWords string
|
|
}{
|
|
// There is no "approved" state in a send's life: Decide's one compare-and-set write moves it
|
|
// straight from undecided to "queued" (novox/hq ADR 0436 clause 8, as decided to replace the
|
|
// two-state design, review round 1).
|
|
"queued": {SendDecision{At: judgedAt, Outcome: "queued"}, "waiting", "approved and waiting for the sender"},
|
|
"being sent": {SendDecision{At: judgedAt, Outcome: "sending"}, "waiting", "being sent"},
|
|
"retrying": {SendDecision{At: judgedAt, Outcome: "retrying", NextAttempt: judgedAt.Add(time.Minute)}, "waiting", "waiting for its next attempt"},
|
|
"sent": {SendDecision{At: judgedAt, Outcome: "sent"}, "sent", "sent on the operator's Approve"},
|
|
"failed with its reason": {SendDecision{At: judgedAt, Outcome: "failed: 550 no such mailbox"}, "failed", "550 no such mailbox"},
|
|
"refused": {SendDecision{At: judgedAt, Outcome: "refused: the warrant does not bind the request"}, "refused", "the warrant does not bind the request"},
|
|
"not proven": {SendDecision{At: judgedAt, Outcome: "not proven"}, "not proven", "not proven"},
|
|
"declined": {SendDecision{At: judgedAt, Outcome: "declined"}, "declined", "the operator declined"},
|
|
"expired (never heard)": {SendDecision{At: judgedAt, Outcome: "ended: never heard"}, "expired", "never heard"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
out := map[string]any{}
|
|
reportDecision(out, c.d, judgedAt.Add(time.Minute))
|
|
if out["status"] != c.wantStatus {
|
|
t.Fatalf("status: %v, want %q (words: %v)", out["status"], c.wantStatus, out["words"])
|
|
}
|
|
words, _ := out["words"].(string)
|
|
if !strings.Contains(words, c.wantInWords) {
|
|
t.Fatalf("words: %q, want it to contain %q", words, c.wantInWords)
|
|
}
|
|
})
|
|
}
|
|
}
|