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
170 lines
6.9 KiB
Go
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)
|
|
}
|