272 lines
11 KiB
Go
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)
|
|
}
|