147 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|