package gateway import ( "context" "encoding/json" "net/http" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // These tests cover the D3 realtime-sync contract: a successful message edit or // delete must reach the account's other browsers as a WS frame, not only the client // that issued the REST call (otherwise every other browser has to re-read to converge). // TestPatchMessageBroadcastsEdited covers ①: PATCH /api/v1/messages/{id} answers 200 // and pushes exactly one `message_edited` frame scoped to the edited message's own // session, carrying the new content (and the mode/createdAt a client renders with). // The frame's data is the updated message DTO with thinking stripped, matching the // message_persisted contract; the REST reply keeps thinking (owner/admin-only read). func TestPatchMessageBroadcastsEdited(t *testing.T) { t.Parallel() ctx := context.Background() srv, ts, jwt, st := newHistoryEnv(t) c := newFakeConn(srv.hub, 32) srv.hub.register(c) sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) } tok := signAny(t, jwt, "acct", auth.RoleOwner) seedMessage := func(sessionID string) history.Message { t.Helper() return seedFullMessage(t, st, history.Message{ SessionID: sessionID, Role: history.RoleUser, Content: "old body", Mode: "chat", Thinking: "internal chain", CreatedAt: 1000, }) } for _, tc := range []struct { name string sessionID string }{ {name: "main session", sessionID: history.MainSessionID}, {name: "sub session", sessionID: sub}, } { t.Run(tc.name, func(t *testing.T) { msg := seedMessage(tc.sessionID) resp, body := doReq(t, http.MethodPatch, ts.URL+"/api/v1/messages/"+msg.ID, map[string]any{"content": "new body"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("patch status = %d, want 200 (body=%s)", resp.StatusCode, body) } // The REST reply is the full DTO for the issuing client. var reply historyMessage if err := json.Unmarshal(decodeBody(t, body)["data"], &reply); err != nil { t.Fatalf("decode reply: %v (body=%s)", err, body) } if reply.Content != "new body" || reply.Thinking != "internal chain" { t.Fatalf("reply = %+v, want edited content + kept thinking", reply) } ev := readFakeFrame(t, c) if ev.Type != "message_edited" { t.Fatalf("frame type = %q, want message_edited (frame=%+v)", ev.Type, ev) } if ev.SessionID != tc.sessionID { t.Fatalf("frame sessionId = %q, want %q", ev.SessionID, tc.sessionID) } data, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("frame data = %+v, want object", ev.Data) } if data["id"] != msg.ID || data["content"] != "new body" { t.Fatalf("frame data = %+v, want id=%s content=new body", data, msg.ID) } if data["sessionId"] != tc.sessionID { t.Fatalf("frame data.sessionId = %v, want %q", data["sessionId"], tc.sessionID) } if data["mode"] != "chat" { t.Fatalf("frame data.mode = %v, want chat", data["mode"]) } if _, isNum := data["createdAt"].(float64); !isNum { t.Fatalf("frame data.createdAt = %v, want a number", data["createdAt"]) } if _, leaked := data["thinking"]; leaked { t.Fatalf("frame data = %+v, thinking must be stripped from the broadcast", data) } if err := connHasNoFrame(c); err != nil { t.Fatal(err) } }) } } // TestDeleteMessageBroadcastsDeleted covers ②: DELETE /api/v1/messages/{id} answers // 200, really removes the row, and pushes one `message_deleted` frame carrying the id // plus the owning session (read before the row disappears) so a remote browser can // drop it without re-reading. func TestDeleteMessageBroadcastsDeleted(t *testing.T) { t.Parallel() ctx := context.Background() srv, ts, jwt, st := newHistoryEnv(t) c := newFakeConn(srv.hub, 32) srv.hub.register(c) sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) } msg := seedFullMessage(t, st, history.Message{SessionID: sub, Role: history.RoleUser, Content: "to be removed", CreatedAt: 2000}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/messages/"+msg.ID, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) } ev := readFakeFrame(t, c) if ev.Type != "message_deleted" { t.Fatalf("frame type = %q, want message_deleted (frame=%+v)", ev.Type, ev) } if ev.SessionID != sub { t.Fatalf("frame sessionId = %q, want %q", ev.SessionID, sub) } data, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("frame data = %+v, want object", ev.Data) } if data["id"] != msg.ID { t.Fatalf("frame data.id = %v, want %s", data["id"], msg.ID) } if err := connHasNoFrame(c); err != nil { t.Fatal(err) } // The broadcast must not claim a delete that did not happen. remaining, err := st.List(ctx, sub, 0, 50, "") if err != nil { t.Fatalf("list after delete: %v", err) } for _, m := range remaining { if m.ID == msg.ID { t.Fatalf("message %s still stored after a successful delete", msg.ID) } } } // TestDeleteMessageUnknownIDBroadcastsNothing pins the failure side: a 404 delete // (unknown id) must not push a `message_deleted` frame, or a client would drop a // message that still exists. func TestDeleteMessageUnknownIDBroadcastsNothing(t *testing.T) { t.Parallel() srv, ts, jwt, _ := newHistoryEnv(t) c := newFakeConn(srv.hub, 32) srv.hub.register(c) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/messages/msg-does-not-exist", nil, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("delete status = %d, want 404 (body=%s)", resp.StatusCode, body) } if err := connHasNoFrame(c); err != nil { t.Fatal(err) } }