Files
Cyrene_v2/internal/runtime/history/session_read_test.go
T
AskaEth 09fba77f87
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/history(15 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 4 条行尾注释的翻译,代码前缀全等)
- 交叉引用(design/16 §4.6、D4/B3、0010 迁移文件名)逐字保留
- 保留未译技术词:DDL/rowid/LIKE/LEFT JOIN/DTO/rune/WS/actor 及全部标识符与表名
- 顺带副作用:store.go、session_persona_test.go 由注释文字改变而变得 gofmt 干净(未运行 gofmt -w)
2026-10-04 12:28:48 +08:00

349 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}