diff --git a/modules/mesh-mail-web/go.mod b/modules/mesh-mail-web/go.mod index cb3bc2cb..45664db3 100644 --- a/modules/mesh-mail-web/go.mod +++ b/modules/mesh-mail-web/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c github.com/coreos/go-oidc/v3 v3.21.0 + github.com/jackc/pgx/v5 v5.11.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/oauth2 v0.37.0 @@ -14,12 +15,17 @@ require ( github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.20.0 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/modules/mesh-mail-web/go.sum b/modules/mesh-mail-web/go.sum index b39b27e2..c62320f4 100644 --- a/modules/mesh-mail-web/go.sum +++ b/modules/mesh-mail-web/go.sum @@ -4,10 +4,21 @@ github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrk github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= @@ -22,12 +33,27 @@ github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/mesh-mail-web/internal/web/list_integration_test.go b/modules/mesh-mail-web/internal/web/list_integration_test.go new file mode 100644 index 00000000..c69f6b87 --- /dev/null +++ b/modules/mesh-mail-web/internal/web/list_integration_test.go @@ -0,0 +1,525 @@ +//go:build integration + +package web + +// Issue 1860 (design 63 milestone 1; novox/hq ADR 0515, 0516, 0517): the operator's list end to end. +// mesh-mail-reader and mesh-mail-web are both started whole, each its own built binary played over its own +// stdin and stdout as the node's runtime plays it; the reader against the real store MESH_TEST_POSTGRES names +// (a fresh database of its own, given by its own `prepare`) and a real bus (its added event published on a +// test nats-server and bridged to its mesh/event, as mesh-mail-reader's own harness does, +// runtime_player_integration_test.go there); mesh-mail-web's every mail-reading ask carried to the reader's +// own tools/call, with the runtime's caller mesh-mail-web — so the page shows what the real reader keeps. The +// stand-ins are mail-store (the mail's text, and no account to re-read) and a fixed judgement giving the mail +// one task due today. Compiled by `go vet -tags integration ./...`; never run on this machine: the lead's try +// runs it, once design 47's R4e is delivered. It fails, never skips, without MESH_TEST_POSTGRES. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nats-io/nats.go" +) + +// listTestBound is how long the whole test may take (1860/AC4), setup included: within the merge check's own +// bound for the integration level. +const listTestBound = 2 * time.Minute + +// ---- the real store: a fresh database of the reader's own ----------------------------------------------------- + +func freshReaderDatabase(t *testing.T) string { + t.Helper() + base := os.Getenv("MESH_TEST_POSTGRES") + if base == "" { + t.Fatal("MESH_TEST_POSTGRES is unset: this integration test needs a real postgres, and never skips") + } + var b [6]byte + _, _ = rand.Read(b[:]) + name := "mw_list_" + hex.EncodeToString(b[:]) + ctx := context.Background() + admin, err := pgxpool.New(ctx, base) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + if _, err := admin.Exec(ctx, "create database "+name); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanup, err := pgxpool.New(context.Background(), base) + if err == nil { + _, _ = cleanup.Exec(context.Background(), "drop database if exists "+name+" with (force)") + cleanup.Close() + } + }) + u, err := url.Parse(base) + if err != nil { + t.Fatal(err) + } + u.Path = "/" + name + return u.String() +} + +// ---- mesh-mail-reader, started whole -------------------------------------------------------------------------- + +// readerStandIns answer what the reader asks of the mesh: mail-store (the mail's text; no account, so the +// 14-day re-reading ends at once), the fixed judgement, messenger and the operator channel. +type readerStandIns struct { + mu sync.Mutex + text string + judge func(question string) any + asked []string +} + +func (s *readerStandIns) answer(key string, body map[string]any) (any, error) { + s.mu.Lock() + s.asked = append(s.asked, key) + s.mu.Unlock() + switch { + case key == "seat:mail-store.read": + return map[string]any{"text": s.text}, nil + case key == "seat:mail-store.accounts": + return map[string]any{"accounts": []any{}}, nil + case key == "seat:mail-store.search": + return map[string]any{"searched": []any{}, "found": []any{}}, nil + case strings.HasPrefix(key, "claude-code.claude_code_judge@"): + question, _ := body["question"].(string) + raw, _ := json.Marshal(s.judge(question)) + return map[string]any{"answer": json.RawMessage(raw)}, nil + case key == "messenger.messenger_check": + return map[string]any{"allowed": true}, nil + case key == "seat:operator-channel.tell": + return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, nil + } + return nil, fmt.Errorf("the test does not answer %s", key) +} + +// readerBundle is the started reader: its own runtime played over its stdin and stdout. +type readerBundle struct { + t *testing.T + cmd *exec.Cmd + in io.WriteCloser + mu sync.Mutex + nextID int + wait map[string]chan wireMessage + stand *readerStandIns + subbed chan string +} + +func buildReader(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "mw-list-reader-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + bin := filepath.Join(dir, "mesh-mail-reader") + // -tags integration: the reader's own batch_gap_integration.go shortens its batch spacing for this build + // alone, as its own harness does. + cmd := exec.Command("go", "build", "-tags", "integration", "-o", bin, ".") + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + cmd.Dir = filepath.Join(wd, "..", "..", "..", "mesh-mail-reader", "cmd", "mesh-mail-reader") + cmd.Env = os.Environ() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("building mesh-mail-reader: %v\n%s", err, out) + } + return bin +} + +func startReaderBundle(t *testing.T, nc *nats.Conn, stand *readerStandIns) *readerBundle { + t.Helper() + bin := buildReader(t) + dir := t.TempDir() + dbPath := filepath.Join(dir, "database.url") + if err := os.WriteFile(dbPath, []byte(freshReaderDatabase(t)), 0o600); err != nil { + t.Fatal(err) + } + prep := exec.Command(bin, "prepare") + prep.Env = append(os.Environ(), "DATABASE_URL_FILE="+dbPath) + if out, err := prep.CombinedOutput(); err != nil { + t.Fatalf("mesh-mail-reader prepare: %v\n%s", err, out) + } + callersPath := filepath.Join(dir, "callers.json") + if err := os.WriteFile(callersPath, []byte(`{"callers": ["mesh-mail-web"]}`), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(bin) + cmd.Env = append(os.Environ(), "MESH_NODE="+testNode, "MESH_SERVED_MODULE=mesh-mail-reader", + "DATABASE_URL_FILE="+dbPath, "MESH_MAIL_READER_CALLERS="+callersPath) + cmd.Stderr = &prefixedLog{t: t, prefix: "mesh-mail-reader stderr: "} + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + r := &readerBundle{t: t, cmd: cmd, in: in, wait: map[string]chan wireMessage{}, stand: stand, subbed: make(chan string, 8)} + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + go r.readLoop(out) + if _, err := r.ask("initialize", map[string]any{"protocolVersion": "2025-03-26", "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "test", "version": "1"}}); err != nil { + t.Fatalf("initialize: %v", err) + } + if err := r.send(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized", "params": map[string]any{}}); err != nil { + t.Fatal(err) + } + // The real bus to the reader's hearing: every message on mesh-mail's stream becomes one mesh/event. + if _, err := nc.Subscribe("mesh.stream.mesh-mail.event.>", func(msg *nats.Msg) { + key := "mesh-mail." + strings.TrimPrefix(msg.Subject, "mesh.stream.mesh-mail.event.") + if _, err := r.ask("mesh/event", map[string]any{"envelope": map[string]any{"key": key, "node": testNode, + "body": json.RawMessage(msg.Data)}}); err != nil { + t.Errorf("delivering %s to mesh-mail-reader: %v", key, err) + } + }); err != nil { + t.Fatal(err) + } + select { + case <-r.subbed: + case <-time.After(20 * time.Second): + t.Fatal("mesh-mail-reader never asked mesh/subscribe") + } + return r +} + +func (r *readerBundle) send(m map[string]any) error { + raw, err := json.Marshal(m) + if err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + _, err = r.in.Write(append(raw, '\n')) + return err +} + +func (r *readerBundle) ask(method string, params any) (json.RawMessage, error) { + r.mu.Lock() + r.nextID++ + id := "player-" + strconv.Itoa(r.nextID) + ch := make(chan wireMessage, 1) + r.wait[id] = ch + r.mu.Unlock() + if err := r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil { + return nil, err + } + select { + case m := <-ch: + if m.Error != nil { + return nil, fmt.Errorf("%s: %s", method, m.Error.Message) + } + return m.Result, nil + case <-time.After(30 * time.Second): + return nil, fmt.Errorf("%s: no answer within 30s", method) + } +} + +func (r *readerBundle) readLoop(out io.Reader) { + dec := json.NewDecoder(out) + for { + var m wireMessage + if err := dec.Decode(&m); err != nil { + return + } + if m.Method == "" && len(m.ID) > 0 { + var id string + _ = json.Unmarshal(m.ID, &id) + r.mu.Lock() + ch := r.wait[id] + delete(r.wait, id) + r.mu.Unlock() + if ch != nil { + ch <- m + } + continue + } + if m.Method != "" { + go r.handle(m) + } + } +} + +// handle answers what the reader asks its runtime: mesh/subscribe (acked) and mesh/ask (the stand-ins). +func (r *readerBundle) handle(m wireMessage) { + reply := func(result any, errMsg string) { + out := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(m.ID)} + if errMsg != "" { + out["error"] = map[string]any{"code": -32000, "message": errMsg} + } else { + raw, _ := json.Marshal(result) + out["result"] = json.RawMessage(raw) + } + _ = r.send(out) + } + switch m.Method { + case "mesh/subscribe": + var p struct { + Pattern string `json:"pattern"` + } + _ = json.Unmarshal(m.Params, &p) + r.subbed <- p.Pattern + reply(map[string]any{}, "") + case "mesh/ask": + var p struct { + Key string `json:"key"` + Body map[string]any `json:"body"` + } + _ = json.Unmarshal(m.Params, &p) + out, err := r.stand.answer(p.Key, p.Body) + if err != nil { + r.t.Errorf("mesh-mail-reader asked %s: %v", p.Key, err) + reply(nil, err.Error()) + return + } + reply(out, "") + default: + reply(nil, "the test does not play "+m.Method) + } +} + +// call is one mail-reading verb on the real reader, called as the runtime calls it for mesh-mail-web: the +// caller in the call's _meta (the SDK's MetaCaller), never in its arguments. +func (r *readerBundle) call(verb string, args map[string]any) (any, error) { + raw, err := r.ask("tools/call", map[string]any{"name": "mail-reading." + verb, "arguments": args, + "_meta": map[string]any{"mesh/caller": testNode + ".mesh-mail-web"}}) + if err != nil { + return nil, err + } + var out struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(raw, &out); err != nil || len(out.Content) == 0 { + return nil, fmt.Errorf("mail-reading.%s answered no content: %s", verb, raw) + } + if out.IsError { + return nil, fmt.Errorf("%s", out.Content[0].Text) + } + var v any + if err := json.Unmarshal([]byte(out.Content[0].Text), &v); err != nil { + return nil, fmt.Errorf("mail-reading.%s answered no JSON: %s", verb, out.Content[0].Text) + } + return v, nil +} + +// ---- the page's mail-reading asks carried to the real reader ----------------------------------------------- + +func carryMailReading(mw *startedMailWeb, r *readerBundle) { + to := func(verb string) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { return r.call(verb, args) } + } + s := mw.mailReading + s.Entries, s.Groups, s.Status, s.Correct, s.Lessons, s.Undo = to("entries"), to("groups"), to("status"), to("correct"), to("lessons"), to("undo") + s.List, s.Handle, s.Unhandle, s.Remind, s.Record = to("list"), to("handle"), to("unhandle"), to("remind"), to("record") +} + +// ---- helpers --------------------------------------------------------------------------------------------- + +var mailIDInQuestion = regexp.MustCompile(`(?m)^mail (\d+):`) + +func pageBody(t *testing.T, mw *startedMailWeb, path string, cookie *http.Cookie) string { + t.Helper() + resp := doWithCookie(t, mw, http.MethodGet, path, nil, cookie) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: status %d:\n%s", path, resp.StatusCode, body) + } + return string(body) +} + +// sectionOfPage is one section of the list page, from its opening to the next one's. +func sectionOfPage(t *testing.T, body, id string) string { + t.Helper() + i := strings.Index(body, `id="`+id+`"`) + if i < 0 { + t.Fatalf("the page has no section %s:\n%s", id, body) + } + rest := body[i:] + if j := strings.Index(rest[1:], "
= 0 { + return rest[:j+1] + } + return rest +} + +func postForm(t *testing.T, mw *startedMailWeb, path string, form url.Values, cookie *http.Cookie) { + t.Helper() + resp := doForm(t, mw, path, form, cookie) + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("POST %s %v: status %d, want 303:\n%s", path, form, resp.StatusCode, body) + } +} + +// linesOf answers the action lines of what, from the reader's own actions verb. +func linesOf(t *testing.T, r *readerBundle, what string) []map[string]any { + t.Helper() + out, err := r.call("actions", map[string]any{}) + if err != nil { + t.Fatal(err) + } + var lines []map[string]any + for _, l := range out.(map[string]any)["actions"].([]any) { + if line := l.(map[string]any); line["what"] == what { + lines = append(lines, line) + } + } + return lines +} + +// ---- the test --------------------------------------------------------------------------------------------- + +// 1860/AC1-AC4. +func TestTheOperatorsListEndToEnd(t *testing.T) { + began := time.Now() + busURL := newTestBus(t) + nc, err := nats.Connect(busURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + + const text = "Hello, the offer for the Harbor demo is attached. Kind regards, Sara" + const taskLine = "Sign and return Sara's Harbor offer" + today := time.Now().In(Brussels).Format("2006-01-02") + stand := &readerStandIns{text: text, judge: func(question string) any { + var mails []any + for _, m := range mailIDInQuestion.FindAllStringSubmatch(question, -1) { + id, _ := strconv.Atoi(m[1]) + mails = append(mails, map[string]any{"id": id, "mark": "neither", "summary": "Sara sent the Harbor offer", + "tasks": []any{map[string]any{"line": taskLine, "due-date": today}}}) + } + return map[string]any{"mails": mails} + }} + reader := startReaderBundle(t, nc, stand) + mw := startMailWeb(t, nil, []string{"alice"}) + carryMailReading(mw, reader) + // mail-store, as mesh-mail-web reaches it: the reading pane's own read of the mail (mesh-mail's readTool). + mw.store.Read = func(args map[string]any) (any, error) { + return map[string]any{"account": "work", "folder": "INBOX", "uid": 7, "date": time.Now().UTC().Format(time.RFC3339), + "from": []any{map[string]any{"name": "Sara Claes", "address": "sara.claes@harbor.example"}}, + "to": []any{map[string]any{"address": "op@work.example"}}, "subject": "The Harbor offer", "text": text, + "attachments": []any{}}, nil + } + cookie := signIn(t, mw, "alice", "/") + + // 1860/AC1: the mail's added event, published on the real bus, becomes an entry holding the task, and the + // page's / shows it under today. + event, _ := json.Marshal(map[string]any{ + "account": "work", "folder": "INBOX", "uid": 7, "uidvalidity": 1, "message-id": "", + "in-reply-to": "", "date": time.Now().UTC().Format(time.RFC3339), + "from": []any{map[string]any{"name": "Sara Claes", "address": "sara.claes@harbor.example"}}, + "to": []any{map[string]any{"address": "op@work.example"}}, "cc": []any{}, "subject": "The Harbor offer", + "size": 2048, "flags": []any{}, "attachments": []any{}, + }) + if err := nc.Publish("mesh.stream.mesh-mail.event.mail.work.message.added", event); err != nil { + t.Fatal(err) + } + var id float64 + for deadline := time.Now().Add(45 * time.Second); id == 0; { + out, err := reader.call("entries", map[string]any{}) + if err == nil { + for _, e := range out.(map[string]any)["entries"].([]any) { + entry := e.(map[string]any) + tasks, _ := entry["tasks"].([]any) + if len(tasks) == 1 && tasks[0].(map[string]any)["line"] == taskLine { + id = entry["id"].(float64) + } + } + } + if id == 0 && time.Now().After(deadline) { + t.Fatalf("no entry with the task within 45s (last answer %v, %v)", out, err) + } + if id == 0 { + time.Sleep(500 * time.Millisecond) + } + } + body := pageBody(t, mw, "/", cookie) + if !strings.Contains(sectionOfPage(t, body, "today"), taskLine) { + t.Fatalf("the task is not under today:\n%s", body) + } + token := extractHiddenValue(body, FormTokenField) + if token == "" { + t.Fatal("the page carries no form token") + } + + // 1860/AC2: ticked with the session's token, the task is under handled today; actions holds one handled + // line, with no mail text; undo returns it. + tick := url.Values{"id": {fmt.Sprint(int64(id))}, "item": {"task 1"}, "mode": {"everything"}, "act": {"handle"}, FormTokenField: {token}} + postForm(t, mw, "/item", tick, cookie) + body = pageBody(t, mw, "/", cookie) + if !strings.Contains(sectionOfPage(t, body, "handled-today"), taskLine) || strings.Contains(sectionOfPage(t, body, "today"), taskLine) { + t.Fatalf("the ticked task is not under handled today alone:\n%s", body) + } + handled := linesOf(t, reader, "handled") + if len(handled) != 1 || handled[0]["id"] != id || handled[0]["item"] != "task 1" || handled[0]["mode"] != "everything" { + t.Fatalf("handled lines %v, want one for task 1 of mail %v", handled, id) + } + raw, _ := json.Marshal(handled[0]) + for _, mailText := range []string{"Harbor", "Sara", "offer", taskLine} { + if strings.Contains(string(raw), mailText) { + t.Fatalf("the action line %s holds mail text %q", raw, mailText) + } + } + tick.Set("act", "unhandle") + postForm(t, mw, "/item", tick, cookie) + body = pageBody(t, mw, "/", cookie) + if !strings.Contains(sectionOfPage(t, body, "today"), taskLine) { + t.Fatalf("the undone task is not back under today:\n%s", body) + } + if undone := linesOf(t, reader, "undone"); len(undone) != 1 { + t.Fatalf("undone lines %v, want one", undone) + } + + // 1860/AC3: a correction made on the page — read later, from the reading pane — is kept as a reading + // lesson and leaves one corrected line. + pane := pageBody(t, mw, "/read?id="+fmt.Sprint(int64(id))+"&mode=everything", cookie) + if !strings.Contains(pane, "the offer for the Harbor demo is attached") { + t.Fatalf("the reading pane does not show the mail's text:\n%s", pane) + } + postForm(t, mw, "/correct", url.Values{"id": {fmt.Sprint(int64(id))}, "mode": {"everything"}, "read-later": {"on"}, + FormTokenField: {token}}, cookie) + lessons, err := reader.call("lessons", map[string]any{}) + if err != nil { + t.Fatal(err) + } + if got := lessons.(map[string]any)["lessons"].([]any); len(got) != 1 || !strings.Contains(fmt.Sprint(got[0]), "read later") { + t.Fatalf("lessons %v, want the one correction", got) + } + if corrected := linesOf(t, reader, "corrected"); len(corrected) != 1 || corrected[0]["id"] != id { + t.Fatalf("corrected lines %v, want one for mail %v", corrected, id) + } + if opened := linesOf(t, reader, "opened"); len(opened) != 1 { + t.Fatalf("opened lines %v, want one: the pane opened once", opened) + } + + // 1860/AC4: the whole of it, setup included, within its bound. + if took := time.Since(began); took > listTestBound { + t.Fatalf("the test took %s, over its bound of %s", took, listTestBound) + } +}