fix(audit): use uuid.New() for ticket workflow audit IDs

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.
This commit is contained in:
Your Name
2026-05-04 13:44:39 +08:00
parent c7cb174c58
commit 087de4e102
23 changed files with 1459 additions and 195 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/bridge/ai-customer-service/internal/app"
"github.com/bridge/ai-customer-service/internal/config"
"github.com/bridge/ai-customer-service/internal/http/middleware"
"github.com/bridge/ai-customer-service/internal/platform/logging"
)
@@ -59,11 +60,16 @@ func mustReadBody(t *testing.T, resp *http.Response, dest any) {
}
}
func setActorHeaders(req *http.Request, actorID, role string) {
req.Header.Set(middleware.HeaderActorID, actorID)
req.Header.Set(middleware.HeaderActorRole, role)
}
// TestFullTicketFlow_E2E exercises the complete ticket lifecycle:
// 1. Webhook triggers handoff → ticket created
// 2. Ticket is assigned to an agent
// 3. Ticket is resolved by the agent
// 4. Ticket is retrieved and verified in final resolved state
// 1. Webhook triggers handoff → ticket created
// 2. Ticket is assigned to an agent
// 3. Ticket is resolved by the agent
// 4. Ticket is retrieved and verified in final resolved state
func TestFullTicketFlow_E2E(t *testing.T) {
application := newTestAppE2E(t)
server := httptest.NewServer(application.Server.Handler)
@@ -98,11 +104,12 @@ func TestFullTicketFlow_E2E(t *testing.T) {
ticketID := whResult.TicketID
// ── Step 2: Assign the ticket to an agent ────────────────────────────
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-e2e-001&actor_id=admin-e2e", baseURL, ticketID)
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-e2e-001", baseURL, ticketID)
assignReq, err := http.NewRequest(http.MethodPost, assignURL, nil)
if err != nil {
t.Fatalf("new assign request error = %v", err)
}
setActorHeaders(assignReq, "admin-e2e", "admin")
assignReq.RemoteAddr = "192.168.1.1:12345"
assignResp, err := http.DefaultClient.Do(assignReq)
if err != nil {
@@ -126,11 +133,12 @@ func TestFullTicketFlow_E2E(t *testing.T) {
}
// ── Step 3: Resolve the ticket ────────────────────────────────────────
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=refund+processed+and+closed&actor_id=agent-e2e-001", baseURL, ticketID)
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=refund+processed+and+closed", baseURL, ticketID)
resolveReq, err := http.NewRequest(http.MethodPost, resolveURL, nil)
if err != nil {
t.Fatalf("new resolve request error = %v", err)
}
setActorHeaders(resolveReq, "agent-e2e-001", "agent")
resolveReq.RemoteAddr = "192.168.1.2:54321"
resolveResp, err := http.DefaultClient.Do(resolveReq)
if err != nil {
@@ -155,7 +163,12 @@ func TestFullTicketFlow_E2E(t *testing.T) {
// ── Step 4: Verify ticket is retrievable in final resolved state ──────
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getResp, err := http.Get(getURL)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-e2e-001", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
@@ -215,8 +228,9 @@ func TestFullTicketFlow_AuditLogVerification(t *testing.T) {
ticketID := whResult.TicketID
// ── Step 2: Assign ticket ────────────────────────────────────────────
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-audit-99&actor_id=supervisor-audit", baseURL, ticketID)
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-audit-99", baseURL, ticketID)
assignReq, _ := http.NewRequest(http.MethodPost, assignURL, nil)
setActorHeaders(assignReq, "supervisor-audit", "supervisor")
assignReq.RemoteAddr = "10.0.0.1:11111"
assignResp, _ := http.DefaultClient.Do(assignReq)
if assignResp.StatusCode != http.StatusOK {
@@ -226,8 +240,9 @@ func TestFullTicketFlow_AuditLogVerification(t *testing.T) {
assignResp.Body.Close()
// ── Step 3: Resolve ticket ───────────────────────────────────────────
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=account+secured&actor_id=agent-audit-99", baseURL, ticketID)
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=account+secured", baseURL, ticketID)
resolveReq, _ := http.NewRequest(http.MethodPost, resolveURL, nil)
setActorHeaders(resolveReq, "agent-audit-99", "agent")
resolveReq.RemoteAddr = "10.0.0.2:22222"
resolveResp, _ := http.DefaultClient.Do(resolveReq)
if resolveResp.StatusCode != http.StatusOK {
@@ -238,7 +253,12 @@ func TestFullTicketFlow_AuditLogVerification(t *testing.T) {
// ── Step 4: Verify final ticket state (audit writes were persisted) ──
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getResp, err := http.Get(getURL)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-audit-99", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
@@ -300,7 +320,12 @@ func TestFullTicketFlow_ListEndpoint_ShowsCreatedTicket(t *testing.T) {
ticketID := whResult.TicketID
// Verify ticket appears in GET /tickets list
listResp, err := http.Get(baseURL + "/api/v1/customer-service/tickets")
listReq, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/customer-service/tickets", nil)
if err != nil {
t.Fatalf("new tickets list request error = %v", err)
}
setActorHeaders(listReq, "supervisor-list", "supervisor")
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatalf("GET tickets list error = %v", err)
}
@@ -388,7 +413,12 @@ func TestFullTicketFlow_MultipleTickets_MaintainedSeparately(t *testing.T) {
// Assign only the first ticket
if i == 0 {
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-only-first", baseURL, ticketID)
assignResp, err := http.Post(assignURL, "application/octet-stream", nil)
assignReq, err := http.NewRequest(http.MethodPost, assignURL, nil)
if err != nil {
t.Fatalf("new assign request error = %v", err)
}
setActorHeaders(assignReq, "supervisor-first", "supervisor")
assignResp, err := http.DefaultClient.Do(assignReq)
if err != nil {
t.Fatalf("assign POST error = %v", err)
}
@@ -401,7 +431,12 @@ func TestFullTicketFlow_MultipleTickets_MaintainedSeparately(t *testing.T) {
// Check state
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getResp, err := http.Get(getURL)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-check", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
@@ -469,7 +504,12 @@ func TestFullTicketFlow_WebhookAuditEvent(t *testing.T) {
// Verify ticket is in open state
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, whResult.TicketID)
getResp, err := http.Get(getURL)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-audit-read", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket error = %v", err)
}
@@ -529,7 +569,12 @@ func TestFullTicketFlow_StateTransitionAuditOrder(t *testing.T) {
// Assign (audit event: assign)
assignURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/assign?agent_id=agent-order-1", baseURL, ticketID)
assignResp, err := http.Post(assignURL, "application/octet-stream", nil)
assignReq, err := http.NewRequest(http.MethodPost, assignURL, nil)
if err != nil {
t.Fatalf("new assign request error = %v", err)
}
setActorHeaders(assignReq, "supervisor-order", "supervisor")
assignResp, err := http.DefaultClient.Do(assignReq)
if err != nil {
t.Fatalf("assign POST error = %v", err)
}
@@ -541,7 +586,12 @@ func TestFullTicketFlow_StateTransitionAuditOrder(t *testing.T) {
// Resolve (audit event: resolve)
resolveURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/resolve?resolution=handled", baseURL, ticketID)
resolveResp, err := http.Post(resolveURL, "application/octet-stream", nil)
resolveReq, err := http.NewRequest(http.MethodPost, resolveURL, nil)
if err != nil {
t.Fatalf("new resolve request error = %v", err)
}
setActorHeaders(resolveReq, "agent-order-1", "agent")
resolveResp, err := http.DefaultClient.Do(resolveReq)
if err != nil {
t.Fatalf("resolve POST error = %v", err)
}
@@ -553,7 +603,12 @@ func TestFullTicketFlow_StateTransitionAuditOrder(t *testing.T) {
// Final state check: proves all audit writes succeeded in order
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
getResp, err := http.Get(getURL)
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
if err != nil {
t.Fatalf("new get request error = %v", err)
}
setActorHeaders(getReq, "agent-order-1", "agent")
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET ticket (final) error = %v", err)
}

View File

@@ -0,0 +1,16 @@
package integration
import (
"net/http"
"github.com/bridge/ai-customer-service/internal/http/middleware"
)
func withActor(req *http.Request, actorID, role string) *http.Request {
return req.WithContext(middleware.WithActor(req.Context(), actorID, role))
}
func setActorHeaders(req *http.Request, actorID, role string) {
req.Header.Set(middleware.HeaderActorID, actorID)
req.Header.Set(middleware.HeaderActorRole, role)
}

View File

@@ -69,7 +69,10 @@ func newMockSessionService(audits *sessionAuditRecorder) *mockSessionService {
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.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 {
@@ -82,14 +85,20 @@ func (m *mockSessionService) GetSession(ctx context.Context, id string) (*sessio
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.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.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)
}
@@ -159,12 +168,12 @@ func (h *SessionHandler) Feedback(w http.ResponseWriter, r *http.Request) {
// 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",
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},
ActorID: sess.OpenID,
Payload: map[string]any{"score": reqBody.Score, "note": reqBody.Note},
CreatedAt: now,
})
writeJSON(w, http.StatusOK, map[string]any{"received": true})
@@ -199,7 +208,7 @@ func (h *SessionHandler) Handoff(w http.ResponseWriter, r *http.Request) {
HandoffReason: reqBody.Reason,
ContextSnapshot: map[string]any{
"channel": sess.Channel,
"open_id": sess.OpenID,
"open_id": sess.OpenID,
},
CreatedAt: now,
UpdatedAt: now,
@@ -213,13 +222,13 @@ func (h *SessionHandler) Handoff(w http.ResponseWriter, r *http.Request) {
_ = 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",
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},
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})
@@ -374,6 +383,7 @@ func TestSessionHandlerHandoff_Success(t *testing.T) {
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)
@@ -409,6 +419,7 @@ func TestSessionHandlerHandoff_SessionNotFound(t *testing.T) {
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)
@@ -442,6 +453,7 @@ func TestSessionHandlerHandoff_CreatesTicket(t *testing.T) {
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)

View File

@@ -151,7 +151,8 @@ func TestAssign_UpdatesStatusToAssigned(t *testing.T) {
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 := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/assign-tkt-1/assign?agent_id=agent-001", nil)
req = withActor(req, "supervisor-1", "supervisor")
req.RemoteAddr = "10.0.0.5:12345"
resp := httptest.NewRecorder()
h.Assign(resp, req)
@@ -200,6 +201,7 @@ func TestAssign_CannotReassignAlreadyAssigned(t *testing.T) {
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/assign-tkt-2/assign?agent_id=agent-second", nil)
req = withActor(req, "supervisor-2", "supervisor")
resp := httptest.NewRecorder()
h.Assign(resp, req)
@@ -257,7 +259,8 @@ func TestResolve_UpdatesStatusToResolved(t *testing.T) {
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 := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/resolve-tkt-1/resolve?resolution=issue+fixed", nil)
req = withActor(req, "agent-001", "agent")
req.RemoteAddr = "10.0.0.6:54321"
resp := httptest.NewRecorder()
h.Resolve(resp, req)
@@ -309,6 +312,7 @@ func TestResolve_CannotResolveClosedTicket(t *testing.T) {
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/resolve-tkt-closed/resolve?resolution=already+closed", nil)
req = withActor(req, "agent-001", "agent")
resp := httptest.NewRecorder()
h.Resolve(resp, req)
@@ -339,6 +343,7 @@ func TestResolve_TicketNotFound(t *testing.T) {
h := handlers.NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/nonexistent/resolve?resolution=not+found", nil)
req = withActor(req, "agent-404", "agent")
resp := httptest.NewRecorder()
h.Resolve(resp, req)
@@ -373,7 +378,8 @@ func TestStateTransition_OpenToAssignedToResolved(t *testing.T) {
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)
assignReq := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/state-tkt-1/assign?agent_id=agent-alpha", nil)
assignReq = withActor(assignReq, "admin-1", "admin")
assignResp := httptest.NewRecorder()
h.Assign(assignResp, assignReq)
if assignResp.Code != http.StatusOK {
@@ -389,7 +395,8 @@ func TestStateTransition_OpenToAssignedToResolved(t *testing.T) {
}
// 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)
resolveReq := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/state-tkt-1/resolve?resolution=refund+processed", nil)
resolveReq = withActor(resolveReq, "agent-alpha", "agent")
resolveResp := httptest.NewRecorder()
h.Resolve(resolveResp, resolveReq)
if resolveResp.Code != http.StatusOK {
@@ -430,6 +437,7 @@ func TestStateTransition_InvalidTransition(t *testing.T) {
// 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)
resolveReq = withActor(resolveReq, "agent-skip", "agent")
resolveResp := httptest.NewRecorder()
h.Resolve(resolveResp, resolveReq)
if resolveResp.Code != http.StatusConflict {

View File

@@ -68,14 +68,14 @@ func (m *mockTicketSvcForHandler) Assign(ctx context.Context, ticketID, agentID,
return err
}
m.audit.Add(ctx, audit.Event{
ID: "audit-assign-1",
Type: "ticket_state_changed",
Action: "assign",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
ID: "audit-assign-1",
Type: "ticket_state_changed",
Action: "assign",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned},
CreatedAt: now,
CreatedAt: now,
})
return nil
}
@@ -85,14 +85,14 @@ func (m *mockTicketSvcForHandler) Resolve(ctx context.Context, ticketID, resolut
return err
}
m.audit.Add(ctx, audit.Event{
ID: "audit-resolve-1",
Type: "ticket_state_changed",
Action: "resolve",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
ID: "audit-resolve-1",
Type: "ticket_state_changed",
Action: "resolve",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusResolved},
CreatedAt: now,
CreatedAt: now,
})
return nil
}
@@ -102,14 +102,14 @@ func (m *mockTicketSvcForHandler) Close(ctx context.Context, ticketID, resolutio
return err
}
m.audit.Add(ctx, audit.Event{
ID: "audit-close-1",
Type: "ticket_state_changed",
Action: "close",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
ID: "audit-close-1",
Type: "ticket_state_changed",
Action: "close",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusClosed},
CreatedAt: now,
CreatedAt: now,
})
return nil
}
@@ -163,6 +163,7 @@ func TestTicketCreateAndList_CreateThenFind(t *testing.T) {
handoffBodyBytes, _ := json.Marshal(handoffBody)
sessionReq := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_list_test/handoff", bytes.NewReader(handoffBodyBytes))
sessionReq.Header.Set("Content-Type", "application/json")
sessionReq = withActor(sessionReq, "agent-list", "agent")
sessionResp := httptest.NewRecorder()
sessionHdlr.Handoff(sessionResp, sessionReq)
@@ -292,7 +293,12 @@ func TestTicketList_PaginationParams(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, err := http.Get(server.URL + tc.query)
req, err := http.NewRequest(http.MethodGet, server.URL+tc.query, nil)
if err != nil {
t.Fatalf("new GET request error = %v", err)
}
setActorHeaders(req, "supervisor-page", "supervisor")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET error = %v", err)
}