Files
mesh-catalog/modules/mesh-mail-web/cmd/mesh-mail-web/mailwrite_integration_test.go
T

147 lines
5.8 KiB
Go

//go:build integration
package main
// 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, "/", 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)
}
}
}