Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/tell_test.go
T

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)
}
})
}
}