Files
Cyrene_v2/internal/runtime/history/store_test.go
T
Aska 9c2a37c4ac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)

agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)

实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
2026-09-07 05:13:36 +08:00

426 lines
13 KiB
Go

package history
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// testLogger returns a logger that writes nowhere, keeping test output clean.
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newStore opens an isolated in-memory DB and returns a Store on it.
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))
}
// Newest first.
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/timestamp assigned when the caller omits them.
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)
}
// The message is actually persisted under the returned 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)
}
// A caller-supplied id/timestamp is preserved, not regenerated.
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()
// An IM owner-private message carries its channel platform through the round
// trip: 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)
}
// A message without an explicit platform defaults to the web lane.
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()
// A Web message tagged with a browser deviceID survives the AppendFull → List
// round trip (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 replay / context tail) reads the same device_id column.
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)
}
// A message without an explicit deviceID persists the column default ('').
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()
// Seed across the web lane (explicit + default) and one IM lane.
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)
}
}
// No platform filter → all three (the empty-platform message defaulted to 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))
}
// Filter to the IM lane → only its message.
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)
}
// Filter to the web lane → web1 + web2 (the one left without an explicit
// platform defaulted to web on insert).
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)
}
// The filter composes with the before cursor.
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)
}
}
// First page: newest 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)
}
// Cursor on the page's oldest (5000) → older page [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)
}
// Past the start → empty.
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()
// Main session exists by default.
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)
}
// Main session is immutable.
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()
// An isolated group session carries the sender display name (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)
}
// An owner-private message on the main session carries no sender name.
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)
}
// An unrelated platform on the main session should not leak into the log.
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 reads the sender_name back.
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 reads it too.
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)
}
// Platform log surfaces only the isolated (non-main) messages of the platform,
// newest first, excluding the owner-private mirror on the main session.
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])
}
// A platform with only main-session messages yields an empty log.
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)
}
}