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:
438
test/integration/ticket_assign_resolve_test.go
Normal file
438
test/integration/ticket_assign_resolve_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user