package history import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // 这些测试覆盖 D4 的每会话读游标(design/16 §4.2,Android 协同 B3): // ListSessions 推导出的未读数、MarkSessionRead 只向前推进的游标语义, // 以及 0010 迁移的升级回填。 // TestListSessionsUnreadCountFromCursor 锁定未读推导:一条消息只有在严格新于 // 会话游标(created_at > read_at)时才计为未读,因此游标包含它所命名的那个 // 时间戳(渲染过 T 时刻消息的客户端把 T 标为已读)。全新会话从游标 0 开始、 // 一切皆未读,而游标落在最新消息处会清空角标。 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) } } // 从未读过:三条消息全未读(全新账号/会话)。 if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 3 { t.Fatalf("initial unreadCount = %d, want 3", got) } // 游标压在消息自身的毫秒上就算该消息已读。 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) } // 游标落在两条消息之间只读到较早那条。 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) } // 游标落在最新消息处清空角标。 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 锁定每会话作用域:每个会话携带 // 自己的游标,因此读取主会话不得清空(或撑大)子会话的角标,反之亦然。 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) } // 读取主会话不得动子会话。 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) } // 读取之后到达的消息会重新点亮主会话角标。 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 覆盖游标契约中竞争/畸形请求的一面:游标绝不后 // 退,因此过期或重复的请求是幂等的,绝不能让已读消息复活。 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) } } // 推进到最新消息。 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) } // 过期请求(落后的端,或一次更旧读取的重试)不得把游标后退并重新点亮角标。 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) } // 重复同一游标是空操作,负游标被同一个 MAX 钳制(它根本无法挪动已存的值)。 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 覆盖 HTTP 层在请求体省略 before 时发出的 // 「把我看到的全部标为已读」调用:零时刻解析为墙上时钟,从而读到所有已存在的 // 消息。 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 锁定错误契约:没有 chat_sessions 行的 id // (包括隔离的群聊/陌生人会话)是 ErrSessionNotFound,因此 HTTP 层可以回答 404, // 而不是为一个不存在的会话悄悄报告成功。 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) } } // migrationFileSQL 从源码树读取一个迁移文件,使测试应用的是嵌入式迁移执行的那份 // SQL 文本,而不是一份副本。 func migrationFileSQL(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 报告 table 是否有名为 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 打开一个携带 0010 之前 schema(chat_sessions 没有 read_at 列)的 // 内存库,放入两个带既有历史的遗留会话 —— 真实固定主会话 "web" 与一个子会话 // —— 使 0010 迁移能应用到一个真正早于此特性的数据库上。 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 拿真实的 0010 迁移跑在一个遗留库上:up 增加该 // 列并把每个会话的游标回填到其最新消息,使升级日对既有历史报告 0 未读 —— 包括 // 固定主会话,它的既有历史正是这次升级的全部意义 —— 而存储(它幂等地重发规范 // DDL)随后能在其上工作;迁移之后到达的消息在标记已读前都是未读;down 再次 // 丢弃该列。 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, migrationFileSQL(t, "0010_chat_session_read_at.up.sql")); err != nil { t.Fatalf("apply 0010 up: %v", err) } // 让模拟的遗留库追到当前 head(0011 增加会话/消息 persona 列),因为存储 // 读取的是规范 schema:落后一个迁移的库会缺 persona_id。生产中的升级顺序是 // db.MigrateUp,因此这复现了真实升级的结果。 if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0011_session_persona.up.sql")); err != nil { t.Fatalf("apply 0011 up: %v", err) } if !columnPresent(t, conn, "chat_sessions", "read_at") { t.Fatal("read_at should exist after the 0010 up migration") } // 回填的是每个会话最新消息的时间戳,不是一刀切的 0(那会把既有历史全部标为 // 未读),也不是第一条消息的时间戳。 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) } } // 存储建立在迁移后的 schema 上,对两个既有会话都报告无未读(升级日不得点亮 // 每一行,主会话包括在内);存储为主会话做的 INSERT OR IGNORE 不得覆盖回填 // 的游标。 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) } } // 迁移之后写入的消息是未读的,标记已读即清除。 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, migrationFileSQL(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") } } // TestStoreUnreadCount 锁定**单会话**未读数(契约 v0.76 的实时面用它换角标): // 它与 ListSessions 的列表口径同源 —— 同一个 `created_at > read_at` 谓词, // 因此同一时刻两条面给出同一个数字,不会有第二份未读定义。 // // 四条边界一并钉住:空 id ⇒ 0;**没有会话行的消息流** ⇒ 0(不是「全部未读」, // 那种会话根本没有读游标、也不出现在已读游标面上);严格 `>`(游标压在 T 上的 // 消息算已读);游标推进后归零、其后的新消息重新计入。 func TestStoreUnreadCount(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}, // 没有 chat_sessions 行的消息流(隔离渠道会话 / 后台思考会话的形状): // 消息落得进去,会话行从不插入。 {SessionID: "sess-backthink", Role: RoleAssistant, Content: "no session row", CreatedAt: 9000}, } { if err := st.Append(ctx, m); err != nil { t.Fatalf("seed %q: %v", m.Content, err) } } // 与列表口径逐条同值 —— 这是「不许有第二份未读定义」的机器化断言。 for _, sessionID := range []string{MainSessionID, sub} { got, err := st.UnreadCount(ctx, sessionID) if err != nil { t.Fatalf("UnreadCount(%s): %v", sessionID, err) } if want := sessionOf(t, st, sessionID).UnreadCount; got != want { t.Fatalf("UnreadCount(%s) = %d, but the session list says %d", sessionID, got, want) } } if got, err := st.UnreadCount(ctx, ""); err != nil || got != 0 { t.Fatalf("UnreadCount(\"\") = (%d, %v), want (0, nil)", got, err) } if got, err := st.UnreadCount(ctx, "sess-backthink"); err != nil || got != 0 { t.Fatalf("UnreadCount(sess-backthink) = (%d, %v), want (0, nil): 没有会话行就没有游标,不得把它的历史当成未读", got, err) } // 严格 `>`:游标压在最新消息的毫秒上就把角标清零。 if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil { t.Fatalf("mark main read: %v", err) } if got, err := st.UnreadCount(ctx, MainSessionID); err != nil || got != 0 { t.Fatalf("UnreadCount(main) after cursor 2000 = (%d, %v), want (0, nil)", got, err) } // 游标之后的新消息重新计入,且**只**计它 —— 子会话的游标没被碰过。 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, err := st.UnreadCount(ctx, MainSessionID); err != nil || got != 1 { t.Fatalf("UnreadCount(main) after a newer message = (%d, %v), want (1, nil)", got, err) } if got, err := st.UnreadCount(ctx, sub); err != nil || got != 1 { t.Fatalf("UnreadCount(sub) = (%d, %v), want (1, nil): 读主会话不得动子会话", got, err) } }