Fixes 'invalid input syntax for type uuid' error when writing ticket
workflow audit logs. The audit Event.ID field was using fmt.Sprintf
with nanoseconds ('wf-%d') which doesn't match PostgreSQL's uuid type.
Also adds uuid import to ticket_workflow.go.
Verified: full chain webhook→assign→resolve→close produces 3 audit
logs correctly, no more 'invalid uuid' errors in logs.
446 lines
14 KiB
Go
446 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
|
"github.com/bridge/ai-customer-service/internal/domain/session"
|
|
"github.com/bridge/ai-customer-service/internal/domain/ticket"
|
|
)
|
|
|
|
// mockSessionGetter implements SessionGetter for testing.
|
|
type mockSessionGetter struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*session.Session
|
|
}
|
|
|
|
func newMockSessionGetter() *mockSessionGetter {
|
|
return &mockSessionGetter{sessions: make(map[string]*session.Session)}
|
|
}
|
|
|
|
func (m *mockSessionGetter) GetByID(_ context.Context, id string) (*session.Session, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[id]; ok {
|
|
return s, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSessionGetter) AddSession(s *session.Session) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.sessions[s.ID] = s
|
|
}
|
|
|
|
// mockTicketCreator implements TicketCreator for testing.
|
|
type mockTicketCreator struct {
|
|
mu sync.Mutex
|
|
tickets []*ticket.Ticket
|
|
calls []struct{ id string }
|
|
}
|
|
|
|
func newMockTicketCreator() *mockTicketCreator {
|
|
return &mockTicketCreator{tickets: make([]*ticket.Ticket, 0)}
|
|
}
|
|
|
|
func (m *mockTicketCreator) Create(_ context.Context, t *ticket.Ticket) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.tickets = append(m.tickets, t)
|
|
m.calls = append(m.calls, struct{ id string }{id: t.ID})
|
|
return nil
|
|
}
|
|
|
|
// mockAuditRecorder implements AuditRecorder for testing.
|
|
type mockAuditRecorder struct {
|
|
mu sync.Mutex
|
|
events []audit.Event
|
|
}
|
|
|
|
func newMockAuditRecorder() *mockAuditRecorder {
|
|
return &mockAuditRecorder{}
|
|
}
|
|
|
|
func (r *mockAuditRecorder) Add(_ context.Context, event audit.Event) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.events = append(r.events, event)
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuditRecorder) eventsOfType(tp string) []audit.Event {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var out []audit.Event
|
|
for _, e := range r.events {
|
|
if e.Type == tp {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------- Feedback tests ----------
|
|
|
|
func TestFeedback_WritesAuditLog(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
|
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
h.now = func() time.Time { return now }
|
|
|
|
body := `{"score":5,"comment":"great service"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-1/feedback", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
|
|
h.Feedback(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.Code)
|
|
}
|
|
events := audits.eventsOfType("feedback")
|
|
if len(events) != 1 {
|
|
t.Fatalf("feedback events count = %d, want 1", len(events))
|
|
}
|
|
evt := events[0]
|
|
if evt.SessionID != "sess-1" {
|
|
t.Fatalf("session_id = %s, want sess-1", evt.SessionID)
|
|
}
|
|
if evt.Action != "submit" {
|
|
t.Fatalf("action = %s, want submit", evt.Action)
|
|
}
|
|
payload := evt.Payload
|
|
if payload["score"].(int) != 5 {
|
|
t.Fatalf("score = %v, want 5", payload["score"])
|
|
}
|
|
if payload["comment"].(string) != "great service" {
|
|
t.Fatalf("comment = %v, want 'great service'", payload["comment"])
|
|
}
|
|
}
|
|
|
|
func TestFeedback_auditFailureDoesNotReturnError(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
|
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
h.now = func() time.Time { return now }
|
|
|
|
body := `{"score":3}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-1/feedback", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
|
|
h.Feedback(resp, req)
|
|
|
|
// Even if audit.Add returned error (it doesn't in this mock),
|
|
// the handler should still return 200
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestFeedback_InvalidScore(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
h.now = time.Now
|
|
|
|
for _, score := range []int{0, 6, -1} {
|
|
body := strings.NewReader(`{"score":` + string(rune('0'+score)) + `}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-1/feedback", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.Feedback(resp, req)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("score=%d: status = %d, want 400", score, resp.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFeedback_InvalidJSON(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-1/feedback", strings.NewReader(`{invalid}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.Feedback(resp, req)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestFeedback_EmptySessionID(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions//feedback", strings.NewReader(`{"score":5}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.Feedback(resp, req)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ---------- Handoff tests ----------
|
|
|
|
func TestHandoff_CreatesTicketAndAudit(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
sessions.AddSession(&session.Session{
|
|
ID: "sess-hw-1",
|
|
Channel: "feishu",
|
|
OpenID: "open-123",
|
|
UserID: "user-456",
|
|
Status: session.StatusProcessing,
|
|
TurnCount: 3,
|
|
})
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
|
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
h.now = func() time.Time { return now }
|
|
|
|
body := `{"reason":"customer requested human","priority":"P1"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-hw-1/handoff", strings.NewReader(body))
|
|
req = withActor(req, "admin-1", "admin")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
resp := httptest.NewRecorder()
|
|
|
|
h.Handoff(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)
|
|
}
|
|
if payload["session_id"] != "sess-hw-1" {
|
|
t.Fatalf("session_id = %v, want sess-hw-1", payload["session_id"])
|
|
}
|
|
ticketID := payload["ticket_id"].(string)
|
|
if ticketID == "" {
|
|
t.Fatal("ticket_id should not be empty")
|
|
}
|
|
|
|
// Verify ticket was created
|
|
if len(tickets.tickets) != 1 {
|
|
t.Fatalf("ticket count = %d, want 1", len(tickets.tickets))
|
|
}
|
|
tkt := tickets.tickets[0]
|
|
if tkt.SessionID != "sess-hw-1" {
|
|
t.Fatalf("ticket session_id = %s, want sess-hw-1", tkt.SessionID)
|
|
}
|
|
if tkt.Priority != ticket.PriorityP1 {
|
|
t.Fatalf("priority = %s, want P1", tkt.Priority)
|
|
}
|
|
if tkt.HandoffReason != "customer requested human" {
|
|
t.Fatalf("handoff_reason = %s, want 'customer requested human'", tkt.HandoffReason)
|
|
}
|
|
if tkt.Status != ticket.StatusOpen {
|
|
t.Fatalf("status = %s, want open", tkt.Status)
|
|
}
|
|
|
|
// Verify audit event
|
|
events := audits.eventsOfType("manual_handoff")
|
|
if len(events) != 1 {
|
|
t.Fatalf("manual_handoff events count = %d, want 1", len(events))
|
|
}
|
|
evt := events[0]
|
|
if evt.SessionID != "sess-hw-1" {
|
|
t.Fatalf("session_id = %s, want sess-hw-1", evt.SessionID)
|
|
}
|
|
if evt.TicketID != ticketID {
|
|
t.Fatalf("ticket_id = %s, want %s", evt.TicketID, ticketID)
|
|
}
|
|
if evt.ActorID != "admin-1" {
|
|
t.Fatalf("actor_id = %s, want admin-1", evt.ActorID)
|
|
}
|
|
if evt.SourceIP != "10.0.0.1" {
|
|
t.Fatalf("source_ip = %s, want 10.0.0.1", evt.SourceIP)
|
|
}
|
|
}
|
|
|
|
func TestHandoff_DefaultPriorityP2(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
sessions.AddSession(&session.Session{ID: "sess-p2", Channel: "feishu", OpenID: "open-1", Status: session.StatusProcessing})
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
|
|
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
h.now = func() time.Time { return now }
|
|
|
|
body := `{"reason":"need help"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-p2/handoff", strings.NewReader(body))
|
|
req = withActor(req, "agent-1", "agent")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
|
|
h.Handoff(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.Code)
|
|
}
|
|
if len(tickets.tickets) != 1 {
|
|
t.Fatalf("ticket count = %d, want 1", len(tickets.tickets))
|
|
}
|
|
if tickets.tickets[0].Priority != ticket.PriorityP2 {
|
|
t.Fatalf("priority = %s, want P2", tickets.tickets[0].Priority)
|
|
}
|
|
}
|
|
|
|
func TestHandoff_SessionNotFound(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
|
|
body := `{"reason":"urgent"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/nonexistent/handoff", strings.NewReader(body))
|
|
req = withActor(req, "agent-1", "agent")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
|
|
h.Handoff(resp, req)
|
|
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandoff_ReasonRequired(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
sessions.AddSession(&session.Session{ID: "sess-r1", Channel: "feishu", OpenID: "open-1", Status: session.StatusProcessing})
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
|
|
// empty reason
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-r1/handoff", strings.NewReader(`{"reason":""}`))
|
|
req = withActor(req, "agent-1", "agent")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.Handoff(resp, req)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("empty reason: status = %d, want 400", resp.Code)
|
|
}
|
|
|
|
// missing reason field
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-r1/handoff", strings.NewReader(`{}`))
|
|
req = withActor(req, "agent-1", "agent")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp = httptest.NewRecorder()
|
|
h.Handoff(resp, req)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing reason: status = %d, want 400", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandoff_InvalidJSON(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-1/handoff", strings.NewReader(`{bad json}`))
|
|
req = withActor(req, "agent-1", "agent")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.Handoff(resp, req)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandoff_TicketCreateFailure(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
sessions.AddSession(&session.Session{ID: "sess-err", Channel: "feishu", OpenID: "open-1", Status: session.StatusProcessing})
|
|
|
|
// ticket creator that always fails
|
|
failingTickets := &failingTicketCreator{}
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, failingTickets, audits)
|
|
|
|
body := `{"reason":"fail"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-err/handoff", strings.NewReader(body))
|
|
req = withActor(req, "agent-1", "agent")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
|
|
h.Handoff(resp, req)
|
|
|
|
if resp.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandoff_RejectsWhenActorOnlyProvidedByQuery(t *testing.T) {
|
|
sessions := newMockSessionGetter()
|
|
sessions.AddSession(&session.Session{ID: "sess-query", Channel: "feishu", OpenID: "open-1", Status: session.StatusProcessing})
|
|
tickets := newMockTicketCreator()
|
|
audits := newMockAuditRecorder()
|
|
h := NewSessionHandler(sessions, tickets, audits)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-query/handoff?actor_id=forged-admin", strings.NewReader(`{"reason":"need help"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.Handoff(resp, req)
|
|
|
|
if resp.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", resp.Code)
|
|
}
|
|
}
|
|
|
|
type failingTicketCreator struct{}
|
|
|
|
func (f *failingTicketCreator) Create(_ context.Context, _ *ticket.Ticket) error {
|
|
return context.DeadlineExceeded
|
|
}
|
|
|
|
// ---------- sessionPathParam tests ----------
|
|
|
|
func TestSessionPathParam(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
wantID string
|
|
wantEmpty bool
|
|
}{
|
|
{"/api/v1/customer-service/sessions/sess-abc/feedback", "sess-abc", false},
|
|
{"/api/v1/customer-service/sessions/sess-abc/handoff", "sess-abc", false},
|
|
{"/api/v1/customer-service/sessions//feedback", "", true},
|
|
// Paths not ending in /feedback or /handoff are invalid
|
|
{"/api/v1/customer-service/sessions/sess-123/other", "", true},
|
|
}
|
|
for _, c := range cases {
|
|
got := sessionPathParam(c.path)
|
|
if c.wantEmpty && got != "" {
|
|
t.Errorf("sessionPathParam(%q) = %q, want empty", c.path, got)
|
|
}
|
|
if !c.wantEmpty && got != c.wantID {
|
|
t.Errorf("sessionPathParam(%q) = %q, want %q", c.path, got, c.wantID)
|
|
}
|
|
}
|
|
}
|