issue 1860: prove the operator's list end to end (integration, compiled only)
Both bundles start whole: mesh-mail-reader against a fresh database on the real store and an added event on a real bus, mesh-mail-web's mail-reading asks carried to the reader's own tools/call as mesh-mail-web, with stand-ins for mail-store and a fixed judgement. A task due today shows under today, ticked through the page it is under handled today with one handled line holding no mail text, undo returns it, and a correction from the reading pane keeps a lesson and one corrected line, within two minutes. pgx joins mesh-mail-web's go.mod for the test's fresh database; the lead's try runs it.
This commit is contained in:
@@ -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:], "<section "); j >= 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": "<harbor-offer@harbor.example>",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user