Files
mesh-catalog/modules/mesh-mail-web/internal/web/mailwrite_integration_test.go
T
jochen ad4f312263 issue 1859: open the mail page on the operator's list (1859.1)
ADR 0515 §1, §5: / greets the operator with what the mail signal told today and when they last looked, then
shows today, this week, later, the promises by kind, good to know, read later, handled today and the scam
warnings, from mail-reading.list; it records the page opened. The classical list moves to /all, as All mail.
2026-10-22 20:03:21 +02:00

147 lines
5.8 KiB
Go

//go:build integration
package web
// 1523/AC4: mesh-mail-web started whole (the harness issue 1285 built, its stand-in seat extended to
// mail-reading by runtime_integration_test.go's standInMailReading): a correction posted on the page
// reaches the stand-in's Correct, an undo reaches Undo, and nothing of mail is read but through mail-store
// (the classical list's own mail-store.accounts; never mail-store.search or .read, which this flow never
// needs). Compiled by `go vet -tags integration ./...`; never run here — the lead's try runs it for real.
import (
"io"
"net/http"
"net/url"
"sync"
"testing"
)
// recordedArgs is a mutex-guarded holder: a stand-in's own closure (called from the player's readLoop
// goroutine, runtime_integration_test.go's handleBundleRequest) records what it was asked here, and the
// test (its own, main goroutine) reads it back safely afterward. The coordinator's own fix for issue 1523:
// the merge check's `go test -race` found this exact unsynchronized pattern — a bare variable written by
// one goroutine and read by another, with no memory synchronization the race detector can see (the two
// communicate only over a stdio pipe and an HTTP round trip, neither of which it tracks as ordinary Go
// synchronization).
type recordedArgs struct {
mu sync.Mutex
args map[string]any
set bool
}
func (r *recordedArgs) record(args map[string]any) {
r.mu.Lock()
r.args, r.set = args, true
r.mu.Unlock()
}
func (r *recordedArgs) clear() {
r.mu.Lock()
r.args, r.set = nil, false
r.mu.Unlock()
}
func (r *recordedArgs) get() map[string]any {
r.mu.Lock()
defer r.mu.Unlock()
return r.args
}
func (r *recordedArgs) wasSet() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.set
}
func TestAcceptanceMailCorrectionAndUndoReachTheStandIn(t *testing.T) {
mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
// The classical list below the mail-reading section still asks seat:mail-store.accounts on every page
// load (accounts.go's own accountsPage), exactly as milestone 1's own tests set it up (review round 1
// finding 1, issue 1523): left unset, GET "/" fails this test as an unknown ask, never reaching the
// mail-reading flow this test is actually about.
mw.store.Accounts = func(map[string]any) (any, error) {
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
}
mw.mailReading.Status = func(map[string]any) (any, error) {
return map[string]any{"healthy": true, "weeks": []map[string]any{}}, nil
}
mw.mailReading.Entries = func(map[string]any) (any, error) {
return map[string]any{"entries": []map[string]any{
{"id": 1, "sender-name": "Alice", "summary": "pay the bill", "mark": "matters"},
}}, nil
}
mw.mailReading.Groups = func(map[string]any) (any, error) {
return map[string]any{"groups": []map[string]any{}}, nil
}
mw.mailReading.Lessons = func(map[string]any) (any, error) {
return map[string]any{"lessons": []map[string]any{
{"id": 9, "sender-name": "Bob", "subject": "fyi", "change": "mark: neither -> matters", "made": "2026-10-20T10:00:00Z"},
}}, nil
}
var correctArgs, undoArgs recordedArgs
mw.mailReading.Correct = func(args map[string]any) (any, error) {
correctArgs.record(args)
return map[string]any{"id": args["id"]}, nil
}
mw.mailReading.Undo = func(args map[string]any) (any, error) {
undoArgs.record(args)
return map[string]any{"undone": args["id"]}, nil
}
cookie := signIn(t, mw, "alice", "/")
page := doWithCookie(t, mw, http.MethodGet, "/all", nil, cookie)
pageBody, _ := io.ReadAll(page.Body)
_ = page.Body.Close()
if page.StatusCode != http.StatusOK {
t.Fatalf("the accounts page: status %d: %s", page.StatusCode, pageBody)
}
token := extractHiddenValue(string(pageBody), FormTokenField)
if token == "" {
t.Fatalf("the accounts page carries no form token:\n%s", pageBody)
}
// 1523/AC1: a correction posted with the session's own token reaches the stand-in's Correct.
correctResp := doForm(t, mw, "/mail-correct", url.Values{
"id": {"1"}, "mark": {"matters"}, "group": {"bills"}, FormTokenField: {token},
}, cookie)
correctBody, _ := io.ReadAll(correctResp.Body)
_ = correctResp.Body.Close()
if correctResp.StatusCode != http.StatusOK {
t.Fatalf("the correction: status %d: %s", correctResp.StatusCode, correctBody)
}
if got := correctArgs.get(); got["id"] != float64(1) || got["mark"] != "matters" || got["group"] != "bills" {
t.Fatalf("mail-reading.correct was asked %v, want id 1, mark matters, group bills", got)
}
// Without the token, the correction never reaches the stand-in (1523/AC1).
correctArgs.clear()
noTokenResp := doForm(t, mw, "/mail-correct", url.Values{"id": {"1"}, "mark": {"neither"}}, cookie)
_ = noTokenResp.Body.Close()
if noTokenResp.StatusCode != http.StatusForbidden {
t.Fatalf("a correction with no token: status %d, want 403", noTokenResp.StatusCode)
}
if correctArgs.wasSet() {
t.Fatalf("mail-reading.correct was asked %v with no token", correctArgs.get())
}
// 1523/AC2: an undo posted with the session's own token reaches the stand-in's Undo.
undoResp := doForm(t, mw, "/mail-undo", url.Values{"id": {"9"}, FormTokenField: {token}}, cookie)
undoBody, _ := io.ReadAll(undoResp.Body)
_ = undoResp.Body.Close()
if undoResp.StatusCode != http.StatusOK {
t.Fatalf("the undo: status %d: %s", undoResp.StatusCode, undoBody)
}
if got := undoArgs.get(); got["id"] != float64(9) {
t.Fatalf("mail-reading.undo was asked %v, want id 9", got)
}
// 1523/AC4: nothing of mail is read but through mail-store — this flow never asks mail-store.search or
// mail-store.read at all (only .accounts, for the classical list beneath).
for _, k := range mw.store.Keys() {
if k == "seat:mail-store.search" || k == "seat:mail-store.read" {
t.Fatalf("mail-store was asked %s, which this flow never needs", k)
}
}
}