Files
ai-customer-service/test/e2e/full_ticket_flow_test.go
Your Name 142b991334 fix(config+app): production fail-fast + readiness收紧
1. config.go: AI_CS_ENV runtime mode with production restriction
   - New RuntimeConfig.Env field (AI_CS_ENV / AI_CS_RUNTIME_ENV)
   - production + Postgres.Enabled=false → Load() returns error
   - production + empty webhook secret → Load() returns error
   - normalizeRuntimeEnv: dev/dev/ → development, prod/production → production, test → test

2. app.go: probe.SetReady only when store is confirmed ready
   - Postgres.Enabled: probe.SetReady(true) after DB+migration OK
   - Memory mode: probe.SetReady(false) — not production-ready

3. health_handler_test.go: add probe live+ready state transition tests

4. config_test.go: add TestLoad_RejectsProdWhenPostgresDisabled,
   TestLoad_RejectsProdWhenWebhookSecretMissing

5. app_test.go: add TestNew_RejectsMemoryModeWithoutExplicitNonProdEnv,
   TestNew_AllowsMemoryModeInTestEnv, TestNew_WithPostgresEnabled_*
   for invalid DSN and migration-failure paths

Phase 1 (code gate) objectives met:
 prod cannot fall back to memory store
 readiness reflects actual store readiness
 both changes have test coverage
2026-05-04 07:38:10 +08:00

585 lines
20 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/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))
}
}
// 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&actor_id=admin-e2e", baseURL, ticketID)
assignReq, err := http.NewRequest(http.MethodPost, assignURL, nil)
if err != nil {
t.Fatalf("new assign request error = %v", err)
}
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&actor_id=agent-e2e-001", baseURL, ticketID)
resolveReq, err := http.NewRequest(http.MethodPost, resolveURL, nil)
if err != nil {
t.Fatalf("new resolve request error = %v", err)
}
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)
getResp, err := http.Get(getURL)
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&actor_id=supervisor-audit", baseURL, ticketID)
assignReq, _ := http.NewRequest(http.MethodPost, assignURL, nil)
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&actor_id=agent-audit-99", baseURL, ticketID)
resolveReq, _ := http.NewRequest(http.MethodPost, resolveURL, nil)
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)
getResp, err := http.Get(getURL)
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
listResp, err := http.Get(baseURL + "/api/v1/customer-service/tickets")
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)
assignResp, err := http.Post(assignURL, "application/octet-stream", nil)
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)
getResp, err := http.Get(getURL)
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)
getResp, err := http.Get(getURL)
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)
assignResp, err := http.Post(assignURL, "application/octet-stream", nil)
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)
resolveResp, err := http.Post(resolveURL, "application/octet-stream", nil)
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)
getResp, err := http.Get(getURL)
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"])
}
}