Files
mesh-catalog/modules/mesh-mail-reader/cmd/mesh-mail-reader/catchup_integration_test.go
T
jochen f3007eb242
mesh/merge-gate head f3007eb242f8 merged into trunk 6c6d5f681c30 — pass: builds mesh-mail, mesh-mail-web, new: modules/mesh-mail-reader → novox; no bus ste…
mesh/repo-check head f3007eb242f8 merged into trunk 6c6d5f681c30 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Fix the try on 8000b637 and review round 2: RunBatches takes its gap as a parameter instead of reading the var BatchGap, and no tell while unhealthy is checked as an exact count, not a before/after that a same-pass tell could hide behind (issue 1520, 1524)
2026-10-21 01:52:58 +02:00

170 lines
6.9 KiB
Go

//go:build integration
package main
// Design 59 test 16's reader half (novox/hq ADR 0459 §12, ADR 0461 §6; issue 1524, split from 1431):
// a refused judgement makes mesh-mail-reader unhealthy, with its cause and since when, and sends no tell;
// once the judge answers again, the mail heard meanwhile is read in the order it was published, and the
// reader answers healthy again. Reuses 1431's own harness (runtime_player_integration_test.go). Compiled
// by `go vet -tags integration ./...`; never run on this machine — the lead's try runs it for real.
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// sinceRE matches the "(since HH:MM YYYY-MM-DD)" health.go's own Answer embeds in why (review round 1
// finding 6): proves since when is actually named, not merely that the cause's own word ("licence") is.
var sinceRE = regexp.MustCompile(`since \d{2}:\d{2} \d{4}-\d{2}-\d{2}`)
// healthAnswer reads mail_reader_health through the player, as a plain map (the tool carries no caller
// check, ADR 0459 §12).
func healthAnswer(t *testing.T, r *startedReader) map[string]any {
t.Helper()
text, isError, err := r.player.ToolsCall("mail_reader_health", map[string]any{})
if err != nil {
t.Fatal(err)
}
if isError {
t.Fatalf("mail_reader_health answered an error: %s", text)
}
var out map[string]any
if err := json.Unmarshal([]byte(text), &out); err != nil {
t.Fatalf("mail_reader_health's answer could not be read: %s", text)
}
return out
}
// 1524/AC1, AC2 (review round 1 finding 6): a mattering mail judged and told is answered FIRST, its own
// tell dropped (so it is genuinely untold, the one thing that would make a later batch try the mail
// signal again) — then a refused judgement ("holds no licence", as claude-code's own judge.go answers when
// the licence binding is removed) makes the reader unhealthy with that cause and since when, and sends no
// tell for that still-untold mail; once the judge answers again, both mails (the second heard while
// unhealthy) are read in the order they were published, and the reader answers healthy again. The earlier
// version of this test asserted "no tell while unhealthy" with no mattering mail ever judged at all, so
// SendSignal had nothing to try telling regardless of health — proving nothing.
func TestAcceptanceRefusedJudgementIsUnhealthyThenCatchesUpInPublishedOrder(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.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 (1522/AC2)
}
return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, nil
}
var mu sync.Mutex
refusing := false
var judgedOrder recorded[int64]
r.stand.Judge = func(args map[string]any) (any, error) {
mu.Lock()
stillRefusing := refusing
mu.Unlock()
if stillRefusing {
// claude-code's own judge.go answers exactly this when the licence binding is removed
// (judge.go:148-151); it is an error from the ask itself, never a pending/ticket shape.
return nil, errors.New("holds no licence")
}
question, _ := args["question"].(string)
ids := extractMailIDs(question)
for _, id := range ids {
judgedOrder.add(id)
}
mails := make([]map[string]any, len(ids))
for i, id := range ids {
mails[i] = map[string]any{"id": id, "mark": "matters", "summary": "s", "why": "pay", "due-date": "2026-11-01"}
}
raw, _ := json.Marshal(map[string]any{"mails": mails})
return map[string]any{"answer": json.RawMessage(raw)}, nil
}
conn := pgConnect(t, r.dbURL)
ctx := context.Background()
// A mattering mail, judged successfully, its own tell dropped — so it stays genuinely untold.
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "first", time.Now())
waitFor(t, 30*time.Second, "the first (dropped) tell attempt", func() bool { return tellCalls.get() >= 1 })
waitFor(t, 10*time.Second, "the first 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 its own tell was dropped")
}
// From here, every judgement is refused (1524/AC1) — set before the second mail is even heard, so its
// own batch, whenever it runs, is refused.
mu.Lock()
refusing = true
mu.Unlock()
publishAdded(t, r.nc, "bob", "INBOX", 2, []map[string]any{{"name": "Bob", "address": "bob2@example.org"}}, "second", time.Now().Add(time.Second))
// 1524/AC1: unhealthy with cause and since when, no tell for the mail that is already untold.
waitFor(t, 30*time.Second, "mail_reader_health to answer unhealthy", func() bool {
h := healthAnswer(t, r)
healthy, _ := h["healthy"].(bool)
return !healthy
})
h := healthAnswer(t, r)
why := fmt.Sprint(h["why"])
if !strings.Contains(why, "licence") {
t.Fatalf("health = %v, want why naming the licence", h)
}
if !sinceRE.MatchString(why) {
t.Fatalf("health's why = %q, want it to name since when (HH:MM YYYY-MM-DD, health.go's own Answer)", why)
}
// Exactly 1 (the dropped tell of the healthy phase, above) — never a relative "unchanged since some
// point after unhealthy was already seen", which a tell sent in the SAME loop pass that made the
// reader unhealthy would already be baked into (review round 2, issue 1524): waitFor's own poll above
// only reads health, so a wrongly-sent tell from that very batch would predate this read and hide
// behind it.
time.Sleep(2 * time.Second) // give the next failed batch attempt, if any, time to run and fail again
if n := tellCalls.get(); n != 1 {
t.Fatalf("tellCalls = %d, want exactly 1 (the dropped tell from the healthy phase; none while unhealthy)", n)
}
// The judge answers again (1524/AC2): catch-up in the order the two mails were published.
mu.Lock()
refusing = false
mu.Unlock()
waitFor(t, BatchGap+60*time.Second, "both entries written", func() bool {
var n int
if err := conn.QueryRow(ctx, `select count(*) from mail_entry`).Scan(&n); err != nil {
return false
}
return n == 2
})
order := judgedOrder.snapshot()
if len(order) < 2 {
t.Fatalf("judged ids = %v, want at least 2 (both mails, in one batch or more)", order)
}
for i := 1; i < len(order); i++ {
if order[i] <= order[i-1] {
t.Fatalf("judged out of arrival order: %v", order)
}
}
waitFor(t, 10*time.Second, "healthy again", func() bool {
healthy, _ := healthAnswer(t, r)["healthy"].(bool)
return healthy
})
assertNeverSendsMovesFlagsOrDeletes(t, r)
}