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