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.
503 lines
16 KiB
Go
503 lines
16 KiB
Go
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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"
|
|
"github.com/bridge/ai-customer-service/internal/store/memory"
|
|
)
|
|
|
|
// --------------------------------------------------
|
|
// Mock infrastructure
|
|
// --------------------------------------------------
|
|
|
|
// sessionAuditRecorder mirrors the pattern from ticket_handler_test.go.
|
|
type sessionAuditRecorder struct {
|
|
events []audit.Event
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (r *sessionAuditRecorder) Add(_ context.Context, event audit.Event) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.events = append(r.events, event)
|
|
return nil
|
|
}
|
|
|
|
func (r *sessionAuditRecorder) 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
|
|
}
|
|
|
|
// mockSessionService simulates the session service used by session handlers.
|
|
type mockSessionService struct {
|
|
mu sync.Mutex
|
|
sessions *memory.SessionStore
|
|
tickets *memory.TicketStore
|
|
audits *sessionAuditRecorder
|
|
calls []struct {
|
|
method string
|
|
args []string
|
|
}
|
|
}
|
|
|
|
func newMockSessionService(audits *sessionAuditRecorder) *mockSessionService {
|
|
return &mockSessionService{
|
|
sessions: memory.NewSessionStore(),
|
|
tickets: memory.NewTicketStore(),
|
|
audits: audits,
|
|
}
|
|
}
|
|
|
|
func (m *mockSessionService) GetSession(ctx context.Context, id string) (*session.Session, error) {
|
|
m.mu.Lock()
|
|
m.calls = append(m.calls, struct {
|
|
method string
|
|
args []string
|
|
}{method: "GetSession", args: []string{id}})
|
|
m.mu.Unlock()
|
|
sessions := m.sessions.List()
|
|
for _, s := range sessions {
|
|
if s.ID == id {
|
|
return s, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSessionService) UpdateSession(ctx context.Context, sess *session.Session) error {
|
|
m.mu.Lock()
|
|
m.calls = append(m.calls, struct {
|
|
method string
|
|
args []string
|
|
}{method: "UpdateSession", args: []string{sess.ID}})
|
|
m.mu.Unlock()
|
|
return m.sessions.Save(ctx, sess)
|
|
}
|
|
|
|
func (m *mockSessionService) CreateTicket(ctx context.Context, t *ticket.Ticket) error {
|
|
m.mu.Lock()
|
|
m.calls = append(m.calls, struct {
|
|
method string
|
|
args []string
|
|
}{method: "CreateTicket", args: []string{t.ID, string(t.Priority), t.SessionID}})
|
|
m.mu.Unlock()
|
|
return m.tickets.Create(ctx, t)
|
|
}
|
|
|
|
func (m *mockSessionService) lastCall() []string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if len(m.calls) == 0 {
|
|
return nil
|
|
}
|
|
return m.calls[len(m.calls)-1].args
|
|
}
|
|
|
|
// --------------------------------------------------
|
|
// Minimal SessionHandler implementation (to be wired into router by engineer)
|
|
// --------------------------------------------------
|
|
|
|
// SessionService defines what the handler needs from the service layer.
|
|
type SessionService interface {
|
|
GetSession(ctx context.Context, id string) (*session.Session, error)
|
|
UpdateSession(ctx context.Context, sess *session.Session) error
|
|
CreateTicket(ctx context.Context, t *ticket.Ticket) error
|
|
}
|
|
|
|
// SessionHandler handles session-related HTTP endpoints.
|
|
type SessionHandler struct {
|
|
service SessionService
|
|
audit sessionAuditRecorderInterface
|
|
now func() time.Time
|
|
}
|
|
|
|
type sessionAuditRecorderInterface interface {
|
|
Add(ctx context.Context, event audit.Event) error
|
|
}
|
|
|
|
// NewSessionHandler creates a new SessionHandler.
|
|
func NewSessionHandler(svc SessionService, auditRecorder sessionAuditRecorderInterface) *SessionHandler {
|
|
return &SessionHandler{service: svc, audit: auditRecorder, now: time.Now}
|
|
}
|
|
|
|
func (h *SessionHandler) Feedback(w http.ResponseWriter, r *http.Request) {
|
|
sessionID := sessionPathParam(r.URL.Path)
|
|
if sessionID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "CS_REQ_4009", "message": "session_id is required"}})
|
|
return
|
|
}
|
|
|
|
var reqBody struct {
|
|
Score int `json:"score"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "CS_REQ_4001", "message": "invalid JSON"}})
|
|
return
|
|
}
|
|
if reqBody.Score < 1 || reqBody.Score > 5 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "CS_SES_4004", "message": "score must be between 1 and 5"}})
|
|
return
|
|
}
|
|
|
|
sess, err := h.service.GetSession(r.Context(), sessionID)
|
|
if err != nil || sess == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": "CS_SES_4001", "message": "session not found"}})
|
|
return
|
|
}
|
|
|
|
// Record feedback audit event
|
|
now := h.now()
|
|
_ = h.audit.Add(r.Context(), audit.Event{
|
|
ID: fmt.Sprintf("fb-%d", now.UnixNano()),
|
|
Type: "session_feedback",
|
|
Action: "feedback",
|
|
SessionID: sessionID,
|
|
ActorID: sess.OpenID,
|
|
Payload: map[string]any{"score": reqBody.Score, "note": reqBody.Note},
|
|
CreatedAt: now,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{"received": true})
|
|
}
|
|
|
|
func (h *SessionHandler) Handoff(w http.ResponseWriter, r *http.Request) {
|
|
sessionID := sessionPathParam(r.URL.Path)
|
|
if sessionID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "CS_REQ_4009", "message": "session_id is required"}})
|
|
return
|
|
}
|
|
|
|
var reqBody struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&reqBody)
|
|
|
|
sess, err := h.service.GetSession(r.Context(), sessionID)
|
|
if err != nil || sess == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": "CS_SES_4001", "message": "session not found"}})
|
|
return
|
|
}
|
|
|
|
now := h.now()
|
|
ticketID := fmt.Sprintf("tkt-%s-%d", sessionID, now.UnixNano())
|
|
tkt := &ticket.Ticket{
|
|
ID: ticketID,
|
|
SessionID: sessionID,
|
|
UserID: sess.UserID,
|
|
Priority: ticket.PriorityP2,
|
|
Status: ticket.StatusOpen,
|
|
HandoffReason: reqBody.Reason,
|
|
ContextSnapshot: map[string]any{
|
|
"channel": sess.Channel,
|
|
"open_id": sess.OpenID,
|
|
},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := h.service.CreateTicket(r.Context(), tkt); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"code": "CS_SYS_5001", "message": "internal server error"}})
|
|
return
|
|
}
|
|
|
|
sess.Status = session.StatusHandoff
|
|
_ = h.service.UpdateSession(r.Context(), sess)
|
|
|
|
_ = h.audit.Add(r.Context(), audit.Event{
|
|
ID: fmt.Sprintf("ho-%d", now.UnixNano()),
|
|
Type: "session_handoff",
|
|
Action: "handoff",
|
|
SessionID: sessionID,
|
|
TicketID: ticketID,
|
|
ActorID: sess.OpenID,
|
|
Payload: map[string]any{"reason": reqBody.Reason},
|
|
CreatedAt: now,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{"handoff": true, "ticket_id": ticketID})
|
|
}
|
|
|
|
func sessionPathParam(path string) string {
|
|
prefix := "/api/v1/customer-service/sessions/"
|
|
trimmed := path[len(prefix):]
|
|
if !strings.HasSuffix(trimmed, "/feedback") && !strings.HasSuffix(trimmed, "/handoff") {
|
|
return ""
|
|
}
|
|
trimmed = strings.TrimSuffix(trimmed, "/feedback")
|
|
trimmed = strings.TrimSuffix(trimmed, "/handoff")
|
|
return trimmed
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// --------------------------------------------------
|
|
// Tests — POST sessions/{id}/feedback
|
|
// --------------------------------------------------
|
|
|
|
func TestSessionHandlerFeedback_Success(t *testing.T) {
|
|
auditRecorder := &sessionAuditRecorder{}
|
|
svc := newMockSessionService(auditRecorder)
|
|
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
_, _ = svc.sessions.GetOrCreate(ctx, "widget", "u_feedback_ok", now)
|
|
sess, _ := svc.sessions.GetOrCreate(ctx, "widget", "u_feedback_ok", now)
|
|
sess.Status = session.StatusIdle
|
|
_ = svc.sessions.Save(ctx, sess)
|
|
|
|
h := NewSessionHandler(svc, auditRecorder)
|
|
h.now = func() time.Time { return now }
|
|
|
|
body := map[string]any{"score": 5, "note": "great service"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_feedback_ok/feedback", bytes.NewReader(bodyBytes))
|
|
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; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("json decode error = %v", err)
|
|
}
|
|
if payload["received"] != true {
|
|
t.Fatalf("received = %v, want true", payload["received"])
|
|
}
|
|
// Verify audit was recorded
|
|
events := auditRecorder.eventsOfType("feedback")
|
|
if len(events) != 1 {
|
|
t.Fatalf("feedback audit events = %d, want 1", len(events))
|
|
}
|
|
if events[0].SessionID != "widget:u_feedback_ok" {
|
|
t.Fatalf("audit session_id = %s, want widget:u_feedback_ok", events[0].SessionID)
|
|
}
|
|
}
|
|
|
|
func TestSessionHandlerFeedback_SessionNotFound(t *testing.T) {
|
|
auditRecorder := &sessionAuditRecorder{}
|
|
svc := newMockSessionService(auditRecorder)
|
|
h := NewSessionHandler(svc, auditRecorder)
|
|
|
|
body := map[string]any{"score": 4}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/nonexistent-session/feedback", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.Feedback(resp, req)
|
|
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
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_SES_4001" {
|
|
t.Fatalf("error code = %v, want CS_SES_4001", errPayload["code"])
|
|
}
|
|
}
|
|
|
|
func TestSessionHandlerFeedback_InvalidScore(t *testing.T) {
|
|
auditRecorder := &sessionAuditRecorder{}
|
|
svc := newMockSessionService(auditRecorder)
|
|
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
_, _ = svc.sessions.GetOrCreate(ctx, "widget", "u_invalid_score", now)
|
|
|
|
h := NewSessionHandler(svc, auditRecorder)
|
|
h.now = func() time.Time { return now }
|
|
|
|
// Score too low (0)
|
|
body := map[string]any{"score": 0}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_invalid_score/feedback", bytes.NewReader(bodyBytes))
|
|
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; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
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_SES_4004" {
|
|
t.Fatalf("error code = %v, want CS_SES_4004", errPayload["code"])
|
|
}
|
|
|
|
// Score too high (6)
|
|
body2 := map[string]any{"score": 6}
|
|
bodyBytes2, _ := json.Marshal(body2)
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_invalid_score/feedback", bytes.NewReader(bodyBytes2))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
resp2 := httptest.NewRecorder()
|
|
h.Feedback(resp2, req2)
|
|
if resp2.Code != http.StatusBadRequest {
|
|
t.Fatalf("status(score=6) = %d, want 400", resp2.Code)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------
|
|
// Tests — POST sessions/{id}/handoff
|
|
// --------------------------------------------------
|
|
|
|
func TestSessionHandlerHandoff_Success(t *testing.T) {
|
|
auditRecorder := &sessionAuditRecorder{}
|
|
svc := newMockSessionService(auditRecorder)
|
|
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
_, _ = svc.sessions.GetOrCreate(ctx, "widget", "u_handoff_ok", now)
|
|
sess, _ := svc.sessions.GetOrCreate(ctx, "widget", "u_handoff_ok", now)
|
|
sess.Status = session.StatusIdle
|
|
_ = svc.sessions.Save(ctx, sess)
|
|
|
|
h := NewSessionHandler(svc, auditRecorder)
|
|
h.now = func() time.Time { return now }
|
|
|
|
body := map[string]any{"reason": "manual transfer"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_handoff_ok/handoff", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withActor(req, "agent-handoff", "agent")
|
|
resp := httptest.NewRecorder()
|
|
h.Handoff(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("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("json decode error = %v", err)
|
|
}
|
|
if payload["handoff"] != true {
|
|
t.Fatalf("handoff = %v, want true", payload["handoff"])
|
|
}
|
|
ticketID, ok := payload["ticket_id"].(string)
|
|
if !ok || ticketID == "" {
|
|
t.Fatalf("ticket_id missing or empty, got %v", payload["ticket_id"])
|
|
}
|
|
// Verify session was updated to handoff status
|
|
updated := svc.sessions.List()
|
|
for _, s := range updated {
|
|
if s.ID == "widget:u_handoff_ok" && s.Status != session.StatusHandoff {
|
|
t.Fatalf("session status = %s, want handoff", s.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionHandlerHandoff_SessionNotFound(t *testing.T) {
|
|
auditRecorder := &sessionAuditRecorder{}
|
|
svc := newMockSessionService(auditRecorder)
|
|
h := NewSessionHandler(svc, auditRecorder)
|
|
|
|
body := map[string]any{"reason": "manual"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/nonexistent-session/handoff", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withActor(req, "agent-missing", "agent")
|
|
resp := httptest.NewRecorder()
|
|
h.Handoff(resp, req)
|
|
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
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_SES_4001" {
|
|
t.Fatalf("error code = %v, want CS_SES_4001", errPayload["code"])
|
|
}
|
|
}
|
|
|
|
func TestSessionHandlerHandoff_CreatesTicket(t *testing.T) {
|
|
auditRecorder := &sessionAuditRecorder{}
|
|
svc := newMockSessionService(auditRecorder)
|
|
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
_, _ = svc.sessions.GetOrCreate(ctx, "telegram", "u_ticket_create", now)
|
|
sess, _ := svc.sessions.GetOrCreate(ctx, "telegram", "u_ticket_create", now)
|
|
sess.Status = session.StatusIdle
|
|
_ = svc.sessions.Save(ctx, sess)
|
|
|
|
h := NewSessionHandler(svc, auditRecorder)
|
|
h.now = func() time.Time { return now }
|
|
|
|
body := map[string]any{"reason": "customer requested human"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/telegram:u_ticket_create/handoff", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withActor(req, "agent-ticket-create", "agent")
|
|
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)
|
|
}
|
|
ticketID, ok := payload["ticket_id"].(string)
|
|
if !ok || ticketID == "" {
|
|
t.Fatalf("ticket_id missing, got %v", payload["ticket_id"])
|
|
}
|
|
|
|
// Verify ticket was stored with correct fields
|
|
tickets := svc.tickets.List()
|
|
found := false
|
|
for _, tk := range tickets {
|
|
if tk.ID == ticketID {
|
|
found = true
|
|
if tk.SessionID != "telegram:u_ticket_create" {
|
|
t.Fatalf("ticket session_id = %s, want telegram:u_ticket_create", tk.SessionID)
|
|
}
|
|
if tk.Status != ticket.StatusOpen {
|
|
t.Fatalf("ticket status = %s, want open", tk.Status)
|
|
}
|
|
if tk.HandoffReason != "customer requested human" {
|
|
t.Fatalf("handoff_reason = %s, want 'customer requested human'", tk.HandoffReason)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("ticket %s not found in store", ticketID)
|
|
}
|
|
|
|
// Verify handoff audit event was recorded
|
|
events := auditRecorder.eventsOfType("handoff")
|
|
if len(events) != 1 {
|
|
t.Fatalf("handoff audit events = %d, want 1", len(events))
|
|
}
|
|
if events[0].TicketID != ticketID {
|
|
t.Fatalf("audit ticket_id = %s, want %s", events[0].TicketID, ticketID)
|
|
}
|
|
}
|