package history import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // These tests cover the D4 per-session read cursor (design/16 §4.2, Android // collaboration B3): the unread count derived in ListSessions, the forward-only // cursor semantics of MarkSessionRead, and the 0010 migration's upgrade backfill. // TestListSessionsUnreadCountFromCursor pins the unread derivation: a message counts as // unread only when it is strictly newer than the session's cursor (created_at > read_at), // so the cursor is inclusive of the timestamp it names (a client that has rendered a // message sent at T marks T read). A fresh session starts at cursor 0 with everything // unread, and a cursor at the newest message clears the badge. func TestListSessionsUnreadCountFromCursor(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() for _, ts := range []int64{1000, 2000, 3000} { if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil { t.Fatalf("seed %d: %v", ts, err) } } // Never read: all three messages are unread (a brand-new account/session). if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 3 { t.Fatalf("initial unreadCount = %d, want 3", got) } // Cursor on a message's own timestamp counts that message as read. readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)) if err != nil { t.Fatalf("mark read: %v", err) } if readAt != 2000 { t.Fatalf("readAt = %d, want 2000", readAt) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 { t.Fatalf("unreadCount after cursor 2000 = %d, want 1 (only 3000 newer)", got) } // A cursor between two messages reads the earlier one only. if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2500)); err != nil { t.Fatalf("mark read 2500: %v", err) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 { t.Fatalf("unreadCount after cursor 2500 = %d, want 1", got) } // A cursor at the newest message clears the badge. if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil { t.Fatalf("mark read 3000: %v", err) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 { t.Fatalf("unreadCount after cursor 3000 = %d, want 0", got) } } // TestListSessionsUnreadCountSessionIsolation pins the per-session scope: each session // carries its own cursor, so reading the main session must not clear (or inflate) a sub // session's badge, and vice versa. func TestListSessionsUnreadCountSessionIsolation(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) } for _, m := range []Message{ {SessionID: MainSessionID, Role: RoleUser, Content: "main-1", CreatedAt: 1000}, {SessionID: MainSessionID, Role: RoleAssistant, Content: "main-2", CreatedAt: 2000}, {SessionID: sub, Role: RoleUser, Content: "sub-1", CreatedAt: 1500}, } { if err := st.Append(ctx, m); err != nil { t.Fatalf("seed %q: %v", m.Content, err) } } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 2 { t.Fatalf("main unreadCount = %d, want 2", got) } if got := sessionOf(t, st, sub).UnreadCount; got != 1 { t.Fatalf("sub unreadCount = %d, want 1", got) } // Reading the main session must leave the sub untouched. if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil { t.Fatalf("mark main read: %v", err) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 { t.Fatalf("main unreadCount after read = %d, want 0", got) } if got := sessionOf(t, st, sub).UnreadCount; got != 1 { t.Fatalf("sub unreadCount after main read = %d, want 1 (cursors are per-session)", got) } // A message arriving after the read re-opens the main badge. if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil { t.Fatalf("append main-3: %v", err) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 { t.Fatalf("main unreadCount after new message = %d, want 1", got) } } // TestMarkSessionReadForwardOnly covers the racing/malformed-request side of the cursor // contract: the cursor never moves backwards, so a stale or repeated request is // idempotent and can never resurrect already-read messages. func TestMarkSessionReadForwardOnly(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() for _, ts := range []int64{1000, 2000, 3000} { if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil { t.Fatalf("seed %d: %v", ts, err) } } // Advance to the newest message. readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)) if err != nil || readAt != 3000 { t.Fatalf("mark 3000 = (%d, %v), want (3000, nil)", readAt, err) } // A stale request (an end that was behind, or a retry of an older read) must not // move the cursor back and re-open the badge. readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(1000)) if err != nil { t.Fatalf("stale mark: %v", err) } if readAt != 3000 { t.Fatalf("stale mark readAt = %d, want the cursor to stay at 3000", readAt) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 { t.Fatalf("unreadCount after stale mark = %d, want 0 (never un-read)", got) } // Repeating the same cursor is a no-op, and a negative cursor is clamped by the // same MAX (it cannot move the stored value at all). if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil || readAt != 3000 { t.Fatalf("repeat mark = (%d, %v), want (3000, nil)", readAt, err) } if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(-5000)); err != nil || readAt != 3000 { t.Fatalf("negative mark = (%d, %v), want (3000, nil)", readAt, err) } } // TestMarkSessionReadZeroTimeMeansNow covers the "mark everything I have seen" call the // HTTP layer makes when the body omits before: the zero time resolves to the wall clock, // which reads every message that already exists. func TestMarkSessionReadZeroTimeMeansNow(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() seedAt := time.Now().UnixMilli() if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: seedAt}); err != nil { t.Fatalf("seed: %v", err) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 { t.Fatalf("unreadCount = %d, want 1", got) } readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.Time{}) if err != nil { t.Fatalf("mark zero time: %v", err) } if readAt < seedAt { t.Fatalf("readAt = %d, want >= the seeded message time %d", readAt, seedAt) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 { t.Fatalf("unreadCount after mark-now = %d, want 0", got) } } // TestMarkSessionReadUnknownSession pins the error contract: an id with no chat_sessions // row (including an isolated group/stranger session) is ErrSessionNotFound, so the HTTP // layer can answer 404 instead of silently reporting success for a session that does not // exist. func TestMarkSessionReadUnknownSession(t *testing.T) { t.Parallel() st := newStore(t) if _, err := st.MarkSessionRead(context.Background(), "web:missing", time.UnixMilli(1000)); !errors.Is(err, ErrSessionNotFound) { t.Fatalf("unknown session err = %v, want ErrSessionNotFound", err) } } // migrationReadAtSQL reads one of the 0010 migration files from the source tree, so the // test applies the same SQL text the embedded migration runs rather than a copy. func migrationReadAtSQL(t *testing.T, name string) string { t.Helper() b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name)) if err != nil { t.Fatalf("read migration %s: %v", name, err) } return string(b) } // columnPresent reports whether table has a column named column. func columnPresent(t *testing.T, conn *sql.DB, table, column string) bool { t.Helper() var n int if err := conn.QueryRowContext(context.Background(), "SELECT count(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&n); err != nil { t.Fatalf("pragma_table_info(%s): %v", table, err) } return n == 1 } // openLegacyDB opens an in-memory DB carrying the pre-0010 schema (chat_sessions with no // read_at column) plus two legacy sessions with prior history — the real fixed main // session "web" and a sub session — so the 0010 migration can be applied to a database // that really predates this feature. func openLegacyDB(t *testing.T) *sql.DB { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, ":memory:", testLogger()) if err != nil { t.Fatalf("open memory db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) ddls := []string{ `CREATE TABLE chat_sessions ( id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)`, `CREATE TABLE chat_messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, mode TEXT, mood TEXT, image TEXT, thinking TEXT, platform TEXT NOT NULL DEFAULT 'web', device_id TEXT NOT NULL DEFAULT '', sender_name TEXT NOT NULL DEFAULT '', adapter_id TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL)`, `CREATE INDEX idx_chat_msgs_session_created ON chat_messages (session_id, created_at DESC)`, } for _, ddl := range ddls { if _, err := conn.ExecContext(ctx, ddl); err != nil { t.Fatalf("legacy ddl: %v", err) } } legacy := []struct { sessionID string sessionType string messageAt []int64 }{ {sessionID: MainSessionID, sessionType: SessionMain, messageAt: []int64{1000, 4000}}, {sessionID: "web:legacy", sessionType: SessionSub, messageAt: []int64{1000, 2000}}, } for _, l := range legacy { if _, err := conn.ExecContext(ctx, `INSERT INTO chat_sessions (id, type, title, pinned, created_at) VALUES (?, ?, '', 0, ?)`, l.sessionID, l.sessionType, int64(100)); err != nil { t.Fatalf("insert legacy session %s: %v", l.sessionID, err) } for i, ts := range l.messageAt { if _, err := conn.ExecContext(ctx, `INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`, fmt.Sprintf("%s-%d", l.sessionID, i), l.sessionID, RoleUser, "legacy message", ts); err != nil { t.Fatalf("insert legacy message: %v", err) } } } return conn } // TestMigration0010ReadAtBackfill exercises the real 0010 migration against a legacy // database: up adds the column and backfills each session's cursor to its newest message, // so upgrade day reports 0 unread for existing history — including the fixed main session, // whose existing history is the whole point of the upgrade — and the store (which // re-issues the canonical DDL idempotently) then works on top of it; a message that arrives // after the migration is unread until it is marked read; down drops the column again. func TestMigration0010ReadAtBackfill(t *testing.T) { ctx := context.Background() conn := openLegacyDB(t) if columnPresent(t, conn, "chat_sessions", "read_at") { t.Fatal("legacy schema should not have read_at before the migration") } if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.up.sql")); err != nil { t.Fatalf("apply 0010 up: %v", err) } if !columnPresent(t, conn, "chat_sessions", "read_at") { t.Fatal("read_at should exist after the 0010 up migration") } // The backfill is each session's newest message timestamp, not a blanket 0 (which // would mark the whole existing history unread) and not the first message's. for _, tc := range []struct { sessionID string want int64 }{ {sessionID: MainSessionID, want: 4000}, {sessionID: "web:legacy", want: 2000}, } { var backfilled int64 if err := conn.QueryRowContext(ctx, `SELECT read_at FROM chat_sessions WHERE id = ?`, tc.sessionID).Scan(&backfilled); err != nil { t.Fatalf("read backfilled cursor %s: %v", tc.sessionID, err) } if backfilled != tc.want { t.Fatalf("backfilled read_at for %s = %d, want its newest legacy message %d", tc.sessionID, backfilled, tc.want) } } // The store comes up on the migrated schema and reports no unread for either // pre-existing session (upgrade day must not light up every row, main session // included); the store's INSERT OR IGNORE for the main session must not overwrite // the backfilled cursor. st, err := NewStore(ctx, conn, testLogger()) if err != nil { t.Fatalf("new store on migrated db: %v", err) } for _, sessionID := range []string{MainSessionID, "web:legacy"} { if got := sessionOf(t, st, sessionID).UnreadCount; got != 0 { t.Fatalf("legacy session %s unreadCount = %d, want 0 after backfill", sessionID, got) } } // A message written after the migration is unread, and marking it read clears it. if err := st.Append(ctx, Message{SessionID: "web:legacy", Role: RoleAssistant, Content: "new", CreatedAt: 9000}); err != nil { t.Fatalf("append after migration: %v", err) } if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 1 { t.Fatalf("unreadCount after post-migration message = %d, want 1", got) } if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 { t.Fatalf("main unreadCount = %d, want 0 (untouched by the sub session's new message)", got) } readAt, err := st.MarkSessionRead(ctx, "web:legacy", time.UnixMilli(9000)) if err != nil || readAt != 9000 { t.Fatalf("mark read = (%d, %v), want (9000, nil)", readAt, err) } if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 0 { t.Fatalf("unreadCount after mark = %d, want 0", got) } if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.down.sql")); err != nil { t.Fatalf("apply 0010 down: %v", err) } if columnPresent(t, conn, "chat_sessions", "read_at") { t.Fatal("read_at should be dropped by the 0010 down migration") } }