eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net/http"
8 "strings"
9 "testing"
10 "time"
11
12 "lchat/internal/bot"
13 "lchat/internal/e2ee"
14)
15
16// postTypedAs posts a typed (non-chat) protocol payload — flow.state here.
17func postTypedAs(t *testing.T, tsURL string, sess *bot.Session, threadID string, threadKey []byte, payload map[string]any) {
18 t.Helper()
19 payload["ts"] = time.Now().UnixMilli()
20 blob, err := e2ee.EncryptJSON(threadKey, payload)
21 if err != nil {
22 t.Fatal(err)
23 }
24 body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
25 resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body))
26 if err != nil {
27 t.Fatal(err)
28 }
29 resp.Body.Close()
30 if resp.StatusCode != http.StatusOK {
31 t.Fatalf("post typed: %d", resp.StatusCode)
32 }
33}
34
35// TestFlowStateMutesBot pins the one-Eleven handshake: while a client-run
36// scripted flow holds the floor in a DM (flow.state active), the bot's brain
37// stays silent — the member's typed answers belong to the script. Ceding the
38// floor un-mutes it. Ordered processing proves the silence: replies come in
39// message order, so if the muted "hello" had been answered, its reply would
40// precede /help's.
41func TestFlowStateMutesBot(t *testing.T) {
42 ts, s := newTestServer(t)
43 owner := provisionNamed(t, ts.URL, "Owner", false)
44 eleven := provisionNamed(t, ts.URL, "Eleven", true)
45 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven)
46
47 ready := make(chan struct{})
48 ctx, cancel := context.WithCancel(context.Background())
49 defer cancel()
50 rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false),
51 Ready: func() { close(ready) }, Logf: t.Logf}
52 go rt.Run(ctx)
53 awaitReady(t, ready)
54
55 // The script takes the floor; the member's answer must get NO reply.
56 postTypedAs(t, ts.URL, owner, dmID, dmKey, map[string]any{"type": "flow.state", "active": true})
57 postAs(t, ts.URL, owner, dmID, dmKey, "Paul") // a scripted answer, not a prompt
58
59 // The script cedes; a real question gets its reply.
60 postTypedAs(t, ts.URL, owner, dmID, dmKey, map[string]any{"type": "flow.state", "active": false})
61 postAs(t, ts.URL, owner, dmID, dmKey, "/help")
62 waitFor(t, "post-flow reply", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 })
63
64 replies := botReplies(t, s, dmID, dmKey, "Eleven")
65 if len(replies) != 1 {
66 t.Fatalf("got %d replies, want 1 (the mid-flow answer must be ignored): %+v", len(replies), replies)
67 }
68 if !strings.Contains(replies[0].Text, "/theme") {
69 t.Fatalf("the one reply should be /help's: %+v", replies[0])
70 }
71}
72