Both stand-in judges answered the fixed due date 2026-11-01; since 2026-11-02 00:00 UTC that date is past, signalEntries drops the mail, no tell is sent, and both tests time out on every check that rebuilds the module (met in the enrolment group's check of issue 774, 2026-11-02 02:50). Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
309 lines
13 KiB
Go
309 lines
13 KiB
Go
//go:build integration
|
|
|
|
package main
|
|
|
|
// mesh-mail-reader started whole against the real bus and store (novox/hq ADR 0459, 0461, 0472; issue
|
|
// 1431, design 59 tests 10, 11, 13 — this issue's own acceptance criteria note replaces its stale report,
|
|
// which named the grant split ADR 0472 made won't-fix). Compiled by `go vet -tags integration ./...`; never
|
|
// run on this machine — the lead's try runs it for real.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// mailIDPattern finds a mail's own id in a batch's question (question.go's mailHeaderBlock: "mail %d:
|
|
// account ..."), so a stand-in judge can answer about exactly the mails it was actually asked, without
|
|
// knowing their ids ahead of time (postgres assigns them).
|
|
var mailIDPattern = regexp.MustCompile(`mail (\d+):`)
|
|
|
|
func extractMailIDs(question string) []int64 {
|
|
matches := mailIDPattern.FindAllStringSubmatch(question, -1)
|
|
out := make([]int64, 0, len(matches))
|
|
for _, m := range matches {
|
|
id, _ := strconv.ParseInt(m[1], 10, 64)
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// judgeEveryMailAs is a stand-in Judge that marks every mail of the batch's own question the same way —
|
|
// enough for a test that cares about one outcome, not a per-mail distinction.
|
|
func judgeEveryMailAs(mark, group, why, dueDate string) func(args map[string]any) (any, error) {
|
|
return func(args map[string]any) (any, error) {
|
|
question, _ := args["question"].(string)
|
|
ids := extractMailIDs(question)
|
|
mails := make([]map[string]any, len(ids))
|
|
for i, id := range ids {
|
|
m := map[string]any{"id": id, "mark": mark, "summary": "s"}
|
|
if group != "" {
|
|
m["group"] = group
|
|
}
|
|
if why != "" {
|
|
m["why"] = why
|
|
}
|
|
if dueDate != "" {
|
|
m["due-date"] = dueDate
|
|
}
|
|
mails[i] = m
|
|
}
|
|
raw, _ := json.Marshal(map[string]any{"mails": mails})
|
|
return map[string]any{"answer": json.RawMessage(raw)}, nil
|
|
}
|
|
}
|
|
|
|
func oneAccountAnswer(account string) func(args map[string]any) (any, error) {
|
|
return func(map[string]any) (any, error) {
|
|
return map[string]any{"accounts": []map[string]any{{"account": account, "kind": "imap", "address": account + "@example.org"}}}, nil
|
|
}
|
|
}
|
|
|
|
// assertNeverSendsMovesFlagsOrDeletes is 1431/AC2: across the run, the reader never asks a verb that
|
|
// changes the mailbox. Every ask this harness's stand-ins do not know already fails the test loudly
|
|
// (readerPlayer.handleAsk); this is the belt the AC's own words ask for, on top of that suspenders.
|
|
func assertNeverSendsMovesFlagsOrDeletes(t *testing.T, r *startedReader) {
|
|
t.Helper()
|
|
for _, k := range r.stand.Keys() {
|
|
lower := strings.ToLower(k)
|
|
for _, forbidden := range []string{"send", "move", "flag", "delete"} {
|
|
if strings.Contains(lower, forbidden) {
|
|
t.Fatalf("asked %q, which names %q — the reader must never change a mailbox", k, forbidden)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1431/AC1 (design 59 test 10): a published added event gives a mail entry in a mail group, and a search
|
|
// of every text column of the reader's own database for a known line of that mail's body finds nothing.
|
|
func TestAcceptancePublishedMailBecomesAnEntryNeverKeepingItsBody(t *testing.T) {
|
|
r := startReader(t)
|
|
r.stand.MailAccounts = oneAccountAnswer("bob")
|
|
knownLine := "the-known-body-line-xyz123-never-kept"
|
|
r.stand.MailRead = func(map[string]any) (any, error) {
|
|
return map[string]any{"text": "hello, " + knownLine + ", goodbye"}, nil
|
|
}
|
|
r.stand.Judge = judgeEveryMailAs("neither", "newsletters", "", "")
|
|
r.stand.MessengerCheck = func(map[string]any) (any, error) { return map[string]any{"allowed": true}, nil }
|
|
r.stand.Tell = func(map[string]any) (any, error) {
|
|
return map[string]any{"sent_on": nil, "dropped": []string{"telegram"}}, nil
|
|
}
|
|
|
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
|
|
|
conn := pgConnect(t, r.dbURL)
|
|
ctx := context.Background()
|
|
waitFor(t, 30*time.Second, "the mail entry to appear", func() bool {
|
|
var n int
|
|
if err := conn.QueryRow(ctx, `select count(*) from mail_entry`).Scan(&n); err != nil {
|
|
return false
|
|
}
|
|
return n == 1
|
|
})
|
|
|
|
var groupID *int64
|
|
if err := conn.QueryRow(ctx, `select mail_group from mail_entry limit 1`).Scan(&groupID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if groupID == nil {
|
|
t.Fatal("the entry carries no mail group")
|
|
}
|
|
|
|
// A search of every text-ish column of the whole database for the known body line finds nothing.
|
|
rows, err := conn.Query(ctx, `select table_name, column_name from information_schema.columns
|
|
where table_schema = 'public' and data_type in ('text', 'character varying')`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
checked := 0
|
|
for rows.Next() {
|
|
var table, column string
|
|
if err := rows.Scan(&table, &column); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var found int
|
|
q := fmt.Sprintf(`select count(*) from %q where %q like $1`, table, column)
|
|
if err := conn.QueryRow(ctx, q, "%"+knownLine+"%").Scan(&found); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checked++
|
|
if found > 0 {
|
|
t.Fatalf("the known body line was found in %s.%s", table, column)
|
|
}
|
|
}
|
|
if checked == 0 {
|
|
t.Fatal("no text column was checked at all")
|
|
}
|
|
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
|
}
|
|
|
|
// 1431/AC3 (design 59 test 13, first half): a batch with one mail the stand-in judge marks as mattering
|
|
// gives exactly one tell naming it, its words passing the content rule (messenger_check).
|
|
func TestAcceptanceOneMatteringMailSendsExactlyOneTellNamingIt(t *testing.T) {
|
|
r := startReader(t)
|
|
r.stand.MailAccounts = oneAccountAnswer("bob")
|
|
r.stand.MailRead = func(map[string]any) (any, error) { return map[string]any{"text": "pay by friday"}, nil }
|
|
r.stand.Judge = judgeEveryMailAs("matters", "", "due friday", time.Now().UTC().AddDate(0, 0, 7).Format("2006-01-02"))
|
|
|
|
var checked recorded[string]
|
|
r.stand.MessengerCheck = func(args map[string]any) (any, error) {
|
|
text, _ := args["text"].(string)
|
|
checked.add(text)
|
|
// The content rule's own shape (ADR 0461 §4): refuse words carrying a URL, an address or a host name.
|
|
allowed := !strings.Contains(text, "://") && !strings.Contains(text, "example.com")
|
|
return map[string]any{"allowed": allowed}, nil
|
|
}
|
|
var tells recorded[map[string]any]
|
|
r.stand.Tell = func(args map[string]any) (any, error) {
|
|
tells.add(args)
|
|
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
|
}
|
|
|
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "invoice", time.Now())
|
|
|
|
waitFor(t, 30*time.Second, "exactly one tell", func() bool { return tells.len() >= 1 })
|
|
time.Sleep(2 * time.Second) // give a stray second tell a chance to prove this wrong, if it would happen
|
|
sent := tells.snapshot()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("tells = %d, want exactly 1", len(sent))
|
|
}
|
|
if sent[0]["headline"] != "1 mail matters" {
|
|
t.Fatalf("headline = %v, want '1 mail matters'", sent[0]["headline"])
|
|
}
|
|
if sent[0]["subject"] != "mail" {
|
|
t.Fatalf("subject = %v, want mail", sent[0]["subject"])
|
|
}
|
|
lines, _ := sent[0]["lines"].([]any)
|
|
named := false
|
|
for _, l := range lines {
|
|
if s, ok := l.(string); ok && strings.Contains(s, "Alice") {
|
|
named = true
|
|
}
|
|
}
|
|
if !named {
|
|
t.Fatalf("lines %v do not name Alice", lines)
|
|
}
|
|
for _, text := range checked.snapshot() {
|
|
if strings.Contains(text, "://") || strings.Contains(text, "example.com") {
|
|
t.Fatalf("messenger_check was asked a word carrying a URL or host name: %q", text)
|
|
}
|
|
}
|
|
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
|
}
|
|
|
|
// 1431/AC3 (design 59 test 13, second half): a sender whose name carries a host name gives the safe form.
|
|
func TestAcceptanceSenderNameWithAHostNameGivesTheSafeForm(t *testing.T) {
|
|
r := startReader(t)
|
|
r.stand.MailAccounts = oneAccountAnswer("bob")
|
|
r.stand.MailRead = func(map[string]any) (any, error) { return map[string]any{"text": "hi"}, nil }
|
|
r.stand.Judge = judgeEveryMailAs("matters", "", "", "")
|
|
r.stand.MessengerCheck = func(args map[string]any) (any, error) {
|
|
text, _ := args["text"].(string)
|
|
return map[string]any{"allowed": !strings.Contains(text, "evil.example.com")}, nil
|
|
}
|
|
var tells recorded[map[string]any]
|
|
r.stand.Tell = func(args map[string]any) (any, error) {
|
|
tells.add(args)
|
|
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
|
}
|
|
|
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "mail.evil.example.com", "address": "x@example.org"}}, "hi", time.Now())
|
|
|
|
waitFor(t, 30*time.Second, "one tell", func() bool { return tells.len() >= 1 })
|
|
sent := tells.snapshot()
|
|
lines, _ := sent[0]["lines"].([]any)
|
|
if len(lines) != 1 || lines[0] != "open the mail page" {
|
|
t.Fatalf("lines = %v, want the safe form (just 'open the mail page')", lines)
|
|
}
|
|
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
|
}
|
|
|
|
// 1431/AC4: with the stand-in tell answering dropped (sent_on empty, the cap of 10 an hour), the mail stays
|
|
// untold, and the next mail signal — after a second batch — counts it.
|
|
func TestAcceptanceDroppedTellLeavesMailUntoldAndCountedNextTime(t *testing.T) {
|
|
r := startReader(t)
|
|
r.stand.MailAccounts = oneAccountAnswer("bob")
|
|
r.stand.MailRead = func(map[string]any) (any, error) { return map[string]any{"text": "hi"}, nil }
|
|
r.stand.Judge = judgeEveryMailAs("matters", "", "", "")
|
|
r.stand.MessengerCheck = func(map[string]any) (any, error) { return map[string]any{"allowed": true}, nil }
|
|
var tellCalls count
|
|
r.stand.Tell = func(map[string]any) (any, error) {
|
|
if tellCalls.inc() == 1 {
|
|
return map[string]any{"sent_on": nil, "dropped": []string{"telegram"}}, nil // dropped: the operator channel's own cap
|
|
}
|
|
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
|
}
|
|
|
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
|
waitFor(t, 30*time.Second, "the first (dropped) tell attempt", func() bool { return tellCalls.get() >= 1 })
|
|
|
|
conn := pgConnect(t, r.dbURL)
|
|
ctx := context.Background()
|
|
waitFor(t, 10*time.Second, "the entry to be written", func() bool {
|
|
var n int
|
|
_ = conn.QueryRow(ctx, `select count(*) from mail_entry`).Scan(&n)
|
|
return n == 1
|
|
})
|
|
var told bool
|
|
if err := conn.QueryRow(ctx, `select told from mail_entry limit 1`).Scan(&told); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if told {
|
|
t.Fatal("the mail was marked told even though the tell was dropped")
|
|
}
|
|
|
|
// A second mail, and its own batch (at least BatchGap after the first started), triggers the next mail
|
|
// signal — which counts the still-untold first mail too (1429/AC4, 1522/AC2).
|
|
publishAdded(t, r.nc, "bob", "INBOX", 2, []map[string]any{{"name": "Bob", "address": "bob2@example.org"}}, "second", time.Now())
|
|
waitFor(t, BatchGap+60*time.Second, "a second tell attempt", func() bool { return tellCalls.get() >= 2 })
|
|
waitFor(t, 10*time.Second, "the first entry to become told", func() bool {
|
|
var firstTold bool
|
|
if err := conn.QueryRow(ctx, `select told from mail_entry order by id limit 1`).Scan(&firstTold); err != nil {
|
|
return false
|
|
}
|
|
return firstTold
|
|
})
|
|
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
|
}
|
|
|
|
// 1926/AC4: a second batch over an unchanged set of mails that matter (its own mail does not matter) sends no
|
|
// second signal — the first, answered as the real operator-channel answers it (sent_on a list), marked the set
|
|
// told.
|
|
func TestAcceptanceRepeatedBatchesOverAnUnchangedSetSendOneSignal(t *testing.T) {
|
|
r := startReader(t)
|
|
r.stand.MailAccounts = oneAccountAnswer("bob")
|
|
r.stand.MailRead = func(map[string]any) (any, error) { return map[string]any{"text": "hi"}, nil }
|
|
var judged count
|
|
matters := judgeEveryMailAs("matters", "", "", "")
|
|
neither := judgeEveryMailAs("neither", "", "", "")
|
|
r.stand.Judge = func(args map[string]any) (any, error) {
|
|
if judged.inc() == 1 {
|
|
return matters(args)
|
|
}
|
|
return neither(args)
|
|
}
|
|
r.stand.MessengerCheck = func(map[string]any) (any, error) { return map[string]any{"allowed": true}, nil }
|
|
var tells count
|
|
r.stand.Tell = func(map[string]any) (any, error) {
|
|
tells.inc()
|
|
return map[string]any{"sent_on": []string{"telegram"}, "dropped": []string{}}, nil
|
|
}
|
|
|
|
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "hi", time.Now())
|
|
waitFor(t, 30*time.Second, "the first tell", func() bool { return tells.get() >= 1 })
|
|
publishAdded(t, r.nc, "bob", "INBOX", 2, []map[string]any{{"name": "Bob", "address": "bob2@example.org"}}, "second", time.Now())
|
|
publishAdded(t, r.nc, "bob", "INBOX", 3, []map[string]any{{"name": "Carol", "address": "carol@example.org"}}, "third", time.Now())
|
|
waitFor(t, BatchGap+60*time.Second, "later batches judged", func() bool { return judged.get() >= 2 })
|
|
time.Sleep(2 * BatchGap) // a stray repeat of the signal would come after a batch
|
|
if got := tells.get(); got != 1 {
|
|
t.Fatalf("1926/AC4: tells = %d, want exactly 1 for an unchanged set", got)
|
|
}
|
|
assertNeverSendsMovesFlagsOrDeletes(t, r)
|
|
}
|