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.
355 lines
11 KiB
Go
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")
|
|
}
|
|
}
|