Files
ai-customer-service/test/integration/ticket_handler_integration_test.go
Your Name 087de4e102 fix(audit): use uuid.New() for ticket workflow audit IDs
Fixes 'invalid input syntax for type uuid' error when writing ticket
workflow audit logs. The audit Event.ID field was using fmt.Sprintf
with nanoseconds ('wf-%d') which doesn't match PostgreSQL's uuid type.

Also adds uuid import to ticket_workflow.go.

Verified: full chain webhook→assign→resolve→close produces 3 audit
logs correctly, no more 'invalid uuid' errors in logs.
2026-05-04 13:44:39 +08:00

355 lines
11 KiB
Go

package integration
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/bridge/ai-customer-service/internal/app"
"github.com/bridge/ai-customer-service/internal/config"
"github.com/bridge/ai-customer-service/internal/domain/audit"
"github.com/bridge/ai-customer-service/internal/domain/session"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
"github.com/bridge/ai-customer-service/internal/http/handlers"
"github.com/bridge/ai-customer-service/internal/platform/logging"
"github.com/bridge/ai-customer-service/internal/store/memory"
)
// --------------------------------------------------
// Mock infrastructure
// --------------------------------------------------
type ticketIntgAuditRecorder struct {
events []audit.Event
}
func (r *ticketIntgAuditRecorder) Add(_ context.Context, event audit.Event) error {
r.events = append(r.events, event)
return nil
}
func (r *ticketIntgAuditRecorder) eventsOfType(action string) []audit.Event {
var out []audit.Event
for _, e := range r.events {
if e.Action == action {
out = append(out, e)
}
}
return out
}
// mockTicketSvcForHandler wraps memory.TicketStore + provides TicketService interface.
type mockTicketSvcForHandler struct {
store *memory.TicketStore
audit *ticketIntgAuditRecorder
}
func newMockTicketSvcForHandler(auditRecorder *ticketIntgAuditRecorder) *mockTicketSvcForHandler {
return &mockTicketSvcForHandler{
store: memory.NewTicketStore(),
audit: auditRecorder,
}
}
func (m *mockTicketSvcForHandler) ListOpen(ctx context.Context, limit int) ([]ticket.Ticket, error) {
return m.store.ListOpen(ctx, limit)
}
func (m *mockTicketSvcForHandler) GetByID(ctx context.Context, id string) (*ticket.Ticket, error) {
return m.store.GetByID(ctx, id)
}
func (m *mockTicketSvcForHandler) Assign(ctx context.Context, ticketID, agentID, actorID, sourceIP string, now time.Time) error {
if err := m.store.Assign(ctx, ticketID, agentID, actorID, sourceIP, now); err != nil {
return err
}
m.audit.Add(ctx, audit.Event{
ID: "audit-assign-1",
Type: "ticket_state_changed",
Action: "assign",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned},
CreatedAt: now,
})
return nil
}
func (m *mockTicketSvcForHandler) Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error {
if err := m.store.Resolve(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil {
return err
}
m.audit.Add(ctx, audit.Event{
ID: "audit-resolve-1",
Type: "ticket_state_changed",
Action: "resolve",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusResolved},
CreatedAt: now,
})
return nil
}
func (m *mockTicketSvcForHandler) Close(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error {
if err := m.store.Close(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil {
return err
}
m.audit.Add(ctx, audit.Event{
ID: "audit-close-1",
Type: "ticket_state_changed",
Action: "close",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusClosed},
CreatedAt: now,
})
return nil
}
// mockHandoffSessions satisfies handlers.SessionGetter
type mockHandoffSessions struct {
store *memory.SessionStore
}
func (m *mockHandoffSessions) GetByID(ctx context.Context, id string) (*session.Session, error) {
return m.store.GetByID(ctx, id)
}
// mockHandoffTickets satisfies handlers.TicketCreator
type mockHandoffTickets struct {
store *memory.TicketStore
}
func (m *mockHandoffTickets) Create(ctx context.Context, t *ticket.Ticket) error {
return m.store.Create(ctx, t)
}
// --------------------------------------------------
// Tests: POST /api/v1/customer-service/tickets (via session handoff)
// and GET /api/v1/customer-service/tickets (list)
// --------------------------------------------------
// TestTicketCreateAndList_CreateThenFind verifies that a ticket created via
// session handoff can be retrieved via GET /tickets/{id}.
func TestTicketCreateAndList_CreateThenFind(t *testing.T) {
auditRecorder := &ticketIntgAuditRecorder{}
svc := newMockTicketSvcForHandler(auditRecorder)
now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
ctx := context.Background()
// Create a session first (required for handoff)
sessions := memory.NewSessionStore()
_, _ = sessions.GetOrCreate(ctx, "widget", "u_list_test", now)
sess, _ := sessions.GetOrCreate(ctx, "widget", "u_list_test", now)
sess.Status = session.StatusIdle
_ = sessions.Save(ctx, sess)
// Use the session handler to create a ticket (simulates POST /tickets behavior)
// This uses the REAL handlers.NewSessionHandler
sessionAudit := &ticketIntgAuditRecorder{}
sessionSvc := &mockHandoffSessions{store: sessions}
ticketSvc := &mockHandoffTickets{store: svc.store}
sessionHdlr := handlers.NewSessionHandler(sessionSvc, ticketSvc, sessionAudit)
handoffBody := handlers.HandoffRequest{Reason: "test ticket creation"}
handoffBodyBytes, _ := json.Marshal(handoffBody)
sessionReq := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_list_test/handoff", bytes.NewReader(handoffBodyBytes))
sessionReq.Header.Set("Content-Type", "application/json")
sessionReq = withActor(sessionReq, "agent-list", "agent")
sessionResp := httptest.NewRecorder()
sessionHdlr.Handoff(sessionResp, sessionReq)
if sessionResp.Code != http.StatusOK {
t.Fatalf("handoff failed: status=%d body=%s", sessionResp.Code, sessionResp.Body.String())
}
var handoffResp map[string]any
if err := json.Unmarshal(sessionResp.Body.Bytes(), &handoffResp); err != nil {
t.Fatalf("decode handoff response error = %v", err)
}
ticketID, ok := handoffResp["ticket_id"].(string)
if !ok || ticketID == "" {
t.Fatalf("ticket_id missing from handoff response: %v", handoffResp)
}
// Now verify the ticket can be found via GET /tickets/{id}
ticketHandler := handlers.NewTicketHandler(svc, auditRecorder)
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/tickets/"+ticketID, nil)
getResp := httptest.NewRecorder()
ticketHandler.Get(getResp, getReq)
if getResp.Code != http.StatusOK {
t.Fatalf("GET ticket status = %d, want 200", getResp.Code)
}
var ticketResp map[string]any
if err := json.Unmarshal(getResp.Body.Bytes(), &ticketResp); err != nil {
t.Fatalf("decode ticket response error = %v", err)
}
tkt := ticketResp["ticket"].(map[string]any)
if tkt["id"] != ticketID {
t.Fatalf("ticket id = %v, want %s", tkt["id"], ticketID)
}
if tkt["status"] != "open" {
t.Fatalf("ticket status = %v, want open", tkt["status"])
}
}
// TestTicketList_ReturnsArray verifies GET /tickets returns a JSON array
// under the "items" key.
func TestTicketList_ReturnsArray(t *testing.T) {
auditRecorder := &ticketIntgAuditRecorder{}
svc := newMockTicketSvcForHandler(auditRecorder)
now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
ctx := context.Background()
// Seed two tickets
for i := 1; i <= 2; i++ {
tkt := &ticket.Ticket{
ID: "list-test-tkt-" + string(rune('0'+i)),
SessionID: "session-list-" + string(rune('0'+i)),
UserID: "user-list-" + string(rune('0'+i)),
Priority: ticket.PriorityP1,
Status: ticket.StatusOpen,
HandoffReason: "test list",
CreatedAt: now,
UpdatedAt: now,
}
_ = svc.store.Create(ctx, tkt)
}
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/tickets", nil)
resp := httptest.NewRecorder()
h.List(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("decode error = %v", err)
}
items, ok := payload["items"].([]any)
if !ok {
t.Fatalf("items field missing or not an array; got %T: %v", payload["items"], payload["items"])
}
if len(items) < 2 {
t.Fatalf("items length = %d, want at least 2", len(items))
}
}
// TestTicketList_PaginationParams verifies that the list endpoint handles
// pagination query parameters without error. Tests via the full HTTP router.
func TestTicketList_PaginationParams(t *testing.T) {
cfg := &config.Config{}
cfg.HTTP.Addr = ":0"
cfg.HTTP.ReadHeaderTimeout = 5
cfg.HTTP.ReadTimeout = 10
cfg.HTTP.WriteTimeout = 15
cfg.HTTP.IdleTimeout = 60
cfg.HTTP.MaxHeaderBytes = 1 << 20
cfg.HTTP.MaxBodyBytes = 1 << 20
cfg.Runtime.Env = "test"
application, err := app.New(cfg, logging.New())
if err != nil {
t.Fatalf("app.New() error = %v", err)
}
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
// Create tickets via webhook first
for i := 0; i < 5; i++ {
payload := map[string]any{
"message_id": "m-page-" + string(rune('a'+i)),
"channel": "widget",
"open_id": "u-page-" + string(rune('a'+i)),
"content": "转人工",
}
body, _ := json.Marshal(payload)
_, _ = http.Post(server.URL+"/api/v1/customer-service/webhook", "application/json", bytes.NewReader(body))
}
tests := []struct {
name string
query string
}{
{"no params", "/api/v1/customer-service/tickets"},
{"limit=2", "/api/v1/customer-service/tickets?limit=2"},
{"limit=10", "/api/v1/customer-service/tickets?limit=10"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, server.URL+tc.query, nil)
if err != nil {
t.Fatalf("new GET request error = %v", err)
}
setActorHeaders(req, "supervisor-page", "supervisor")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 for query %q", resp.StatusCode, tc.query)
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode error = %v", err)
}
items, ok := payload["items"].([]any)
if !ok {
t.Fatalf("items not an array for query %q", tc.query)
}
if len(items) == 0 {
t.Fatalf("items empty for query %q, want non-empty", tc.query)
}
})
}
}
// TestTicketList_EmptyStore returns empty array (not null or error).
func TestTicketList_EmptyStore(t *testing.T) {
auditRecorder := &ticketIntgAuditRecorder{}
svc := newMockTicketSvcForHandler(auditRecorder)
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/tickets", nil)
resp := httptest.NewRecorder()
h.List(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("decode error = %v", err)
}
items, ok := payload["items"].([]any)
if !ok {
t.Fatalf("items missing or not array")
}
if items == nil {
t.Fatalf("items should be empty array, not null")
}
}