Files
Cyrene_v2/internal/runtime/history/store_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

536 lines
17 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"
"errors"
"io"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// testLogger 返回一个不写任何地方的日志器,保持测试输出干净。
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newStore 打开一个隔离的内存库并在其上返回 Store。
func newStore(t *testing.T) *Store {
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() })
st, err := NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new store: %v", err)
}
return st
}
func TestNewStoreNilDB(t *testing.T) {
t.Parallel()
if _, err := NewStore(context.Background(), nil, testLogger()); !errors.Is(err, ErrNilDB) {
t.Fatalf("err = %v, want wrap of ErrNilDB", err)
}
}
func TestAppendAndListNewestFirst(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for i, ts := range []int64{1000, 2000, 3000} {
m := Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi", CreatedAt: ts}
if err := st.Append(ctx, m); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("len = %d, want 3", len(msgs))
}
// 最新优先。
if msgs[0].CreatedAt != 3000 || msgs[1].CreatedAt != 2000 || msgs[2].CreatedAt != 1000 {
t.Fatalf("order = %+v, want [3000 2000 1000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt, msgs[2].CreatedAt})
}
// 调用方省略时分配 id/时间戳。
empty := Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"}
if err := st.Append(ctx, empty); err != nil {
t.Fatalf("append no ts: %v", err)
}
msgs, err = st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list after append: %v", err)
}
if msgs[0].ID == "" || msgs[0].CreatedAt == 0 {
t.Fatalf("append did not fill id/created_at: %+v", msgs[0])
}
}
func TestAppendFullReturnsID(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.ID == "" {
t.Fatal("AppendFull did not assign an id")
}
if full.CreatedAt == 0 {
t.Fatal("AppendFull did not assign a created_at")
}
if full.SessionID != MainSessionID || full.Role != RoleUser || full.Content != "hi" {
t.Fatalf("AppendFull returned %+v, want the stored message unchanged plus id/created_at", full)
}
// 消息确实以返回的 id 落盘。
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].ID != full.ID {
t.Fatalf("list = %+v, want exactly the persisted message id %q", msgs, full.ID)
}
// 调用方传入的 id/时间戳被保留,而不是重新生成。
preset, err := st.AppendFull(ctx, Message{ID: "preset-id", SessionID: MainSessionID, Role: RoleAssistant, Content: "ok", CreatedAt: 42})
if err != nil {
t.Fatalf("append full preset: %v", err)
}
if preset.ID != "preset-id" || preset.CreatedAt != 42 {
t.Fatalf("AppendFull did not preserve preset id/created_at: %+v", preset)
}
}
func TestAppendFullListPlatformRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// 一条 IM 主人私聊消息在 AppendFull → List 往返中携带它的渠道平台。
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "从微信来", Platform: "weixin-ilink"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.Platform != "weixin-ilink" {
t.Fatalf("AppendFull platform = %q, want weixin-ilink", full.Platform)
}
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].Platform != "weixin-ilink" || msgs[0].Content != "从微信来" {
t.Fatalf("list round trip = %+v, want platform weixin-ilink", msgs)
}
// 未显式指定平台的消息默认为 web 通道。
web, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
if err != nil {
t.Fatalf("append web default: %v", err)
}
if web.Platform != PlatformWeb {
t.Fatalf("default platform = %q, want web", web.Platform)
}
}
func TestAppendFullListDeviceIDRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// 一条带浏览器 deviceID 的 Web 消息活过 AppendFull → List 往返
// (design/16 §4.5)。
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "来自浏览器", DeviceID: "web:chrome:a1b2c3"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.DeviceID != "web:chrome:a1b2c3" {
t.Fatalf("AppendFull DeviceID = %q, want web:chrome:a1b2c3", full.DeviceID)
}
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].DeviceID != "web:chrome:a1b2c3" || msgs[0].Content != "来自浏览器" {
t.Fatalf("list round trip = %+v, want DeviceID web:chrome:a1b2c3", msgs)
}
// Recent(WS 回放 / 上下文尾部)读同一个 device_id 列。
recent, err := st.Recent(ctx, MainSessionID, 5)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(recent) != 1 || recent[0].DeviceID != "web:chrome:a1b2c3" {
t.Fatalf("recent = %+v, want DeviceID web:chrome:a1b2c3", recent)
}
// 未显式指定 deviceID 的消息持久化列默认值(空)。
om, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
if err != nil {
t.Fatalf("append empty device: %v", err)
}
if om.DeviceID != "" {
t.Fatalf("empty DeviceID = %q, want empty", om.DeviceID)
}
}
func TestListPlatformFilter(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// 跨 web 通道(显式 + 默认)与一条 IM 通道播种。
for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "web1", Platform: PlatformWeb, CreatedAt: 1000},
{SessionID: MainSessionID, Role: RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000},
{SessionID: MainSessionID, Role: RoleUser, Content: "web2", CreatedAt: 3000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// 不带平台过滤 → 三条全出(未指定平台的那条在插入时默认为 web)。
all, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list all: %v", err)
}
if len(all) != 3 {
t.Fatalf("all = %d, want 3", len(all))
}
// 过滤到 IM 通道 → 只有它的消息。
tg, err := st.List(ctx, MainSessionID, 0, 50, "telegram")
if err != nil {
t.Fatalf("list telegram: %v", err)
}
if len(tg) != 1 || tg[0].Content != "tg" {
t.Fatalf("telegram page = %+v, want [tg]", tg)
}
// 过滤到 web 通道 → web1 + web2(那条未显式指定平台的在插入时默认为 web)。
web, err := st.List(ctx, MainSessionID, 0, 50, PlatformWeb)
if err != nil {
t.Fatalf("list web: %v", err)
}
if len(web) != 2 {
t.Fatalf("web page = %d, want 2 (%+v)", len(web), web)
}
// 过滤与 before 游标可组合。
older, err := st.List(ctx, MainSessionID, 2000, 50, PlatformWeb)
if err != nil {
t.Fatalf("list web before: %v", err)
}
if len(older) != 1 || older[0].Content != "web1" {
t.Fatalf("web before page = %+v, want [web1]", older)
}
}
func TestListPaginationBefore(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, ts := range []int64{1000, 2000, 3000, 4000, 5000, 6000} {
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "x", CreatedAt: ts}); err != nil {
t.Fatalf("append %d: %v", ts, err)
}
}
// 第一页:最新 2 条。
page, err := st.List(ctx, MainSessionID, 0, 2, "")
if err != nil {
t.Fatalf("first page: %v", err)
}
if len(page) != 2 || page[0].CreatedAt != 6000 || page[1].CreatedAt != 5000 {
t.Fatalf("first page = %+v, want [6000 5000]", page)
}
// 游标落在该页最旧那条(5000)→ 更旧的一页 [4000 3000]。
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2, "")
if err != nil {
t.Fatalf("next page: %v", err)
}
if len(next) != 2 || next[0].CreatedAt != 4000 || next[1].CreatedAt != 3000 {
t.Fatalf("next page = %+v, want [4000 3000]", next)
}
// 越过起点 → 空。
tail, err := st.List(ctx, MainSessionID, 1000, 50, "")
if err != nil {
t.Fatalf("tail: %v", err)
}
if len(tail) != 0 {
t.Fatalf("tail = %+v, want empty", tail)
}
}
func TestRecentChronological(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, ts := range []int64{1000, 2000, 3000, 4000, 5000} {
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "x", CreatedAt: ts}); err != nil {
t.Fatalf("append %d: %v", ts, err)
}
}
recent, err := st.Recent(ctx, MainSessionID, 3)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(recent) != 3 || recent[0].CreatedAt != 3000 || recent[1].CreatedAt != 4000 || recent[2].CreatedAt != 5000 {
t.Fatalf("recent = %+v, want [3000 4000 5000]", recent)
}
}
func TestNewSubSessionAndListSessions(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// 主会话默认存在。
sessions, err := st.ListSessions(ctx)
if err != nil {
t.Fatalf("list sessions initial: %v", err)
}
if len(sessions) != 1 || sessions[0].ID != MainSessionID || sessions[0].Type != SessionMain {
t.Fatalf("initial sessions = %+v, want [main web]", sessions)
}
id, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
if !strings.HasPrefix(id, MainSessionID+":") {
t.Fatalf("sub id = %q, want prefix web:", id)
}
sessions, err = st.ListSessions(ctx)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 2 || sessions[0].ID != MainSessionID || sessions[0].Type != SessionMain {
t.Fatalf("sessions[0] = %+v, want main web first", sessions[0])
}
if sessions[1].ID != id || sessions[1].Type != SessionSub {
t.Fatalf("sessions[1] = %+v, want sub %s", sessions[1], id)
}
}
func TestDeleteSession(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)
}
if err := st.Append(ctx, Message{SessionID: sub, Role: RoleUser, Content: "x", CreatedAt: 1000}); err != nil {
t.Fatalf("append: %v", err)
}
if err := st.DeleteSession(ctx, sub); err != nil {
t.Fatalf("delete sub: %v", err)
}
msgs, err := st.List(ctx, sub, 0, 50, "")
if err != nil {
t.Fatalf("list after delete: %v", err)
}
if len(msgs) != 0 {
t.Fatalf("messages after delete = %+v, want empty", msgs)
}
sessions, err := st.ListSessions(ctx)
if err != nil {
t.Fatalf("list sessions after delete: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("sessions after delete = %+v, want [main]", sessions)
}
// 主会话不可变。
if err := st.DeleteSession(ctx, MainSessionID); !errors.Is(err, ErrImmutableMain) {
t.Fatalf("delete main err = %v, want ErrImmutableMain", err)
}
}
func TestSenderNameRoundTripOnListRecentAndPlatformLog(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// 隔离的群聊会话携带发送者显示名(design/16 §4.6)。
groupID := "sess-g"
if err := st.Append(ctx, Message{SessionID: groupID, Role: RoleUser, Content: "hello all", Platform: "telegram", SenderName: "张三", DeviceID: "telegram:-20:9", CreatedAt: 1000}); err != nil {
t.Fatalf("append group msg: %v", err)
}
// 主会话上主人的私聊消息不携带发送者名。
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi", Platform: "telegram", CreatedAt: 900}); err != nil {
t.Fatalf("append main msg: %v", err)
}
// 主会话上一个无关平台不得泄进该日志。
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "web msg", Platform: "web", CreatedAt: 800}); err != nil {
t.Fatalf("append web msg: %v", err)
}
// List 读回 sender_name。
ls, err := st.List(ctx, groupID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(ls) != 1 || ls[0].SenderName != "张三" || ls[0].Content != "hello all" {
t.Fatalf("list = %+v, want SenderName 张三", ls)
}
// Recent 也读它。
rs, err := st.Recent(ctx, groupID, 5)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(rs) != 1 || rs[0].SenderName != "张三" {
t.Fatalf("recent = %+v, want SenderName 张三", rs)
}
// 平台日志只浮现该平台的隔离(非主)消息,最新优先,排除主会话上的主人
// 私聊镜像。
log, err := st.ListPlatformLog(ctx, "telegram", 0, 50)
if err != nil {
t.Fatalf("platform log: %v", err)
}
if len(log) != 1 {
t.Fatalf("platform log = %+v, want only the isolated group message", log)
}
if log[0].SessionID != groupID || log[0].SenderName != "张三" {
t.Fatalf("platform log[0] = %+v, want group session with SenderName 张三", log[0])
}
// 只有主会话消息的平台得到空日志。
if log, err := st.ListPlatformLog(ctx, "web", 0, 50); err != nil || len(log) != 0 {
t.Fatalf("web platform log = %+v, err=%v; want empty", log, err)
}
}
func TestAdapterLogFiltersByAdapter(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// 跨两个适配器、一条空适配器的 web 消息,以及一条仍携带适配器的主会话
// 主人私聊镜像播种(design/16 §4.4)。
for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
{SessionID: "sess-g1", Role: RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000},
{SessionID: "sess-g2", Role: RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", SenderName: "李四", CreatedAt: 3000},
{SessionID: MainSessionID, Role: RoleUser, Content: "web-msg", Platform: PlatformWeb, CreatedAt: 4000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// 按适配器日志同时包含主人私聊的主会话镜像与群聊消息,最新优先。
kook, err := st.ListAdapterLog(ctx, "kook:3bef", 0, 50)
if err != nil {
t.Fatalf("adapter log kook:3bef: %v", err)
}
if len(kook) != 2 {
t.Fatalf("kook adapter log = %d, want 2 (%+v)", len(kook), kook)
}
if kook[0].Content != "kook-group" || kook[0].SessionID != "sess-g1" {
t.Fatalf("kook[0] = %+v, want newest kook-group", kook[0])
}
if kook[1].Content != "kook-owner" || kook[1].SessionID != MainSessionID {
t.Fatalf("kook[1] = %+v, want owner mirror on main session", kook[1])
}
// 另一个适配器只得到它自己的通道。
tg, err := st.ListAdapterLog(ctx, "telegram:conf0", 0, 50)
if err != nil {
t.Fatalf("adapter log telegram:conf0: %v", err)
}
if len(tg) != 1 || tg[0].Content != "tg-group" {
t.Fatalf("telegram adapter log = %+v, want [tg-group]", tg)
}
// 未知 / 空适配器得到非 nil 的空切片。
none, err := st.ListAdapterLog(ctx, "kook:unknown", 0, 50)
if err != nil {
t.Fatalf("adapter log unknown: %v", err)
}
if none == nil || len(none) != 0 {
t.Fatalf("unknown adapter log = %#v, want non-nil empty slice", none)
}
empty, err := st.ListAdapterLog(ctx, "", 0, 50)
if err != nil {
t.Fatalf("adapter log empty: %v", err)
}
if empty == nil || len(empty) != 0 {
t.Fatalf("empty adapter log = %#v, want non-nil empty slice", empty)
}
// before 游标在该适配器的消息流中向后翻页。
older, err := st.ListAdapterLog(ctx, "kook:3bef", 2000, 50)
if err != nil {
t.Fatalf("adapter log before: %v", err)
}
if len(older) != 1 || older[0].Content != "kook-owner" {
t.Fatalf("kook before page = %+v, want [kook-owner]", older)
}
}
func TestAllLogSurfacesOnlyAdapterMessages(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
{SessionID: MainSessionID, Role: RoleUser, Content: "web-msg", Platform: PlatformWeb, CreatedAt: 2000},
{SessionID: "sess-g1", Role: RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", CreatedAt: 3000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// 总览只浮现 IM 适配器来源;web 消息被排除。
all, err := st.ListAllLog(ctx, 0, 50)
if err != nil {
t.Fatalf("all log: %v", err)
}
if len(all) != 2 {
t.Fatalf("all log = %d, want 2 (%+v)", len(all), all)
}
if all[0].Content != "tg-group" || all[1].Content != "kook-owner" {
t.Fatalf("all log order = %+v, want [tg-group kook-owner]", all)
}
// web 消息绝不泄进适配器总览。
for _, m := range all {
if m.Content == "web-msg" {
t.Fatalf("web message leaked into adapter overview: %+v", m)
}
}
// 空存储得到非 nil 的空切片。
st2 := newStore(t)
e, err := st2.ListAllLog(ctx, 0, 50)
if err != nil {
t.Fatalf("empty all log: %v", err)
}
if e == nil || len(e) != 0 {
t.Fatalf("empty all log = %#v, want non-nil empty slice", e)
}
}