- persist: formatTime 定宽;loadLatestSQL 补 rowid 兜底;New 时归一化历史行(避免 LoadLatest 挑到更旧检查点) - channel/session: 写入定宽;listSessionSQL 补 rowid 兜底;核实无 INSERT 吃 DDL 默认值,故只加注释不改默认值 - 新增回归测试并做变异验证(改回 RFC3339Nano 即 FAIL)
416 lines
12 KiB
Go
416 lines
12 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)
|
|
}
|
|
}
|
|
|
|
// insertSessionAt writes a channel_sessions row directly with a caller-supplied
|
|
// raw created_at/updated_at, standing in for a mapping written by past code.
|
|
func insertSessionAt(t *testing.T, handle *sql.DB, cid, at string) {
|
|
t.Helper()
|
|
if _, err := handle.ExecContext(context.Background(),
|
|
`INSERT INTO channel_sessions (platform, channel_id, session_id, actor_role, created_at, updated_at)
|
|
VALUES ('qq', ?, ?, 'owner', ?, ?)`,
|
|
cid, "sess-"+cid, at, at); err != nil {
|
|
t.Fatalf("insert %s: %v", cid, err)
|
|
}
|
|
}
|
|
|
|
// TestListNewestFirstDeterministic covers the ordering contract: three mappings
|
|
// written within the same coarse clock tick share one created_at, so only the
|
|
// rowid tiebreak can define their order. List is newest first, so the row
|
|
// inserted last (highest rowid) must come first. The cache is deliberately empty
|
|
// so List reads the persisted order instead of cached entries.
|
|
func TestListNewestFirstDeterministic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, handle := newTestManager(t)
|
|
ctx := context.Background()
|
|
|
|
const at = "2026-09-07T03:20:05.830000000Z"
|
|
for _, cid := range []string{"direct-a", "direct-b", "direct-c"} {
|
|
insertSessionAt(t, handle, cid, at)
|
|
}
|
|
|
|
got, err := m.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("List = %v", err)
|
|
}
|
|
want := []string{"direct-c", "direct-b", "direct-a"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("List = %d sessions, want %d", len(got), len(want))
|
|
}
|
|
for i, w := range want {
|
|
if got[i].ChannelID != w {
|
|
t.Errorf("got[%d].ChannelID = %q, want %q (rowid DESC tiebreak)", i, got[i].ChannelID, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestListNewestFirstByCreatedAt checks the created_at DESC half: the mapping
|
|
// with the latest fixed-width timestamp is listed first even though it was
|
|
// inserted first and therefore has the lowest rowid.
|
|
func TestListNewestFirstByCreatedAt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, handle := newTestManager(t)
|
|
ctx := context.Background()
|
|
|
|
insertSessionAt(t, handle, "direct-newest", "2026-09-07T03:20:05.830978900Z")
|
|
insertSessionAt(t, handle, "direct-oldest", "2026-09-07T03:20:05.830000000Z")
|
|
|
|
got, err := m.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("List = %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("List = %d sessions, want 2", len(got))
|
|
}
|
|
if got[0].ChannelID != "direct-newest" || got[1].ChannelID != "direct-oldest" {
|
|
t.Errorf("List order = [%q %q], want newest first", got[0].ChannelID, got[1].ChannelID)
|
|
}
|
|
}
|
|
|
|
// TestPersistWritesFixedWidthTimestamps pins the write path: GetOrCreate must
|
|
// store created_at/updated_at with the fixed-width layout so channel_sessions
|
|
// text ordering matches time ordering. RFC3339Nano would drop trailing zeros
|
|
// and persist a variable-width value instead.
|
|
func TestPersistWritesFixedWidthTimestamps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, handle := newTestManager(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := m.GetOrCreate(ctx, directMsg("o-100"), testOwner()); err != nil {
|
|
t.Fatalf("GetOrCreate = %v", err)
|
|
}
|
|
var created, updated string
|
|
if err := handle.QueryRowContext(ctx,
|
|
`SELECT created_at, updated_at FROM channel_sessions WHERE channel_id = ?`,
|
|
"direct-o-100").Scan(&created, &updated); err != nil {
|
|
t.Fatalf("read stored timestamps: %v", err)
|
|
}
|
|
if len(created) != sessionTimeLen || len(updated) != sessionTimeLen {
|
|
t.Errorf("stored timestamps = %q/%q (%d/%d bytes), want fixed width %d",
|
|
created, updated, len(created), len(updated), sessionTimeLen)
|
|
}
|
|
if created != updated {
|
|
t.Errorf("created_at %q != updated_at %q, want the same value on insert", created, updated)
|
|
}
|
|
}
|