- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 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)
349 lines
14 KiB
Go
349 lines
14 KiB
Go
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")
|
||
}
|
||
}
|