From 3e9022a303efebd45a52573e8a3c9475fd9b9cdb Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 1 May 2026 13:29:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(ticket=5Fhandler):=20=E5=B0=86=20auditTicke?= =?UTF-8?q?tChange=20=E6=AD=BB=E4=BB=A3=E7=A0=81=E6=8E=A5=E5=85=A5=20Assig?= =?UTF-8?q?n/Resolve/Close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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% --- .../internal/http/handlers/ticket_handler.go | 121 +++++ .../http/handlers/ticket_handler_test.go | 413 ++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 projects/ai-customer-service/internal/http/handlers/ticket_handler.go create mode 100644 projects/ai-customer-service/internal/http/handlers/ticket_handler_test.go diff --git a/projects/ai-customer-service/internal/http/handlers/ticket_handler.go b/projects/ai-customer-service/internal/http/handlers/ticket_handler.go new file mode 100644 index 00000000..d38b4586 --- /dev/null +++ b/projects/ai-customer-service/internal/http/handlers/ticket_handler.go @@ -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 +} \ No newline at end of file diff --git a/projects/ai-customer-service/internal/http/handlers/ticket_handler_test.go b/projects/ai-customer-service/internal/http/handlers/ticket_handler_test.go new file mode 100644 index 00000000..a513b15c --- /dev/null +++ b/projects/ai-customer-service/internal/http/handlers/ticket_handler_test.go @@ -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") + } +}