package handlers import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync" "testing" "time" "github.com/bridge/ai-customer-service/internal/domain/audit" "github.com/bridge/ai-customer-service/internal/domain/ticket" "github.com/bridge/ai-customer-service/internal/store/memory" ) // ticketAuditRecorder implements AuditRecorder for testing. type ticketAuditRecorder struct { events []audit.Event mu sync.Mutex } func (r *ticketAuditRecorder) Add(_ context.Context, event audit.Event) error { r.mu.Lock() defer r.mu.Unlock() r.events = append(r.events, event) return nil } func (r *ticketAuditRecorder) eventsOfType(action string) []audit.Event { r.mu.Lock() defer r.mu.Unlock() var out []audit.Event for _, e := range r.events { if e.Action == action { out = append(out, e) } } return out } // mockTicketService implements TicketService for testing, // mirroring TicketWorkflowStore behavior (calls store + writes audit). type mockTicketService struct { mu sync.Mutex tickets *memory.TicketStore auditRecorder *ticketAuditRecorder calls []struct { method string args []string } } func newMockTicketService(auditRecorder *ticketAuditRecorder) *mockTicketService { return &mockTicketService{tickets: memory.NewTicketStore(), auditRecorder: auditRecorder} } func (m *mockTicketService) ListOpen(ctx context.Context, limit int) ([]ticket.Ticket, error) { return m.tickets.ListOpen(ctx, limit) } func (m *mockTicketService) GetByID(ctx context.Context, id string) (*ticket.Ticket, error) { return m.tickets.GetByID(ctx, id) } func (m *mockTicketService) Assign(ctx context.Context, ticketID, agentID, actorID, sourceIP string, now time.Time) error { m.mu.Lock() m.calls = append(m.calls, struct { method string args []string }{method: "Assign", args: []string{ticketID, agentID, actorID, sourceIP}}) m.mu.Unlock() if err := m.tickets.Assign(ctx, ticketID, agentID, actorID, sourceIP, now); err != nil { return err } evt := audit.Event{ ID: fmt.Sprintf("wf-%d", now.UnixNano()), Type: "ticket_state_changed", Action: "assign", TicketID: ticketID, ActorID: actorID, SourceIP: sourceIP, AfterState: map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned}, CreatedAt: now, } m.auditRecorder.Add(ctx, evt) return nil } func (m *mockTicketService) Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error { m.mu.Lock() m.calls = append(m.calls, struct { method string args []string }{method: "Resolve", args: []string{ticketID, resolution, actorID, sourceIP}}) m.mu.Unlock() if err := m.tickets.Resolve(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil { return err } evt := audit.Event{ ID: fmt.Sprintf("wf-%d", now.UnixNano()), Type: "ticket_state_changed", Action: "resolve", TicketID: ticketID, ActorID: actorID, SourceIP: sourceIP, AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusResolved}, CreatedAt: now, } m.auditRecorder.Add(ctx, evt) return nil } func (m *mockTicketService) Close(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error { m.mu.Lock() m.calls = append(m.calls, struct { method string args []string }{method: "Close", args: []string{ticketID, resolution, actorID, sourceIP}}) m.mu.Unlock() if err := m.tickets.Close(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil { return err } evt := audit.Event{ ID: fmt.Sprintf("wf-%d", now.UnixNano()), Type: "ticket_state_changed", Action: "close", TicketID: ticketID, ActorID: actorID, SourceIP: sourceIP, AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusClosed}, CreatedAt: now, } m.auditRecorder.Add(ctx, evt) return nil } func (m *mockTicketService) lastCall() []string { m.mu.Lock() defer m.mu.Unlock() if len(m.calls) == 0 { return nil } return m.calls[len(m.calls)-1].args } func TestTicketHandlerAssignAuditsStateChange(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) if err := svc.tickets.Create(context.Background(), &ticket.Ticket{ ID: "ticket-1", SessionID: "session-1", Priority: ticket.PriorityP1, Status: ticket.StatusOpen, HandoffReason: "refund", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create() error = %v", err) } h := NewTicketHandler(svc, auditRecorder) h.now = func() time.Time { return now.Add(time.Minute) } req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-1/assign?agent_id=agent-007", nil) req = withActor(req, "admin-1", "admin") resp := httptest.NewRecorder() h.Assign(resp, req) if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200", resp.Code) } assignEvents := auditRecorder.eventsOfType("assign") if len(assignEvents) != 2 { t.Fatalf("audit assign count = %d, want 2", len(assignEvents)) } event := assignEvents[1] if event.Type != "ticket_state_changed" { t.Fatalf("event.Type = %s, want ticket_state_changed", event.Type) } if event.TicketID != "ticket-1" { t.Fatalf("ticket id = %s, want ticket-1", event.TicketID) } if event.AfterState["assigned_to"] != "agent-007" { t.Fatalf("assigned_to = %v, want agent-007", event.AfterState["assigned_to"]) } if event.AfterState["status"] != ticket.StatusAssigned { t.Fatalf("status = %v, want %s", event.AfterState["status"], ticket.StatusAssigned) } if event.ActorID != "admin-1" { t.Fatalf("actor_id = %v, want admin-1", event.ActorID) } } func TestTicketHandlerResolveAuditsStateChange(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) if err := svc.tickets.Create(context.Background(), &ticket.Ticket{ ID: "ticket-2", SessionID: "session-2", Priority: ticket.PriorityP2, Status: ticket.StatusAssigned, AssignedTo: "agent-1", HandoffReason: "quota", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create() error = %v", err) } h := NewTicketHandler(svc, auditRecorder) h.now = func() time.Time { return now.Add(2 * time.Minute) } req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-2/resolve?resolution=handled", nil) req = withActor(req, "admin-2", "admin") resp := httptest.NewRecorder() h.Resolve(resp, req) if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200", resp.Code) } resolveEvents := auditRecorder.eventsOfType("resolve") if len(resolveEvents) != 2 { t.Fatalf("audit resolve count = %d, want 2", len(resolveEvents)) } event := resolveEvents[1] if event.Action != "resolve" { t.Fatalf("action = %s, want resolve", event.Action) } if event.AfterState["resolution"] != "handled" { t.Fatalf("resolution = %v, want handled", event.AfterState["resolution"]) } if event.AfterState["status"] != ticket.StatusResolved { t.Fatalf("status = %v, want %s", event.AfterState["status"], ticket.StatusResolved) } if event.ActorID != "admin-2" { t.Fatalf("actor_id = %v, want admin-2", event.ActorID) } stored := svc.tickets.List() if len(stored) != 1 || stored[0].Status != ticket.StatusResolved { t.Fatalf("stored status = %#v", stored) } if stored[0].ResolvedAt == nil { t.Fatalf("expected resolved_at to be set") } } func TestTicketHandlerCloseRequiresResolution(t *testing.T) { h := NewTicketHandler(newMockTicketService(&ticketAuditRecorder{}), &ticketAuditRecorder{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-1/close", nil) resp := httptest.NewRecorder() h.Close(resp, req) if resp.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.Code) } var payload map[string]any if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { t.Fatalf("json decode error = %v", err) } errPayload := payload["error"].(map[string]any) if errPayload["code"] != "CS_REQ_4007" { t.Fatalf("error code = %v, want CS_REQ_4007", errPayload["code"]) } } func TestTicketHandlerAssignPassesActorAndSourceIP(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) if err := svc.tickets.Create(context.Background(), &ticket.Ticket{ ID: "ticket-3", SessionID: "session-3", Priority: ticket.PriorityP0, Status: ticket.StatusOpen, HandoffReason: "urgent", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create() error = %v", err) } h := NewTicketHandler(svc, auditRecorder) h.now = func() time.Time { return now } req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-3/assign?agent_id=agent-x", nil) req = withActor(req, "supervisor-1", "supervisor") req.RemoteAddr = "192.168.1.100:12345" resp := httptest.NewRecorder() h.Assign(resp, req) if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200", resp.Code) } args := svc.lastCall() if len(args) < 4 { t.Fatalf("call args count = %d, want at least 4", len(args)) } if args[2] != "supervisor-1" { t.Fatalf("actor_id = %s, want supervisor-1", args[2]) } if args[3] != "192.168.1.100" { t.Fatalf("source_ip = %s, want 192.168.1.100", args[3]) } } func TestTicketHandlerClosePassesActorAndSourceIP(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) if err := svc.tickets.Create(context.Background(), &ticket.Ticket{ ID: "ticket-4", SessionID: "session-4", Priority: ticket.PriorityP1, Status: ticket.StatusResolved, HandoffReason: "done", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create() error = %v", err) } h := NewTicketHandler(svc, auditRecorder) h.now = func() time.Time { return now } req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-4/close?resolution=closed+by+agent", nil) req = withActor(req, "admin-1", "admin") req.RemoteAddr = "10.0.0.1:54321" resp := httptest.NewRecorder() h.Close(resp, req) if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200", resp.Code) } args := svc.lastCall() if len(args) < 4 { t.Fatalf("call args count = %d, want at least 4", len(args)) } if args[2] != "admin-1" { t.Fatalf("actor_id = %s, want admin-1", args[2]) } if args[3] != "10.0.0.1" { t.Fatalf("source_ip = %s, want 10.0.0.1", args[3]) } } // P1-3: GET /api/v1/customer-service/tickets/{id} — Phase 1 minimum implementation func TestTicketHandlerGetByID_NotFound(t *testing.T) { h := NewTicketHandler(newMockTicketService(&ticketAuditRecorder{}), &ticketAuditRecorder{}) req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/tickets/nonexistent-id", nil) resp := httptest.NewRecorder() h.Get(resp, req) if resp.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", resp.Code) } var payload map[string]any if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { t.Fatalf("json decode error = %v", err) } errPayload := payload["error"].(map[string]any) if errPayload["code"] != "CS_TICKET_4001" { t.Fatalf("error code = %v, want CS_TICKET_4001", errPayload["code"]) } } func TestTicketHandlerGetByID_Success(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) expectedTicket := &ticket.Ticket{ ID: "ticket-get-1", SessionID: "session-get-1", UserID: "user-get-1", Priority: ticket.PriorityP1, Status: ticket.StatusOpen, HandoffReason: "refund", AssignedTo: "", ContextSnapshot: map[string]any{"channel": "widget", "open_id": "u1"}, CreatedAt: now, UpdatedAt: now, } if err := svc.tickets.Create(context.Background(), expectedTicket); err != nil { t.Fatalf("Create() error = %v", err) } h := NewTicketHandler(svc, auditRecorder) h.now = func() time.Time { return now } req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/tickets/ticket-get-1", nil) resp := httptest.NewRecorder() h.Get(resp, req) if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200", resp.Code) } var payload map[string]any if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { t.Fatalf("json decode error = %v", err) } tkt, ok := payload["ticket"].(map[string]any) if !ok { t.Fatalf("ticket field missing or not a map: %v", payload) } // Verify all critical fields if tkt["id"] != "ticket-get-1" { t.Fatalf("id = %v, want ticket-get-1", tkt["id"]) } if tkt["session_id"] != "session-get-1" { t.Fatalf("session_id = %v, want session-get-1", tkt["session_id"]) } if tkt["user_id"] != "user-get-1" { t.Fatalf("user_id = %v, want user-get-1", tkt["user_id"]) } if tkt["priority"] != "P1" { t.Fatalf("priority = %v, want P1", tkt["priority"]) } if tkt["status"] != "open" { t.Fatalf("status = %v, want open", tkt["status"]) } if tkt["handoff_reason"] != "refund" { t.Fatalf("handoff_reason = %v, want refund", tkt["handoff_reason"]) } if tkt["context_snapshot"] == nil { t.Fatalf("context_snapshot is nil, want non-nil") } } func TestTicketHandlerAssign_RejectsWhenActorOnlyProvidedByQuery(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) if err := svc.tickets.Create(context.Background(), &ticket.Ticket{ ID: "ticket-auth-1", SessionID: "session-auth-1", Priority: ticket.PriorityP1, Status: ticket.StatusOpen, HandoffReason: "refund", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create() error = %v", err) } h := NewTicketHandler(svc, auditRecorder) req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-auth-1/assign?agent_id=agent-007&actor_id=forged-admin", nil) resp := httptest.NewRecorder() h.Assign(resp, req) if resp.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", resp.Code) } } func TestTicketHandlerResolve_ReturnsNotFoundForMissingTicket(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) h := NewTicketHandler(svc, auditRecorder) req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/missing-ticket/resolve?resolution=handled", nil) req = withActor(req, "agent-404", "agent") resp := httptest.NewRecorder() h.Resolve(resp, req) if resp.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", resp.Code) } var payload map[string]any if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { t.Fatalf("json decode error = %v", err) } errPayload := payload["error"].(map[string]any) if errPayload["code"] != "CS_TICKET_4001" { t.Fatalf("error code = %v, want CS_TICKET_4001", errPayload["code"]) } } func TestTicketHandlerClose_ReturnsConflictWhenTicketNotResolved(t *testing.T) { auditRecorder := &ticketAuditRecorder{} svc := newMockTicketService(auditRecorder) now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) if err := svc.tickets.Create(context.Background(), &ticket.Ticket{ ID: "ticket-close-conflict-1", SessionID: "session-close-conflict-1", Priority: ticket.PriorityP1, Status: ticket.StatusAssigned, AssignedTo: "agent-1", HandoffReason: "refund", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create() error = %v", err) } h := NewTicketHandler(svc, auditRecorder) req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-close-conflict-1/close?resolution=user+confirmed", nil) req = withActor(req, "supervisor-1", "supervisor") resp := httptest.NewRecorder() h.Close(resp, req) if resp.Code != http.StatusConflict { t.Fatalf("status = %d, want 409", resp.Code) } var payload map[string]any if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { t.Fatalf("json decode error = %v", err) } errPayload := payload["error"].(map[string]any) if errPayload["code"] != "CS_TICKET_4093" { t.Fatalf("error code = %v, want CS_TICKET_4093", errPayload["code"]) } }