Files
Cyrene_v2/internal/channel/session/manager_test.go
T
Aska 04bfa2c7a7
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): admin 列表端点接真实数据——persona/session/memory 补 List() 并注入 gateway.Deps
根因:gateway.Server 定义 SessionStore/PersonaStore/MemoryStore 接口但生产从未满足:
- 底层 manager 无 List() 方法;
- wireFrontend 的 gateway.Deps{} 未传 Session/Personas/Memories。
→ /api/v1/sessions|personas 与 /api/v1/admin/{sessions,personas,memories} 恒返回 []。

改动:
- persona/registry:Registry.List() []Character(并发安全、值拷贝)。
- channel/session:SessionManager.List(ctx)(查 channel_sessions 表+按 actor_role 重建)。
- runtime/memory:Store.List(ctx)(全表,复用 scanItems)。
- gateway 三 Store 接口改为返回域类型(Character/MemoryItem),映射统一放 collect* 侧。
- cmd/cyrene/wire.go:Deps 补传 Session/Personas/Memories。
- .gitignore:cyrene→/cyrene(锚定根,不再误伤 cmd/cyrene 目录)。

实机验证:/api/v1/admin/personas 返回 {id:cyrene,name:昔涟,isDefault:true}。
2026-09-06 14:05:34 +08:00

321 lines
8.1 KiB
Go

