package support import ( "context" "errors" "fmt" "os" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestStaffQueueClaimReleaseIsolation(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := context.Background() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) var hasTable bool if err := pg.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'support_tickets' )`).Scan(&hasTable); err != nil { t.Fatalf("schema probe: %v", err) } if !hasTable { t.Skip("support_tickets missing") } companyID := uuid.New() ownerID := uuid.New() agentA := uuid.New() agentB := uuid.New() prefix := companyID.String()[:8] _, err = pg.Exec(ctx, `INSERT INTO companies (id, name, language) VALUES ($1, $2, 'en')`, companyID, "Support Desk Co "+prefix) if err != nil { t.Fatalf("seed company: %v", err) } for _, u := range []struct { id uuid.UUID email string role string }{ {ownerID, fmt.Sprintf("owner-%s@example.test", prefix), ""}, {agentA, fmt.Sprintf("agent-a-%s@example.test", prefix), "support_staff"}, {agentB, fmt.Sprintf("agent-b-%s@example.test", prefix), "support_staff"}, } { _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, staff_role) VALUES ($1, $2, $3, 'x', false, false, true, NULLIF($4, ''))`, u.id, u.email, u.email, u.role) if err != nil { // staff_role column may be missing — retry without it _, err2 := pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active) VALUES ($1, $2, $3, 'x', false, false, true)`, u.id, u.email, u.email) if err2 != nil { t.Fatalf("seed user: %v / %v", err, err2) } } _, err = pg.Exec(ctx, ` INSERT INTO memberships (company_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')`, companyID, u.id) if err != nil { t.Fatalf("seed membership: %v", err) } } t.Cleanup(func() { cctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, _ = pg.Exec(cctx, `DELETE FROM support_notifications WHERE ticket_id IN (SELECT id FROM support_tickets WHERE company_id = $1)`, companyID) _, _ = pg.Exec(cctx, `DELETE FROM support_messages WHERE company_id = $1`, companyID) _, _ = pg.Exec(cctx, `DELETE FROM support_tickets WHERE company_id = $1`, companyID) _, _ = pg.Exec(cctx, `DELETE FROM memberships WHERE company_id = $1`, companyID) _, _ = pg.Exec(cctx, `DELETE FROM users WHERE id IN ($1,$2,$3)`, ownerID, agentA, agentB) _, _ = pg.Exec(cctx, `DELETE FROM companies WHERE id = $1`, companyID) }) svc := NewService(pg) ticket, err := svc.Create(ctx, companyID, ownerID, CreateInput{ Subject: "Claim race probe", Category: "bug", Priority: "normal", Body: "Please help", }) if err != nil { t.Fatalf("create: %v", err) } // Customer isolation still holds. if _, err := svc.GetForUser(ctx, companyID, agentA, ticket.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("staff must not get ticket via customer path: %v", err) } // Inbox scope includes unassigned for agent A. list, total, err := svc.ListAdmin(ctx, ListFilter{ Scope: ScopeInbox, ActorID: agentA, FullAdmin: false, }, 50, 0) if err != nil { t.Fatalf("list inbox: %v", err) } found := false for _, row := range list { if row.ID == ticket.ID { found = true break } } if !found || total < 1 { t.Fatalf("inbox missing unassigned ticket (found=%v total=%d)", found, total) } // Agent A claims. claimed, err := svc.Claim(ctx, ticket.ID, AgentActor{UserID: agentA}) if err != nil { t.Fatalf("claim A: %v", err) } if claimed.AssigneeAdminUserID == nil || *claimed.AssigneeAdminUserID != agentA { t.Fatalf("assignee=%v want A", claimed.AssigneeAdminUserID) } // Agent B claim conflict. if _, err := svc.Claim(ctx, ticket.ID, AgentActor{UserID: agentB}); !errors.Is(err, ErrAlreadyClaimed) { t.Fatalf("claim B err=%v want ErrAlreadyClaimed", err) } // Agent B cannot see assigned-to-A ticket via GetAdminForActor. if _, err := svc.GetAdminForActor(ctx, ticket.ID, AgentActor{UserID: agentB}); !errors.Is(err, ErrNotFound) { t.Fatalf("B get err=%v want ErrNotFound", err) } // Agent B inbox must not include A's ticket. listB, _, err := svc.ListAdmin(ctx, ListFilter{ Scope: ScopeInbox, ActorID: agentB, FullAdmin: false, }, 50, 0) if err != nil { t.Fatalf("list B: %v", err) } for _, row := range listB { if row.ID == ticket.ID { t.Fatalf("B inbox leaked A's ticket") } } // scope=all forbidden for agents. if _, _, err := svc.ListAdmin(ctx, ListFilter{ Scope: ScopeAll, ActorID: agentA, FullAdmin: false, }, 10, 0); !errors.Is(err, ErrForbidden) { t.Fatalf("scope=all err=%v want ErrForbidden", err) } // Release by A returns to queue; B can claim. if _, err := svc.Release(ctx, ticket.ID, AgentActor{UserID: agentA}); err != nil { t.Fatalf("release: %v", err) } if _, err := svc.Claim(ctx, ticket.ID, AgentActor{UserID: agentB}); err != nil { t.Fatalf("claim B after release: %v", err) } } func TestNormalizeListScopeDefaults(t *testing.T) { got, err := NormalizeListScope("", false) if err != nil || got != ScopeInbox { t.Fatalf("agent default=%q err=%v", got, err) } got, err = NormalizeListScope("", true) if err != nil || got != ScopeAll { t.Fatalf("admin default=%q err=%v", got, err) } if _, err := NormalizeListScope(ScopeAll, false); !errors.Is(err, ErrForbidden) { t.Fatalf("agent all err=%v", err) } }