Files
AskaEth fe4c702f87
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 未读面 unreadCount 进 message_persisted 帧(契约 v0.76 ②)
- broadcastPersisted 是 Web 与 IM 两条路的**共用**函数 ⇒ 两处口径天然一致,不存在分叉
- 口径 = 绝对未读 + 主人单一已读游标(chat_sessions.read_at)⇒ 多端共享
- 可选键 omitempty:只在 >0 且确实算得出来时出现;0 与「算不出来」都不顶位
  (0 只能从「会话有游标」推出,否则那个 0 就是编的)⇒ 缺席 = 老行为
- Docs/api-client.md:v0.76 ① 按实测改标「未实现且无实现路径」(见下),② 已实现,
  并更正「GET /sessions 的 unreadCount 自 D4 起已在」、新增 §4.13 与 §7.4 第 23 条

⚠️ ① toolCalls[] 本版未落地(取证结论,非推断):chat_messages.turn_id(编排器随机路标识
turn-<16hex>)与 flow_graphs.turn_id(trace.FlowGraph.ID = <session>-<轮次序号>,进程内递增)
不同源,真实库中 66 条消息行 vs 21 条图行字面交集为空 ⇒ 无法 join;按时间窗硬凑属于猜,不做。
补齐路径:链路图带回合标识(新键 + 迁移 0023 起)+ 落盘列,之后 toolCalls[] 是一条批量 SQL。

我亲自复跑的变异:去掉帧里的 n > 0 守卫 ⇒ TestBroadcastPersistedUnreadCountAbsentWhenUnknown
与 TestBroadcastPersistedCarriesUnreadCount 双双 FAIL(断言「0 不该顶位」)⇒ 测试是真护栏。
2026-10-06 07:58:42 +08:00

414 lines
17 KiB
Go
Raw Permalink 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")
}
}
// 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)
}
}