fix: P0-1 RateLimiter并发写安全 + P0-2工单操作错误码区分 + P1 rows.Close修复

P0-1 (limits.go): Allow()方法改为全程使用写锁保护counters map读写,避免RLock写入时的data race
P0-2 (ticket_workflow.go+ticket_handler.go): Assign/Resolve/Close操作先查询ticket存在性和状态,返回明确的CS_TICKET_4001/CS_TKT_4002/CS_TICKET_4092/CS_TICKET_4093错误码,handler根据错误前缀路由HTTP状态码
P1-1 (ticket_store.go): 移除GetStats中3处手动rows.Close(),只保留defer Close()
This commit is contained in:
Your Name
2026-05-01 20:56:25 +08:00
parent bd2d848009
commit cf46b27610
103 changed files with 16428 additions and 0 deletions

View File

@@ -0,0 +1,438 @@
package integration
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"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/http/handlers"
"github.com/bridge/ai-customer-service/internal/store/memory"
)
// --------------------------------------------------
// Shared mock infrastructure
// --------------------------------------------------
type arAuditRecorder struct{ events []audit.Event }
func (r *arAuditRecorder) Add(_ context.Context, event audit.Event) error {
r.events = append(r.events, event)
return nil
}
func (r *arAuditRecorder) eventsOfType(action string) []audit.Event {
var out []audit.Event
for _, e := range r.events {
if e.Action == action {
out = append(out, e)
}
}
return out
}
// mockAssignResolveService wraps memory.TicketStore and satisfies TicketService.
type mockAssignResolveService struct {
store *memory.TicketStore
audit *arAuditRecorder
}
func newMockAssignResolveService(auditRecorder *arAuditRecorder) *mockAssignResolveService {
return &mockAssignResolveService{
store: memory.NewTicketStore(),
audit: auditRecorder,
}
}
func (m *mockAssignResolveService) ListOpen(ctx context.Context, limit int) ([]ticket.Ticket, error) {
return m.store.ListOpen(ctx, limit)
}
func (m *mockAssignResolveService) GetByID(ctx context.Context, id string) (*ticket.Ticket, error) {
return m.store.GetByID(ctx, id)
}
func (m *mockAssignResolveService) 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-" + ticketID,
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 *mockAssignResolveService) Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error {
tkt, _ := m.store.GetByID(ctx, ticketID)
if tkt == nil {
return fmt.Errorf("ticket not found")
}
// Enforce state machine: only assigned/processing tickets can be resolved
if tkt.Status != ticket.StatusAssigned && tkt.Status != ticket.StatusProcessing {
return fmt.Errorf("ticket not resolvable from status: %s", tkt.Status)
}
if err := m.store.Resolve(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil {
return err
}
m.audit.Add(ctx, audit.Event{
ID: "audit-resolve-" + ticketID,
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 *mockAssignResolveService) 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-" + ticketID,
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
}
// --------------------------------------------------
// Tests: POST /assign — state transitions
// --------------------------------------------------
// TestAssign_UpdatesStatusToAssigned verifies that assigning an open ticket
// transitions it to the "assigned" status.
func TestAssign_UpdatesStatusToAssigned(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
now := time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
ctx := context.Background()
// Create an open ticket
_ = svc.store.Create(ctx, &ticket.Ticket{
ID: "assign-tkt-1",
SessionID: "session-assign-1",
UserID: "user-assign-1",
Priority: ticket.PriorityP1,
Status: ticket.StatusOpen,
HandoffReason: "refund request",
CreatedAt: now,
UpdatedAt: now,
})
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/assign-tkt-1/assign?agent_id=agent-001&actor_id=supervisor-1", nil)
req.RemoteAddr = "10.0.0.5:12345"
resp := httptest.NewRecorder()
h.Assign(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("assign status = %d, want 200; body: %s", resp.Code, resp.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode error = %v", err)
}
if payload["assigned"] != true {
t.Fatalf("assigned = %v, want true", payload["assigned"])
}
// Verify ticket status in store
tkt, _ := svc.store.GetByID(ctx, "assign-tkt-1")
if tkt.Status != ticket.StatusAssigned {
t.Fatalf("ticket status = %s, want assigned", tkt.Status)
}
if tkt.AssignedTo != "agent-001" {
t.Fatalf("assigned_to = %s, want agent-001", tkt.AssignedTo)
}
}
// TestAssign_CannotReassignAlreadyAssigned verifies that a ticket already
// assigned cannot be reassigned (returns 409 Conflict).
func TestAssign_CannotReassignAlreadyAssigned(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
now := time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
ctx := context.Background()
_ = svc.store.Create(ctx, &ticket.Ticket{
ID: "assign-tkt-2",
SessionID: "session-assign-2",
Priority: ticket.PriorityP2,
Status: ticket.StatusAssigned,
AssignedTo: "agent-first",
HandoffReason: "quota inquiry",
CreatedAt: now,
UpdatedAt: now,
})
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/assign-tkt-2/assign?agent_id=agent-second", nil)
resp := httptest.NewRecorder()
h.Assign(resp, req)
if resp.Code != http.StatusConflict {
t.Fatalf("assign already-assigned ticket status = %d, want 409", resp.Code)
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode error = %v", err)
}
errPayload := payload["error"].(map[string]any)
if errPayload["code"] != "CS_TKT_4002" {
t.Fatalf("error code = %v, want CS_TKT_4002", errPayload["code"])
}
}
// TestAssign_MissingAgentID returns 400.
func TestAssign_MissingAgentID(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/some-ticket/assign", nil)
resp := httptest.NewRecorder()
h.Assign(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.Code)
}
}
// --------------------------------------------------
// Tests: POST /resolve — state transitions
// --------------------------------------------------
// TestResolve_UpdatesStatusToResolved verifies that resolving an assigned ticket
// transitions it to the "resolved" status.
func TestResolve_UpdatesStatusToResolved(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
now := time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
ctx := context.Background()
_ = svc.store.Create(ctx, &ticket.Ticket{
ID: "resolve-tkt-1",
SessionID: "session-resolve-1",
Priority: ticket.PriorityP2,
Status: ticket.StatusAssigned,
AssignedTo: "agent-001",
HandoffReason: "account issue",
CreatedAt: now,
UpdatedAt: now,
})
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/resolve-tkt-1/resolve?resolution=issue+fixed&actor_id=agent-001", nil)
req.RemoteAddr = "10.0.0.6:54321"
resp := httptest.NewRecorder()
h.Resolve(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("resolve status = %d, want 200; body: %s", resp.Code, resp.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode error = %v", err)
}
if payload["resolved"] != true {
t.Fatalf("resolved = %v, want true", payload["resolved"])
}
// Verify ticket in store
tkt, _ := svc.store.GetByID(ctx, "resolve-tkt-1")
if tkt.Status != ticket.StatusResolved {
t.Fatalf("ticket status = %s, want resolved", tkt.Status)
}
if tkt.Resolution != "issue fixed" {
t.Fatalf("resolution = %q, want 'issue fixed'", tkt.Resolution)
}
if tkt.ResolvedAt == nil {
t.Fatalf("resolved_at should be set")
}
}
// TestResolve_CannotResolveClosedTicket verifies that resolving a closed
// ticket returns 409 Conflict.
func TestResolve_CannotResolveClosedTicket(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
now := time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
ctx := context.Background()
_ = svc.store.Create(ctx, &ticket.Ticket{
ID: "resolve-tkt-closed",
SessionID: "session-closed",
Priority: ticket.PriorityP3,
Status: ticket.StatusClosed,
AssignedTo: "agent-001",
HandoffReason: "done",
CreatedAt: now,
UpdatedAt: now,
})
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/resolve-tkt-closed/resolve?resolution=already+closed", nil)
resp := httptest.NewRecorder()
h.Resolve(resp, req)
if resp.Code != http.StatusConflict {
t.Fatalf("resolve closed ticket status = %d, want 409", resp.Code)
}
}
// TestResolve_MissingResolution returns 400.
func TestResolve_MissingResolution(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/some-ticket/resolve", nil)
resp := httptest.NewRecorder()
h.Resolve(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.Code)
}
}
// TestResolve_TicketNotFound returns 409.
func TestResolve_TicketNotFound(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/nonexistent/resolve?resolution=not+found", nil)
resp := httptest.NewRecorder()
h.Resolve(resp, req)
if resp.Code != http.StatusConflict {
t.Fatalf("resolve nonexistent ticket status = %d, want 409", resp.Code)
}
}
// --------------------------------------------------
// Tests: State transition correctness
// --------------------------------------------------
// TestStateTransition_OpenToAssignedToResolved verifies the full happy-path
// state transition: open → assigned → resolved.
func TestStateTransition_OpenToAssignedToResolved(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
now := time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC)
ctx := context.Background()
_ = svc.store.Create(ctx, &ticket.Ticket{
ID: "state-tkt-1",
SessionID: "session-state-1",
UserID: "user-state-1",
Priority: ticket.PriorityP1,
Status: ticket.StatusOpen,
HandoffReason: "urgent refund",
CreatedAt: now,
UpdatedAt: now,
})
h := handlers.NewTicketHandler(svc, auditRecorder)
// Step 1: Assign
assignReq := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/state-tkt-1/assign?agent_id=agent-alpha&actor_id=admin-1", nil)
assignResp := httptest.NewRecorder()
h.Assign(assignResp, assignReq)
if assignResp.Code != http.StatusOK {
t.Fatalf("[assign] status = %d, want 200", assignResp.Code)
}
tktAfterAssign, _ := svc.store.GetByID(ctx, "state-tkt-1")
if tktAfterAssign.Status != ticket.StatusAssigned {
t.Fatalf("[assign] status = %s, want assigned", tktAfterAssign.Status)
}
if tktAfterAssign.AssignedTo != "agent-alpha" {
t.Fatalf("[assign] assigned_to = %s, want agent-alpha", tktAfterAssign.AssignedTo)
}
// Step 2: Resolve
resolveReq := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/state-tkt-1/resolve?resolution=refund+processed&actor_id=agent-alpha", nil)
resolveResp := httptest.NewRecorder()
h.Resolve(resolveResp, resolveReq)
if resolveResp.Code != http.StatusOK {
t.Fatalf("[resolve] status = %d, want 200", resolveResp.Code)
}
tktAfterResolve, _ := svc.store.GetByID(ctx, "state-tkt-1")
if tktAfterResolve.Status != ticket.StatusResolved {
t.Fatalf("[resolve] status = %s, want resolved", tktAfterResolve.Status)
}
if tktAfterResolve.Resolution != "refund processed" {
t.Fatalf("[resolve] resolution = %q, want 'refund processed'", tktAfterResolve.Resolution)
}
if tktAfterResolve.ResolvedAt == nil {
t.Fatalf("[resolve] resolved_at should be set")
}
}
// TestStateTransition_InvalidTransition verifies that skipping states
// (e.g., resolving an open ticket directly) returns 409.
func TestStateTransition_InvalidTransition(t *testing.T) {
auditRecorder := &arAuditRecorder{}
svc := newMockAssignResolveService(auditRecorder)
now := time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC)
ctx := context.Background()
_ = svc.store.Create(ctx, &ticket.Ticket{
ID: "state-tkt-2",
SessionID: "session-state-2",
Priority: ticket.PriorityP2,
Status: ticket.StatusOpen,
HandoffReason: "test",
CreatedAt: now,
UpdatedAt: now,
})
h := handlers.NewTicketHandler(svc, auditRecorder)
// Try to resolve an open ticket directly (should fail — must be assigned first)
resolveReq := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/state-tkt-2/resolve?resolution=skip+assign", nil)
resolveResp := httptest.NewRecorder()
h.Resolve(resolveResp, resolveReq)
if resolveResp.Code != http.StatusConflict {
t.Fatalf("resolve open ticket (skip assign) status = %d, want 409", resolveResp.Code)
}
}