Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
192 lines
5.7 KiB
Go
192 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|