Files
mesh-catalog/modules/mesh-mail-reader/cmd/mesh-mail-reader/acceptance_integration_test.go
T

272 lines
11 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 }
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", "2026-11-01")
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": time.Now().UTC().Format(time.RFC3339)}, 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": time.Now().UTC().Format(time.RFC3339)}, 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: the operator channel's own cap
}
return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, 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)
}