| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | func 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. |
| 41 | func 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 | |