package gateway import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // These tests cover the D4 read-cursor contract (Android collaboration B3): the // unreadCount carried by GET /api/v1/sessions, the advance endpoint // POST /api/v1/sessions/{id}/read, and the `session_read` frame that clears the badge // on the account's other browsers. // unreadBySession decodes GET /api/v1/sessions into a map of session id → unreadCount via // the wire key, so the test asserts the JSON contract rather than the in-package struct. func unreadBySession(t *testing.T, ts *httptest.Server, tok string) map[string]int { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list sessions status = %d, want 200 (body=%s)", resp.StatusCode, body) } var sessions []struct { ID string `json:"id"` UnreadCount int `json:"unreadCount"` } decodeDataSlice(t, body, &sessions) out := make(map[string]int, len(sessions)) for _, s := range sessions { out[s.ID] = s.UnreadCount } return out } // TestSessionsUnreadCountAndMarkRead covers the happy path end to end: a session with // unread messages reports the count, POST /read moves the cursor and answers // {sessionId, readAt}, the list drops to the messages sent after the cursor, and a // body-less POST (mark everything seen) clears the badge. func TestSessionsUnreadCountAndMarkRead(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) ctx := context.Background() sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) } for _, m := range []history.Message{ {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "a", CreatedAt: 1000}, {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "b", CreatedAt: 2000}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "c", CreatedAt: 3000}, {SessionID: sub, Role: history.RoleUser, Content: "sub", CreatedAt: 1500}, } { if err := st.Append(ctx, m); err != nil { t.Fatalf("seed %q: %v", m.Content, err) } } tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/read" // Nothing read yet: the main session is fully unread, the sub session independently so. if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 3 || got[sub] != 1 { t.Fatalf("initial unread = %+v, want main 3 / sub 1", got) } // Advance the main cursor to 2000: messages at or before it are read. resp, body := doReq(t, http.MethodPost, base, map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("mark read status = %d, want 200 (body=%s)", resp.StatusCode, body) } env := decodeBody(t, body) if len(env) != 1 { t.Fatalf("envelope = %v, want only a data field", env) } var data struct { SessionID string `json:"sessionId"` ReadAt int64 `json:"readAt"` } if err := json.Unmarshal(env["data"], &data); err != nil { t.Fatalf("decode read data: %v (body=%s)", err, body) } if data.SessionID != history.MainSessionID || data.ReadAt != 2000 { t.Fatalf("read data = %+v, want sessionId %s readAt 2000", data, history.MainSessionID) } // Only the later message stays unread, and the sub session is untouched. if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 1 || got[sub] != 1 { t.Fatalf("unread after cursor 2000 = %+v, want main 1 / sub 1", got) } // No body = "mark everything I have seen read" (resolved to now server-side). resp, body = doReq(t, http.MethodPost, base, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("body-less mark status = %d, want 200 (body=%s)", resp.StatusCode, body) } if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil { t.Fatalf("decode body-less read data: %v (body=%s)", err, body) } if data.ReadAt < 3000 { t.Fatalf("body-less readAt = %d, want >= the newest message 3000", data.ReadAt) } if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 0 || got[sub] != 1 { t.Fatalf("unread after mark-now = %+v, want main 0 / sub 1", got) } markedNow := data.ReadAt // The cursor only moves forward: re-posting a stale cursor must not re-open the badge. resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("stale mark status = %d, want 200 (body=%s)", resp.StatusCode, body) } if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil { t.Fatalf("decode stale read data: %v", err) } if data.ReadAt != markedNow { t.Fatalf("stale readAt = %d, want the cursor to stay at %d", data.ReadAt, markedNow) } if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 0 { t.Fatalf("unread after stale mark = %d, want 0", got[history.MainSessionID]) } } // TestMarkSessionReadValidation covers the failure contract: a present but unusable // `before` answers 400, an unknown session 404, a viewer 403 and a missing HistoryStore // 501 — none of which may be mistaken for a successful read. func TestMarkSessionReadValidation(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/sessions/web/read" for _, tc := range []struct { name string body any }{ {name: "zero", body: map[string]any{"before": 0}}, {name: "negative", body: map[string]any{"before": -1}}, {name: "non-numeric", body: map[string]any{"before": "abc"}}, {name: "not an object", body: "not-an-object"}, } { t.Run(tc.name, func(t *testing.T) { resp, body := doReq(t, http.MethodPost, base, tc.body, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) }) } // An empty object means "now", not "no-op". resp, body := doReq(t, http.MethodPost, base, map[string]any{}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("empty object status = %d, want 200 (body=%s)", resp.StatusCode, body) } // Unknown session → 404; the session id is not auto-created. resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown session status = %d, want 404 (body=%s)", resp.StatusCode, body) } // A viewer cannot write the cursor (owner/admin history write, like PATCH /sessions). viewer := signAny(t, jwt, "acct", auth.RoleViewer) resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, viewer) if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body) } // And no token at all is 401, not a silent success. resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body) } } // TestMarkSessionReadNotWired pins the honest degradation: with no HistoryStore injected // the endpoint answers 501 not_implemented rather than pretending the cursor moved. func TestMarkSessionReadNotWired(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web/read", map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusNotImplemented { t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // TestMarkSessionReadBroadcasts asserts the cross-end sync frame: a successful advance // pushes exactly one `session_read` frame scoped to that session and carrying the new // cursor, while a failed one (404) pushes nothing. func TestMarkSessionReadBroadcasts(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.MethodPost, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/read", map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } ev := readFakeFrame(t, c) if ev.Type != "session_read" { t.Fatalf("frame type = %q, want session_read (frame=%+v)", ev.Type, ev) } if ev.SessionID != history.MainSessionID { t.Fatalf("frame sessionId = %q, want %q", ev.SessionID, history.MainSessionID) } data, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("frame data = %+v, want object", ev.Data) } if data["readAt"] != float64(2000) { t.Fatalf("frame data.readAt = %v, want 2000", data["readAt"]) } if err := connHasNoFrame(c); err != nil { t.Fatal(err) } // A failed advance must not tell other ends the badge was cleared. resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown status = %d, want 404 (body=%s)", resp.StatusCode, body) } if err := connHasNoFrame(c); err != nil { t.Fatal(err) } }