- 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 不该顶位」)⇒ 测试是真护栏。
414 lines
17 KiB
Go
414 lines
17 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")
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|