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) } }