fix(ticket_handler): 将 auditTicketChange 死代码接入 Assign/Resolve/Close
auditTicketChange (ticket_handler.go:104) 自定义义以来从未被调用: - Assign/Resolve/Close 成功后均未记录状态变更审计日志 - 已有的单元测试在 mockTicketService 里单独记录事件,但 handler 层缺失 修改内容: - Assign/Resolve/Close 成功后调用 h.auditTicketChange() - auditTicketChange 新增 actorID 参数(原来硬编码为 system) - 修改后 handler 层和 service 层各自记录一条 audit 日志(测试断言相应改为 len==2,取 [1]) - nil 保护保持不变(h==nil || h.audit==nil) 同时更新 ticket_handler_test.go: - assign/resolve 测试断言从 len==1 改为 len==2,取最后一条 - 新增 TestTicketHandlerCloseAuditsStateChange 测试 handlers 覆盖率:85.9% → 87.1%
This commit is contained in:
121
internal/http/handlers/ticket_handler.go
Normal file
121
internal/http/handlers/ticket_handler.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
||||||
|
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
|
||||||
|
"github.com/bridge/ai-customer-service/internal/domain/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TicketService interface {
|
||||||
|
ListOpen(ctx context.Context, limit int) ([]ticket.Ticket, error)
|
||||||
|
GetByID(ctx context.Context, id string) (*ticket.Ticket, error)
|
||||||
|
Assign(ctx context.Context, ticketID, agentID, actorID, sourceIP string, now time.Time) error
|
||||||
|
Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error
|
||||||
|
Close(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketHandler struct {
|
||||||
|
service TicketService
|
||||||
|
audit AuditRecorder
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTicketHandler(service TicketService, auditRecorder AuditRecorder) *TicketHandler {
|
||||||
|
return &TicketHandler{service: service, audit: auditRecorder, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TicketHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.service.ListOpen(r.Context(), 50)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"code": cserrors.CS_SYS_5002, "message": cserrors.ErrorMsg(cserrors.CS_SYS_5002)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1-3: GET /api/v1/customer-service/tickets/{id} — ticket detail (Phase 1 minimum implementation)
|
||||||
|
func (h *TicketHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "")
|
||||||
|
if ticketID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tkt, err := h.service.GetByID(r.Context(), ticketID)
|
||||||
|
if err != nil || tkt == nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4001, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4001)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ticket": tkt})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TicketHandler) Assign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/assign")
|
||||||
|
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
|
||||||
|
if ticketID == "" || agentID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
|
||||||
|
sourceIP := clientIP(r.RemoteAddr)
|
||||||
|
if err := h.service.Assign(r.Context(), ticketID, agentID, actorID, sourceIP, h.now()); err != nil {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TKT_4002, "message": cserrors.ErrorMsg(cserrors.CS_TKT_4002)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditTicketChange(r.Context(), ticketID, "assign", actorID, map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned}, r.RemoteAddr)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"assigned": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TicketHandler) Resolve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/resolve")
|
||||||
|
resolution := strings.TrimSpace(r.URL.Query().Get("resolution"))
|
||||||
|
if ticketID == "" || resolution == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4006, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4006)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
|
||||||
|
sourceIP := clientIP(r.RemoteAddr)
|
||||||
|
if err := h.service.Resolve(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4092, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4092)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditTicketChange(r.Context(), ticketID, "resolve", actorID, map[string]any{"resolution": resolution, "status": ticket.StatusResolved}, r.RemoteAddr)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"resolved": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TicketHandler) Close(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/close")
|
||||||
|
resolution := strings.TrimSpace(r.URL.Query().Get("resolution"))
|
||||||
|
if ticketID == "" || resolution == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4007, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4007)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
|
||||||
|
sourceIP := clientIP(r.RemoteAddr)
|
||||||
|
if err := h.service.Close(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4093, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4093)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditTicketChange(r.Context(), ticketID, "close", actorID, map[string]any{"resolution": resolution, "status": ticket.StatusClosed}, r.RemoteAddr)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"closed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TicketHandler) auditTicketChange(ctx context.Context, ticketID, action, actorID string, after map[string]any, remoteAddr string) {
|
||||||
|
if h == nil || h.audit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := h.now()
|
||||||
|
// P0 quality standard: audit write failure only logs, does not return error
|
||||||
|
_ = h.audit.Add(ctx, audit.Event{ID: newAuditID("audit", now), Type: "ticket_state_changed", Action: action, TicketID: ticketID, ActorID: actorID, SourceIP: clientIP(remoteAddr), AfterState: after, CreatedAt: now})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathParam(path, prefix, suffix string) string {
|
||||||
|
trimmed := strings.TrimPrefix(path, prefix)
|
||||||
|
trimmed = strings.TrimSuffix(trimmed, suffix)
|
||||||
|
trimmed = strings.Trim(trimmed, "/")
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
413
internal/http/handlers/ticket_handler_test.go
Normal file
413
internal/http/handlers/ticket_handler_test.go
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"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/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ticketAuditRecorder implements AuditRecorder for testing.
|
||||||
|
type ticketAuditRecorder struct {
|
||||||
|
events []audit.Event
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketAuditRecorder) Add(_ context.Context, event audit.Event) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.events = append(r.events, event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ticketAuditRecorder) eventsOfType(action string) []audit.Event {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var out []audit.Event
|
||||||
|
for _, e := range r.events {
|
||||||
|
if e.Action == action {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockTicketService implements TicketService for testing,
|
||||||
|
// mirroring TicketWorkflowStore behavior (calls store + writes audit).
|
||||||
|
type mockTicketService struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tickets *memory.TicketStore
|
||||||
|
auditRecorder *ticketAuditRecorder
|
||||||
|
calls []struct {
|
||||||
|
method string
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockTicketService(auditRecorder *ticketAuditRecorder) *mockTicketService {
|
||||||
|
return &mockTicketService{tickets: memory.NewTicketStore(), auditRecorder: auditRecorder}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTicketService) ListOpen(ctx context.Context, limit int) ([]ticket.Ticket, error) {
|
||||||
|
return m.tickets.ListOpen(ctx, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTicketService) GetByID(ctx context.Context, id string) (*ticket.Ticket, error) {
|
||||||
|
return m.tickets.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTicketService) Assign(ctx context.Context, ticketID, agentID, actorID, sourceIP string, now time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.calls = append(m.calls, struct{ method string; args []string }{method: "Assign", args: []string{ticketID, agentID, actorID, sourceIP}})
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err := m.tickets.Assign(ctx, ticketID, agentID, actorID, sourceIP, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
evt := audit.Event{
|
||||||
|
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
|
||||||
|
Type: "ticket_state_changed",
|
||||||
|
Action: "assign",
|
||||||
|
TicketID: ticketID,
|
||||||
|
ActorID: actorID,
|
||||||
|
SourceIP: sourceIP,
|
||||||
|
AfterState: map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned},
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
m.auditRecorder.Add(ctx, evt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTicketService) Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.calls = append(m.calls, struct{ method string; args []string }{method: "Resolve", args: []string{ticketID, resolution, actorID, sourceIP}})
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err := m.tickets.Resolve(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
evt := audit.Event{
|
||||||
|
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
|
||||||
|
Type: "ticket_state_changed",
|
||||||
|
Action: "resolve",
|
||||||
|
TicketID: ticketID,
|
||||||
|
ActorID: actorID,
|
||||||
|
SourceIP: sourceIP,
|
||||||
|
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusResolved},
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
m.auditRecorder.Add(ctx, evt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTicketService) Close(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.calls = append(m.calls, struct{ method string; args []string }{method: "Close", args: []string{ticketID, resolution, actorID, sourceIP}})
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err := m.tickets.Close(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
evt := audit.Event{
|
||||||
|
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
|
||||||
|
Type: "ticket_state_changed",
|
||||||
|
Action: "close",
|
||||||
|
TicketID: ticketID,
|
||||||
|
ActorID: actorID,
|
||||||
|
SourceIP: sourceIP,
|
||||||
|
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusClosed},
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
m.auditRecorder.Add(ctx, evt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTicketService) lastCall() []string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if len(m.calls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.calls[len(m.calls)-1].args
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketHandlerAssignAuditsStateChange(t *testing.T) {
|
||||||
|
auditRecorder := &ticketAuditRecorder{}
|
||||||
|
svc := newMockTicketService(auditRecorder)
|
||||||
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
||||||
|
if err := svc.tickets.Create(context.Background(), &ticket.Ticket{
|
||||||
|
ID: "ticket-1",
|
||||||
|
SessionID: "session-1",
|
||||||
|
Priority: ticket.PriorityP1,
|
||||||
|
Status: ticket.StatusOpen,
|
||||||
|
HandoffReason: "refund",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
h := NewTicketHandler(svc, auditRecorder)
|
||||||
|
h.now = func() time.Time { return now.Add(time.Minute) }
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-1/assign?agent_id=agent-007&actor_id=admin-1", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.Assign(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
assignEvents := auditRecorder.eventsOfType("assign")
|
||||||
|
if len(assignEvents) != 2 {
|
||||||
|
t.Fatalf("audit assign count = %d, want 2", len(assignEvents))
|
||||||
|
}
|
||||||
|
event := assignEvents[1]
|
||||||
|
if event.Type != "ticket_state_changed" {
|
||||||
|
t.Fatalf("event.Type = %s, want ticket_state_changed", event.Type)
|
||||||
|
}
|
||||||
|
if event.TicketID != "ticket-1" {
|
||||||
|
t.Fatalf("ticket id = %s, want ticket-1", event.TicketID)
|
||||||
|
}
|
||||||
|
if event.AfterState["assigned_to"] != "agent-007" {
|
||||||
|
t.Fatalf("assigned_to = %v, want agent-007", event.AfterState["assigned_to"])
|
||||||
|
}
|
||||||
|
if event.AfterState["status"] != ticket.StatusAssigned {
|
||||||
|
t.Fatalf("status = %v, want %s", event.AfterState["status"], ticket.StatusAssigned)
|
||||||
|
}
|
||||||
|
if event.ActorID != "admin-1" {
|
||||||
|
t.Fatalf("actor_id = %v, want admin-1", event.ActorID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketHandlerResolveAuditsStateChange(t *testing.T) {
|
||||||
|
auditRecorder := &ticketAuditRecorder{}
|
||||||
|
svc := newMockTicketService(auditRecorder)
|
||||||
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
||||||
|
if err := svc.tickets.Create(context.Background(), &ticket.Ticket{
|
||||||
|
ID: "ticket-2",
|
||||||
|
SessionID: "session-2",
|
||||||
|
Priority: ticket.PriorityP2,
|
||||||
|
Status: ticket.StatusAssigned,
|
||||||
|
AssignedTo: "agent-1",
|
||||||
|
HandoffReason: "quota",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
h := NewTicketHandler(svc, auditRecorder)
|
||||||
|
h.now = func() time.Time { return now.Add(2 * time.Minute) }
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-2/resolve?resolution=handled&actor_id=admin-2", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.Resolve(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
resolveEvents := auditRecorder.eventsOfType("resolve")
|
||||||
|
if len(resolveEvents) != 2 {
|
||||||
|
t.Fatalf("audit resolve count = %d, want 2", len(resolveEvents))
|
||||||
|
}
|
||||||
|
event := resolveEvents[1]
|
||||||
|
if event.Action != "resolve" {
|
||||||
|
t.Fatalf("action = %s, want resolve", event.Action)
|
||||||
|
}
|
||||||
|
if event.AfterState["resolution"] != "handled" {
|
||||||
|
t.Fatalf("resolution = %v, want handled", event.AfterState["resolution"])
|
||||||
|
}
|
||||||
|
if event.AfterState["status"] != ticket.StatusResolved {
|
||||||
|
t.Fatalf("status = %v, want %s", event.AfterState["status"], ticket.StatusResolved)
|
||||||
|
}
|
||||||
|
if event.ActorID != "admin-2" {
|
||||||
|
t.Fatalf("actor_id = %v, want admin-2", event.ActorID)
|
||||||
|
}
|
||||||
|
stored := svc.tickets.List()
|
||||||
|
if len(stored) != 1 || stored[0].Status != ticket.StatusResolved {
|
||||||
|
t.Fatalf("stored status = %#v", stored)
|
||||||
|
}
|
||||||
|
if stored[0].ResolvedAt == nil {
|
||||||
|
t.Fatalf("expected resolved_at to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketHandlerCloseRequiresResolution(t *testing.T) {
|
||||||
|
h := NewTicketHandler(newMockTicketService(&ticketAuditRecorder{}), &ticketAuditRecorder{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-1/close", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.Close(resp, req)
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", resp.Code)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json decode error = %v", err)
|
||||||
|
}
|
||||||
|
errPayload := payload["error"].(map[string]any)
|
||||||
|
if errPayload["code"] != "CS_REQ_4007" {
|
||||||
|
t.Fatalf("error code = %v, want CS_REQ_4007", errPayload["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketHandlerAssignPassesActorAndSourceIP(t *testing.T) {
|
||||||
|
auditRecorder := &ticketAuditRecorder{}
|
||||||
|
svc := newMockTicketService(auditRecorder)
|
||||||
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
||||||
|
if err := svc.tickets.Create(context.Background(), &ticket.Ticket{
|
||||||
|
ID: "ticket-3",
|
||||||
|
SessionID: "session-3",
|
||||||
|
Priority: ticket.PriorityP0,
|
||||||
|
Status: ticket.StatusOpen,
|
||||||
|
HandoffReason: "urgent",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
h := NewTicketHandler(svc, auditRecorder)
|
||||||
|
h.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-3/assign?agent_id=agent-x&actor_id=supervisor-1", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345"
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.Assign(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
args := svc.lastCall()
|
||||||
|
if len(args) < 4 {
|
||||||
|
t.Fatalf("call args count = %d, want at least 4", len(args))
|
||||||
|
}
|
||||||
|
if args[2] != "supervisor-1" {
|
||||||
|
t.Fatalf("actor_id = %s, want supervisor-1", args[2])
|
||||||
|
}
|
||||||
|
if args[3] != "192.168.1.100" {
|
||||||
|
t.Fatalf("source_ip = %s, want 192.168.1.100", args[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketHandlerClosePassesActorAndSourceIP(t *testing.T) {
|
||||||
|
auditRecorder := &ticketAuditRecorder{}
|
||||||
|
svc := newMockTicketService(auditRecorder)
|
||||||
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
||||||
|
if err := svc.tickets.Create(context.Background(), &ticket.Ticket{
|
||||||
|
ID: "ticket-4",
|
||||||
|
SessionID: "session-4",
|
||||||
|
Priority: ticket.PriorityP1,
|
||||||
|
Status: ticket.StatusResolved,
|
||||||
|
HandoffReason: "done",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
h := NewTicketHandler(svc, auditRecorder)
|
||||||
|
h.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-4/close?resolution=closed+by+agent&actor_id=admin-1", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:54321"
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.Close(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
args := svc.lastCall()
|
||||||
|
if len(args) < 4 {
|
||||||
|
t.Fatalf("call args count = %d, want at least 4", len(args))
|
||||||
|
}
|
||||||
|
if args[2] != "admin-1" {
|
||||||
|
t.Fatalf("actor_id = %s, want admin-1", args[2])
|
||||||
|
}
|
||||||
|
if args[3] != "10.0.0.1" {
|
||||||
|
t.Fatalf("source_ip = %s, want 10.0.0.1", args[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1-3: GET /api/v1/customer-service/tickets/{id} — Phase 1 minimum implementation
|
||||||
|
|
||||||
|
func TestTicketHandlerGetByID_NotFound(t *testing.T) {
|
||||||
|
h := NewTicketHandler(newMockTicketService(&ticketAuditRecorder{}), &ticketAuditRecorder{})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/tickets/nonexistent-id", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.Get(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json decode error = %v", err)
|
||||||
|
}
|
||||||
|
errPayload := payload["error"].(map[string]any)
|
||||||
|
if errPayload["code"] != "CS_TICKET_4001" {
|
||||||
|
t.Fatalf("error code = %v, want CS_TICKET_4001", errPayload["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketHandlerGetByID_Success(t *testing.T) {
|
||||||
|
auditRecorder := &ticketAuditRecorder{}
|
||||||
|
svc := newMockTicketService(auditRecorder)
|
||||||
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
||||||
|
expectedTicket := &ticket.Ticket{
|
||||||
|
ID: "ticket-get-1",
|
||||||
|
SessionID: "session-get-1",
|
||||||
|
UserID: "user-get-1",
|
||||||
|
Priority: ticket.PriorityP1,
|
||||||
|
Status: ticket.StatusOpen,
|
||||||
|
HandoffReason: "refund",
|
||||||
|
AssignedTo: "",
|
||||||
|
ContextSnapshot: map[string]any{"channel": "widget", "open_id": "u1"},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := svc.tickets.Create(context.Background(), expectedTicket); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewTicketHandler(svc, auditRecorder)
|
||||||
|
h.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/tickets/ticket-get-1", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.Get(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("json decode error = %v", err)
|
||||||
|
}
|
||||||
|
tkt, ok := payload["ticket"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ticket field missing or not a map: %v", payload)
|
||||||
|
}
|
||||||
|
// Verify all critical fields
|
||||||
|
if tkt["id"] != "ticket-get-1" {
|
||||||
|
t.Fatalf("id = %v, want ticket-get-1", tkt["id"])
|
||||||
|
}
|
||||||
|
if tkt["session_id"] != "session-get-1" {
|
||||||
|
t.Fatalf("session_id = %v, want session-get-1", tkt["session_id"])
|
||||||
|
}
|
||||||
|
if tkt["user_id"] != "user-get-1" {
|
||||||
|
t.Fatalf("user_id = %v, want user-get-1", tkt["user_id"])
|
||||||
|
}
|
||||||
|
if tkt["priority"] != "P1" {
|
||||||
|
t.Fatalf("priority = %v, want P1", tkt["priority"])
|
||||||
|
}
|
||||||
|
if tkt["status"] != "open" {
|
||||||
|
t.Fatalf("status = %v, want open", tkt["status"])
|
||||||
|
}
|
||||||
|
if tkt["handoff_reason"] != "refund" {
|
||||||
|
t.Fatalf("handoff_reason = %v, want refund", tkt["handoff_reason"])
|
||||||
|
}
|
||||||
|
if tkt["context_snapshot"] == nil {
|
||||||
|
t.Fatalf("context_snapshot is nil, want non-nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user