package session
import (
"context"
"database/sql"
"errors"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// newTestManager opens a private in-memory SQLite handle and builds a manager
// over it. Each caller gets an isolated, non-shared database so parallel tests
// never contend.
func newTestManager(t *testing.T) (*SessionManager, *sql.DB) {
t.Helper()
ctx := context.Background()
handle, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("db.Open(:memory:) = %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
m, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New() = %v", err)
}
return m, handle
}
func TestGetOrCreateCreatesAndReuses(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
msg := directMsg("o-100")
first, err := m.GetOrCreate(ctx, msg, testOwner())
if err != nil {
t.Fatalf("GetOrCreate() = %v", err)
}
if first.SessionID == "" {
t.Fatal("first SessionID empty")
}
if !first.Owner || !first.MemWritable {
t.Fatalf("owner session Owner=%v MemWritable=%v, want true/true", first.Owner, first.MemWritable)
}
second, err := m.GetOrCreate(ctx, msg, testOwner())
if err != nil {
t.Fatalf("second GetOrCreate() = %v", err)
}
if second.SessionID != first.SessionID {
t.Errorf("SessionID changed across calls: %q != %q", first.SessionID, second.SessionID)
}
if second.ChannelID != first.ChannelID {
t.Errorf("ChannelID changed: %q != %q", first.ChannelID, second.ChannelID)
}
}
func TestGetOrCreateDifferentChannelsDiffer(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
s1, err := m.GetOrCreate(ctx, directMsg("o-100"), testOwner())
if err != nil {
t.Fatalf("GetOrCreate(sender1) = %v", err)
}
s2, err := m.GetOrCreate(ctx, directMsg("o-100"), testOwner())
if err != nil {
t.Fatalf("GetOrCreate(sender2) = %v", err)
}
// Same real account maps to the same session, so the channel id must be the
// same; a different account must differ.
s3, err := m.GetOrCreate(ctx, directMsg("stranger-1"), testOwner())
if err != nil {
t.Fatalf("GetOrCreate(stranger) = %v", err)
}
if s1.SessionID != s2.SessionID {
t.Error("same account produced different sessions")
}
if s3.SessionID == s1.SessionID {
t.Error("different account collided onto the same session")
}
}
func TestGetOrCreateGroupIsolation(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
// Owner speaking in a group: isolated context, memory read-only.
sess, err := m.GetOrCreate(ctx, groupMsg("o-100"), testOwner())
if err != nil {
t.Fatalf("GetOrCreate(owner group) = %v", err)
}
if !sess.IsGroup {
t.Error("group message not flagged IsGroup")
}
if !sess.Owner {
t.Error("owner in group should still be Owner")
}
if sess.MemWritable {
t.Error("group session must be read-only (MemWritable=false) even for owner")
}
if got := sess.Env(); got != restrict.ActorGroupMember {
t.Errorf("Env() = %q, want group_member", got)
}
}
func TestGetOrCreateStrangerIsolation(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
sess, err := m.GetOrCreate(ctx, directMsg("stranger-1"), testOwner())
if err != nil {
t.Fatalf("GetOrCreate(stranger) = %v", err)
}
if sess.Owner {
t.Error("stranger marked Owner")
}
if sess.MemWritable {
t.Error("stranger session must be read-only (MemWritable=false)")
}
if sess.IsGroup {
t.Error("stranger direct message marked IsGroup")
}
}
func TestGetOrCreatePersistenceAcrossManagers(t *testing.T) {
t.Parallel()
ctx := context.Background()
handle, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("db.Open = %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
m1, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#1 = %v", err)
}
first, err := m1.GetOrCreate(ctx, directMsg("o-100"), testOwner())
if err != nil {
t.Fatalf("m1.GetOrCreate = %v", err)
}
// A brand-new manager over the same database must recover the same binding.
m2, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#2 = %v", err)
}
second, err := m2.GetOrCreate(ctx, directMsg("o-100"), testOwner())
if err != nil {
t.Fatalf("m2.GetOrCreate = %v", err)
}
if second.SessionID != first.SessionID {
t.Errorf("session binding lost across managers: %q != %q", first.SessionID, second.SessionID)
}
}
func TestGetOrCreateConcurrent(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
msg := directMsg("o-100")
const n = 20
ids := make([]string, n)
errs := make([]error, n)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
i := i
go func() {
defer wg.Done()
sess, err := m.GetOrCreate(ctx, msg, testOwner())
if err != nil {
errs[i] = err
return
}
ids[i] = sess.SessionID
}()
}
wg.Wait()
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("goroutine %d: GetOrCreate = %v", i, errs[i])
}
if ids[i] == "" {
t.Fatalf("goroutine %d produced empty session id", i)
}
if ids[i] != ids[0] {
t.Errorf("goroutine %d session id %q != %q", i, ids[i], ids[0])
}
}
}
func TestList(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
if _, err := m.GetOrCreate(ctx, directMsg("o-100"), testOwner()); err != nil {
t.Fatalf("GetOrCreate(owner): %v", err)
}
if _, err := m.GetOrCreate(ctx, directMsg("stranger-1"), testOwner()); err != nil {
t.Fatalf("GetOrCreate(stranger): %v", err)
}
if _, err := m.GetOrCreate(ctx, groupMsg("g-member-1"), testOwner()); err != nil {
t.Fatalf("GetOrCreate(group): %v", err)
}
got, err := m.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 3 {
t.Fatalf("List = %d sessions, want 3", len(got))
}
var owner, stranger, group bool
for _, s := range got {
switch s.ChannelID {
case "direct-o-100":
if !s.Owner || !s.MemWritable || s.IsGroup {
t.Errorf("owner session flags = %+v, want owner/writable/not-group", s)
}
owner = true
case "direct-stranger-1":
if s.Owner || s.MemWritable || s.IsGroup {
t.Errorf("stranger session flags = %+v, want non-owner/read-only/not-group", s)
}
stranger = true
case groupChannelPrefix + "g1":
if !s.IsGroup || s.MemWritable {
t.Errorf("group session flags = %+v, want group/read-only", s)
}
group = true
}
}
if !owner || !stranger || !group {
t.Fatalf("missing sessions: owner=%v stranger=%v group=%v", owner, stranger, group)
}
}
func TestListRecoveredFromTable(t *testing.T) {
t.Parallel()
ctx := context.Background()
handle, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("db.Open = %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
m1, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#1 = %v", err)
}
if _, err := m1.GetOrCreate(ctx, directMsg("o-100"), testOwner()); err != nil {
t.Fatalf("m1.GetOrCreate = %v", err)
}
// A brand-new manager over the same DB has an empty cache: List must
// reconstruct the session from the backing table (cross-restart path).
m2, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#2 = %v", err)
}
got, err := m2.List(ctx)
if err != nil {
t.Fatalf("List = %v", err)
}
if len(got) != 1 {
t.Fatalf("List = %d sessions, want 1", len(got))
}
s := got[0]
if s.ChannelID != "direct-o-100" || s.SessionID == "" {
t.Fatalf("recovered session = %+v, want direct-o-100", s)
}
if !s.Owner || !s.MemWritable {
t.Errorf("recovered owner flags = %+v, want owner/writable", s)
}
}
func TestGetOrCreateInvalidChannel(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
msg := adapter.UnifiedMessage{
Platform: "qq",
ChannelType: adapter.ChannelDirect,
}
_, err := m.GetOrCreate(ctx, msg, testOwner())
if !errors.Is(err, ErrInvalidChannel) {
t.Fatalf("GetOrCreate(invalid) error = %v, want ErrInvalidChannel", err)
}
}
func TestNewRejectsNilDB(t *testing.T) {
t.Parallel()
if _, err := New(context.Background(), nil, nil); !errors.Is(err, ErrNilDB) {
t.Fatalf("New(nil db) error = %v, want ErrNilDB", err)
}
}