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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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