From 175e9be36a289e8b9bf82b29a4db71478541cff3 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 4 May 2026 17:59:22 +0800 Subject: [PATCH] feat(chat): http handler + DTO + admin guard for endpoints/models/usage --- internal/chat/dto.go | 291 +++++++++++++ internal/chat/handler.go | 782 ++++++++++++++++++++++++++++++++++ internal/chat/handler_test.go | 390 +++++++++++++++++ 3 files changed, 1463 insertions(+) create mode 100644 internal/chat/dto.go create mode 100644 internal/chat/handler.go create mode 100644 internal/chat/handler_test.go diff --git a/internal/chat/dto.go b/internal/chat/dto.go new file mode 100644 index 0000000..cbc23de --- /dev/null +++ b/internal/chat/dto.go @@ -0,0 +1,291 @@ +package chat + +import ( + "time" + + "github.com/google/uuid" +) + +// ===== Request DTOs ===== + +// CreateConversationReq is the HTTP request body for POST /conversations. +type CreateConversationReq struct { + ModelID uuid.UUID `json:"model_id"` + TemplateID *uuid.UUID `json:"template_id,omitempty"` + SystemPrompt *string `json:"system_prompt,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` + IssueID *uuid.UUID `json:"issue_id,omitempty"` +} + +// UpdateConversationTitleReq is the HTTP request body for PATCH /conversations/{id}. +type UpdateConversationTitleReq struct { + Title string `json:"title"` +} + +// SendMessageReq is the HTTP request body for POST /conversations/{id}/messages. +type SendMessageReq struct { + Content string `json:"content"` + AttachmentIDs []uuid.UUID `json:"attachment_ids,omitempty"` +} + +// CreateTemplateReq is the HTTP request body for POST /prompt-templates. +type CreateTemplateReq struct { + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` // "user" | "system" +} + +// UpdateTemplateReq is the HTTP request body for PATCH /prompt-templates/{id}. +type UpdateTemplateReq struct { + Name string `json:"name"` + Content string `json:"content"` +} + +// CreateEndpointReq is the HTTP request body for POST /admin/llm-endpoints. +type CreateEndpointReq struct { + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` +} + +// UpdateEndpointReq is the HTTP request body for PATCH /admin/llm-endpoints/{id}. +type UpdateEndpointReq struct { + DisplayName *string `json:"display_name,omitempty"` + BaseURL *string `json:"base_url,omitempty"` + APIKey *string `json:"api_key,omitempty"` +} + +// CreateModelReq is the HTTP request body for POST /admin/llm-models. +type CreateModelReq struct { + EndpointID uuid.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities ModelCapabilities `json:"capabilities"` + ContextWindow int `json:"context_window"` + MaxOutputTokens int `json:"max_output_tokens"` + PromptPricePerMillionUSD float64 `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUSD float64 `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUSD float64 `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` +} + +// UpdateModelReq is the HTTP request body for PATCH /admin/llm-models/{id}. +type UpdateModelReq struct { + DisplayName *string `json:"display_name,omitempty"` + Capabilities *ModelCapabilities `json:"capabilities,omitempty"` + ContextWindow *int `json:"context_window,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + PromptPricePerMillionUSD *float64 `json:"prompt_price_per_million_usd,omitempty"` + CompletionPricePerMillionUSD *float64 `json:"completion_price_per_million_usd,omitempty"` + ThinkingPricePerMillionUSD *float64 `json:"thinking_price_per_million_usd,omitempty"` + IsTitleGenerator *bool `json:"is_title_generator,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` +} + +// ===== Response DTOs ===== + +// ConversationDTO is the JSON representation of a Conversation. +type ConversationDTO struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ModelID uuid.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` + IssueID *uuid.UUID `json:"issue_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MessageDTO is the JSON representation of a Message. +type MessageDTO struct { + ID int64 `json:"id"` + ConversationID uuid.UUID `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Thinking *string `json:"thinking,omitempty"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message,omitempty"` + PromptTokens *int `json:"prompt_tokens,omitempty"` + CompletionTokens *int `json:"completion_tokens,omitempty"` + ThinkingTokens *int `json:"thinking_tokens,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AttachmentDTO is the JSON representation of an Attachment. +type AttachmentDTO struct { + ID uuid.UUID `json:"id"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + CreatedAt time.Time `json:"created_at"` +} + +// SendMessageResp is the response body for POST /conversations/{id}/messages. +type SendMessageResp struct { + UserMessageID int64 `json:"user_message_id"` + AssistantMessageID int64 `json:"assistant_message_id"` + StreamURL string `json:"stream_url"` +} + +// TemplateDTO is the JSON representation of a PromptTemplate. +type TemplateDTO struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` + OwnerID *uuid.UUID `json:"owner_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// EndpointDTO is the JSON representation of a LLMEndpoint. +// APIKeyEncrypted is intentionally excluded to prevent leaking secrets. +type EndpointDTO struct { + ID uuid.UUID `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseURL string `json:"base_url"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ModelDTO is the JSON representation of a LLMModel. +type ModelDTO struct { + ID uuid.UUID `json:"id"` + EndpointID uuid.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities ModelCapabilities `json:"capabilities"` + ContextWindow int `json:"context_window"` + MaxOutputTokens int `json:"max_output_tokens"` + PromptPricePerMillionUSD float64 `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUSD float64 `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUSD float64 `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// DayUsageDTO is the per-day usage row returned to both regular users and admins. +type DayUsageDTO struct { + Day time.Time `json:"day"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ThinkingTokens int `json:"thinking_tokens"` + CostUSD float64 `json:"cost_usd"` +} + +// UserUsageDTO is the per-user aggregate usage row returned to admins. +type UserUsageDTO struct { + UserID uuid.UUID `json:"user_id"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ThinkingTokens int `json:"thinking_tokens"` + CostUSD float64 `json:"cost_usd"` +} + +// ModelUsageDTO is the per-model aggregate usage row returned to admins. +type ModelUsageDTO struct { + ModelID uuid.UUID `json:"model_id"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ThinkingTokens int `json:"thinking_tokens"` + CostUSD float64 `json:"cost_usd"` +} + +// AdminUsageResp is the response body for GET /admin/usage. +type AdminUsageResp struct { + ByUser []UserUsageDTO `json:"by_user"` + ByModel []ModelUsageDTO `json:"by_model"` + ByDay []DayUsageDTO `json:"by_day"` +} + +// ===== Conversion helpers ===== + +func toConversationDTO(c *Conversation) ConversationDTO { + return ConversationDTO{ + ID: c.ID, + UserID: c.UserID, + ModelID: c.ModelID, + SystemPrompt: c.SystemPrompt, + Title: c.Title, + ProjectID: c.ProjectID, + WorkspaceID: c.WorkspaceID, + IssueID: c.IssueID, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func toMessageDTO(m *Message) MessageDTO { + return MessageDTO{ + ID: m.ID, + ConversationID: m.ConversationID, + Role: string(m.Role), + Content: m.Content, + Thinking: m.Thinking, + Status: string(m.Status), + ErrorMessage: m.ErrorMessage, + PromptTokens: m.PromptTokens, + CompletionTokens: m.CompletionTokens, + ThinkingTokens: m.ThinkingTokens, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +func toTemplateDTO(t *PromptTemplate) TemplateDTO { + return TemplateDTO{ + ID: t.ID, + Name: t.Name, + Content: t.Content, + Scope: string(t.Scope), + OwnerID: t.OwnerID, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + +func toEndpointDTO(ep *LLMEndpoint) EndpointDTO { + return EndpointDTO{ + ID: ep.ID, + Provider: string(ep.Provider), + DisplayName: ep.DisplayName, + BaseURL: ep.BaseURL, + CreatedBy: ep.CreatedBy, + CreatedAt: ep.CreatedAt, + UpdatedAt: ep.UpdatedAt, + } +} + +func toModelDTO(m *LLMModel) ModelDTO { + return ModelDTO{ + ID: m.ID, + EndpointID: m.EndpointID, + ModelID: m.ModelID, + DisplayName: m.DisplayName, + Capabilities: m.Capabilities, + ContextWindow: m.ContextWindow, + MaxOutputTokens: m.MaxOutputTokens, + PromptPricePerMillionUSD: m.PromptPricePerMillionUSD, + CompletionPricePerMillionUSD: m.CompletionPricePerMillionUSD, + ThinkingPricePerMillionUSD: m.ThinkingPricePerMillionUSD, + IsTitleGenerator: m.IsTitleGenerator, + Enabled: m.Enabled, + SortOrder: m.SortOrder, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} diff --git a/internal/chat/handler.go b/internal/chat/handler.go new file mode 100644 index 0000000..e8617a3 --- /dev/null +++ b/internal/chat/handler.go @@ -0,0 +1,782 @@ +// handler.go exposes the chat module's HTTP endpoints. +// Routes follow the workspace/handler.go pattern: a single Handler holds all +// service dependencies, and Mount registers sub-routes with Auth middleware. +// +// Auth strategy: +// - All routes require a valid session (Auth middleware, 401 on failure). +// - Admin-only routes are wrapped by adminGuard, which checks IsAdmin and +// returns 403 for non-admin callers. +// +// Error format: httpx.WriteError (Google API style {code, message, request_id}). +package chat + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// AdminLookup resolves whether a user is an admin. +// Using a narrow interface (rather than importing the user package directly) +// mirrors the workspace handler pattern and makes the dependency testable. +type AdminLookup interface { + IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) +} + +// Handler holds all chat service dependencies and implements chi.Router mounting. +type Handler struct { + convSvc ConversationService + msgSvc MessageService + tplSvc TemplateService + epSvc EndpointService + usageSvc UsageService + upload *Uploader + resolver middleware.SessionResolver + users AdminLookup +} + +// NewHandler constructs a Handler. +func NewHandler( + conv ConversationService, + msg MessageService, + tpl TemplateService, + ep EndpointService, + usage UsageService, + upload *Uploader, + resolver middleware.SessionResolver, + users AdminLookup, +) *Handler { + return &Handler{ + convSvc: conv, + msgSvc: msg, + tplSvc: tpl, + epSvc: ep, + usageSvc: usage, + upload: upload, + resolver: resolver, + users: users, + } +} + +// Mount registers all chat routes under /api/v1 on r. +func (h *Handler) Mount(r chi.Router) { + r.Route("/api/v1", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + + // User-scoped conversation routes. + r.Get("/conversations", h.listConversations) + r.Post("/conversations", h.createConversation) + r.Get("/conversations/{id}", h.getConversation) + r.Patch("/conversations/{id}", h.updateConversationTitle) + r.Delete("/conversations/{id}", h.deleteConversation) + r.Post("/conversations/{id}/messages", h.sendMessage) + r.Get("/conversations/{id}/stream", h.streamMessages) // T18 + r.Get("/conversations/{id}/messages", h.listMessages) + + // User-scoped message routes. + r.Post("/messages/{id}/cancel", h.cancelMessage) + r.Post("/messages/{id}/retry", h.retryMessage) + + // Upload endpoint (T18). + r.Post("/uploads", h.upload.Handle) + + // User-scoped template routes. + r.Get("/prompt-templates", h.listTemplates) + r.Post("/prompt-templates", h.createTemplate) + r.Patch("/prompt-templates/{id}", h.updateTemplate) + r.Delete("/prompt-templates/{id}", h.deleteTemplate) + + // User usage. + r.Get("/users/me/usage", h.userDailyUsage) + + // Admin-only routes. + r.Group(func(r chi.Router) { + r.Use(h.adminGuard) + r.Get("/admin/llm-endpoints", h.listEndpoints) + r.Post("/admin/llm-endpoints", h.createEndpoint) + r.Patch("/admin/llm-endpoints/{id}", h.updateEndpoint) + r.Delete("/admin/llm-endpoints/{id}", h.deleteEndpoint) + r.Post("/admin/llm-endpoints/{id}/test", h.testEndpoint) + r.Get("/admin/llm-models", h.listModels) + r.Post("/admin/llm-models", h.createModel) + r.Patch("/admin/llm-models/{id}", h.updateModel) + r.Delete("/admin/llm-models/{id}", h.deleteModel) + r.Get("/admin/usage", h.adminUsage) + }) + }) +} + +// adminGuard is middleware that enforces admin access for the wrapped routes. +func (h *Handler) adminGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized")) + return + } + isAdmin, err := h.users.IsAdmin(r.Context(), uid) + if err != nil { + writeErr(w, r, err) + return + } + if !isAdmin { + writeErr(w, r, errs.New(errs.CodeForbidden, "admin only")) + return + } + next.ServeHTTP(w, r) + }) +} + +// userID returns the authenticated user's ID from the request context. +// Returns CodeUnauthorized if the Auth middleware did not run or yielded no ID. +func (h *Handler) userID(r *http.Request) (uuid.UUID, error) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + return uuid.Nil, errs.New(errs.CodeUnauthorized, "unauthorized") + } + return uid, nil +} + +// ===== Transport helpers ===== + +func writeErr(w http.ResponseWriter, r *http.Request, err error) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + httpx.WriteJSON(w, status, body) +} + +func parseUUID(s string) (uuid.UUID, error) { + id, err := uuid.Parse(s) + if err != nil { + return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid uuid") + } + return id, nil +} + +// ===== Conversation handlers ===== + +func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + q := r.URL.Query() + f := ConversationFilter{ + TitleQuery: q.Get("q"), + } + if v := q.Get("limit"); v != "" { + if n, e := strconv.Atoi(v); e == nil { + f.Limit = n + } + } + if v := q.Get("project_id"); v != "" { + if id, e := parseUUID(v); e == nil { + f.ProjectID = &id + } + } + if v := q.Get("workspace_id"); v != "" { + if id, e := parseUUID(v); e == nil { + f.WorkspaceID = &id + } + } + if v := q.Get("issue_id"); v != "" { + if id, e := parseUUID(v); e == nil { + f.IssueID = &id + } + } + convs, err := h.convSvc.List(r.Context(), uid, f) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]ConversationDTO, 0, len(convs)) + for i := range convs { + out = append(out, toConversationDTO(&convs[i])) + } + writeJSON(w, http.StatusOK, out) +} + +func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + var req CreateConversationReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + conv, err := h.convSvc.Create(r.Context(), uid, CreateConversationInput{ + ModelID: req.ModelID, + TemplateID: req.TemplateID, + SystemPrompt: req.SystemPrompt, + ProjectID: req.ProjectID, + WorkspaceID: req.WorkspaceID, + IssueID: req.IssueID, + }) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusCreated, toConversationDTO(conv)) +} + +func (h *Handler) getConversation(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + conv, msgs, err := h.convSvc.Get(r.Context(), uid, id) + if err != nil { + writeErr(w, r, err) + return + } + msgDTOs := make([]MessageDTO, 0, len(msgs)) + for i := range msgs { + msgDTOs = append(msgDTOs, toMessageDTO(&msgs[i])) + } + writeJSON(w, http.StatusOK, map[string]any{ + "conversation": toConversationDTO(conv), + "messages": msgDTOs, + }) +} + +func (h *Handler) updateConversationTitle(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + var req UpdateConversationTitleReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + if err := h.convSvc.UpdateTitle(r.Context(), uid, id, req.Title); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) deleteConversation(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + if err := h.convSvc.SoftDelete(r.Context(), uid, id); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ===== Message handlers ===== + +func (h *Handler) sendMessage(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + convID, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + var req SendMessageReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + userMsgID, assistantMsgID, err := h.msgSvc.Send(r.Context(), uid, convID, req.Content, req.AttachmentIDs) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, SendMessageResp{ + UserMessageID: userMsgID, + AssistantMessageID: assistantMsgID, + StreamURL: fmt.Sprintf("/api/v1/conversations/%s/stream", convID), + }) +} + +func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + convID, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + q := r.URL.Query() + var beforeID *int64 + if v := q.Get("before_id"); v != "" { + n, e := strconv.ParseInt(v, 10, 64) + if e == nil { + beforeID = &n + } + } + limit := 50 + if v := q.Get("limit"); v != "" { + if n, e := strconv.Atoi(v); e == nil && n > 0 { + limit = n + } + } + msgs, err := h.msgSvc.ListMessages(r.Context(), uid, convID, beforeID, limit) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]MessageDTO, 0, len(msgs)) + for i := range msgs { + out = append(out, toMessageDTO(&msgs[i])) + } + writeJSON(w, http.StatusOK, out) +} + +func (h *Handler) cancelMessage(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + msgID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id")) + return + } + if err := h.msgSvc.Cancel(r.Context(), uid, msgID); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) retryMessage(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + msgID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id")) + return + } + newID, err := h.msgSvc.Retry(r.Context(), uid, msgID) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]int64{"assistant_message_id": newID}) +} + +// ===== Template handlers ===== + +func (h *Handler) listTemplates(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + tpls, err := h.tplSvc.List(r.Context(), uid) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]TemplateDTO, 0, len(tpls)) + for i := range tpls { + out = append(out, toTemplateDTO(&tpls[i])) + } + writeJSON(w, http.StatusOK, out) +} + +func (h *Handler) createTemplate(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + var req CreateTemplateReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + scope := TemplateScope(req.Scope) + // Creating a system-scoped template requires admin privileges. + if scope == TemplateScopeSystem { + isAdmin, e := h.users.IsAdmin(r.Context(), uid) + if e != nil { + writeErr(w, r, e) + return + } + if !isAdmin { + writeErr(w, r, errs.New(errs.CodeForbidden, "system scope requires admin")) + return + } + } + tpl, err := h.tplSvc.Create(r.Context(), uid, req.Name, req.Content, scope) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusCreated, toTemplateDTO(tpl)) +} + +func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + var req UpdateTemplateReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + if err := h.tplSvc.Update(r.Context(), uid, id, req.Name, req.Content); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) deleteTemplate(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + if err := h.tplSvc.Delete(r.Context(), uid, id); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ===== Usage handlers ===== + +func (h *Handler) userDailyUsage(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + q := r.URL.Query() + to := time.Now().UTC() + from := to.AddDate(0, 0, -30) + if v := q.Get("from"); v != "" { + if t, e := time.Parse(time.RFC3339, v); e == nil { + from = t + } + } + if v := q.Get("to"); v != "" { + if t, e := time.Parse(time.RFC3339, v); e == nil { + to = t + } + } + rows, err := h.usageSvc.UserDaily(r.Context(), uid, from, to) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]DayUsageDTO, 0, len(rows)) + for _, row := range rows { + out = append(out, DayUsageDTO{ + Day: row.Day, + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: row.CostUSD, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// ===== Admin: Endpoint handlers ===== + +func (h *Handler) listEndpoints(w http.ResponseWriter, r *http.Request) { + eps, err := h.epSvc.ListEndpoints(r.Context()) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]EndpointDTO, 0, len(eps)) + for i := range eps { + out = append(out, toEndpointDTO(&eps[i])) + } + writeJSON(w, http.StatusOK, out) +} + +func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) { + uid, err := h.userID(r) + if err != nil { + writeErr(w, r, err) + return + } + var req CreateEndpointReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + ep, err := h.epSvc.CreateEndpoint(r.Context(), uid, CreateEndpointInput{ + Provider: LLMProvider(req.Provider), + DisplayName: req.DisplayName, + BaseURL: req.BaseURL, + APIKey: req.APIKey, + }) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusCreated, toEndpointDTO(ep)) +} + +func (h *Handler) updateEndpoint(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + var req UpdateEndpointReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + if err := h.epSvc.UpdateEndpoint(r.Context(), id, UpdateEndpointInput{ + DisplayName: req.DisplayName, + BaseURL: req.BaseURL, + APIKey: req.APIKey, + }); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) deleteEndpoint(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + if err := h.epSvc.DeleteEndpoint(r.Context(), id); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) testEndpoint(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + if err := h.epSvc.TestEndpoint(r.Context(), id); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ===== Admin: Model handlers ===== + +func (h *Handler) listModels(w http.ResponseWriter, r *http.Request) { + onlyEnabled := r.URL.Query().Get("only_enabled") == "true" + models, err := h.epSvc.ListModels(r.Context(), onlyEnabled) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]ModelDTO, 0, len(models)) + for i := range models { + out = append(out, toModelDTO(&models[i])) + } + writeJSON(w, http.StatusOK, out) +} + +func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) { + var req CreateModelReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + m, err := h.epSvc.CreateModel(r.Context(), CreateModelInput{ + EndpointID: req.EndpointID, + ModelID: req.ModelID, + DisplayName: req.DisplayName, + Capabilities: req.Capabilities, + ContextWindow: req.ContextWindow, + MaxOutputTokens: req.MaxOutputTokens, + PromptPrice: req.PromptPricePerMillionUSD, + CompletionPrice: req.CompletionPricePerMillionUSD, + ThinkingPrice: req.ThinkingPricePerMillionUSD, + IsTitleGenerator: req.IsTitleGenerator, + Enabled: req.Enabled, + SortOrder: req.SortOrder, + }) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusCreated, toModelDTO(m)) +} + +func (h *Handler) updateModel(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + var req UpdateModelReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + if err := h.epSvc.UpdateModel(r.Context(), id, UpdateModelInput{ + DisplayName: req.DisplayName, + Capabilities: req.Capabilities, + ContextWindow: req.ContextWindow, + MaxOutputTokens: req.MaxOutputTokens, + PromptPrice: req.PromptPricePerMillionUSD, + CompletionPrice: req.CompletionPricePerMillionUSD, + ThinkingPrice: req.ThinkingPricePerMillionUSD, + IsTitleGenerator: req.IsTitleGenerator, + Enabled: req.Enabled, + SortOrder: req.SortOrder, + }); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) deleteModel(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + if err := h.epSvc.DeleteModel(r.Context(), id); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ===== Admin: Usage handler ===== + +func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + to := time.Now().UTC() + from := to.AddDate(0, 0, -30) + if v := q.Get("from"); v != "" { + if t, e := time.Parse(time.RFC3339, v); e == nil { + from = t + } + } + if v := q.Get("to"); v != "" { + if t, e := time.Parse(time.RFC3339, v); e == nil { + to = t + } + } + + byUser, err := h.usageSvc.SummarizeByUser(r.Context(), from, to) + if err != nil { + writeErr(w, r, err) + return + } + byModel, err := h.usageSvc.SummarizeByModel(r.Context(), from, to) + if err != nil { + writeErr(w, r, err) + return + } + byDay, err := h.usageSvc.SummarizeByDay(r.Context(), from, to) + if err != nil { + writeErr(w, r, err) + return + } + + userDTOs := make([]UserUsageDTO, 0, len(byUser)) + for _, row := range byUser { + userDTOs = append(userDTOs, UserUsageDTO{ + UserID: row.UserID, + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: row.CostUSD, + }) + } + modelDTOs := make([]ModelUsageDTO, 0, len(byModel)) + for _, row := range byModel { + modelDTOs = append(modelDTOs, ModelUsageDTO{ + ModelID: row.ModelID, + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: row.CostUSD, + }) + } + dayDTOs := make([]DayUsageDTO, 0, len(byDay)) + for _, row := range byDay { + dayDTOs = append(dayDTOs, DayUsageDTO{ + Day: row.Day, + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: row.CostUSD, + }) + } + + writeJSON(w, http.StatusOK, AdminUsageResp{ + ByUser: userDTOs, + ByModel: modelDTOs, + ByDay: dayDTOs, + }) +} diff --git a/internal/chat/handler_test.go b/internal/chat/handler_test.go new file mode 100644 index 0000000..1490790 --- /dev/null +++ b/internal/chat/handler_test.go @@ -0,0 +1,390 @@ +package chat + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// ===== Fake service implementations for handler tests ===== + +// fakeSessionResolver always resolves to fakeUID when a non-empty token is provided. +type fakeSessionResolver struct { + uid uuid.UUID +} + +func (f *fakeSessionResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { + if token == "" { + return uuid.Nil, false, nil + } + return f.uid, true, nil +} + +// fakeAdminLookup allows controlling IsAdmin per-user. +type fakeAdminLookup struct { + adminIDs map[uuid.UUID]bool +} + +func newFakeAdminLookup(admins ...uuid.UUID) *fakeAdminLookup { + m := make(map[uuid.UUID]bool, len(admins)) + for _, id := range admins { + m[id] = true + } + return &fakeAdminLookup{adminIDs: m} +} + +func (f *fakeAdminLookup) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) { + return f.adminIDs[id], nil +} + +// ===== Fake ConversationService ===== + +type fakeConvSvc struct { + createFunc func(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error) + listFunc func(ctx context.Context, userID uuid.UUID, f ConversationFilter) ([]Conversation, error) + getFunc func(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) +} + +func (f *fakeConvSvc) Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error) { + return f.createFunc(ctx, userID, in) +} +func (f *fakeConvSvc) List(ctx context.Context, userID uuid.UUID, filter ConversationFilter) ([]Conversation, error) { + if f.listFunc != nil { + return f.listFunc(ctx, userID, filter) + } + return nil, nil +} +func (f *fakeConvSvc) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) { + if f.getFunc != nil { + return f.getFunc(ctx, userID, id) + } + return nil, nil, errs.New(errs.CodeChatConversationNotFound, "not found") +} +func (f *fakeConvSvc) UpdateTitle(_ context.Context, _, _ uuid.UUID, _ string) error { return nil } +func (f *fakeConvSvc) SoftDelete(_ context.Context, _, _ uuid.UUID) error { return nil } + +// ===== Fake MessageService ===== + +type fakeMsgSvc struct { + sendFunc func(ctx context.Context, userID, convID uuid.UUID, content string, ids []uuid.UUID) (int64, int64, error) + retryErr error +} + +func (f *fakeMsgSvc) Send(ctx context.Context, userID, convID uuid.UUID, content string, ids []uuid.UUID) (int64, int64, error) { + if f.sendFunc != nil { + return f.sendFunc(ctx, userID, convID, content, ids) + } + return 1, 2, nil +} +func (f *fakeMsgSvc) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ int64) (<-chan SSEEvent, error) { + ch := make(chan SSEEvent) + close(ch) + return ch, nil +} +func (f *fakeMsgSvc) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil } +func (f *fakeMsgSvc) Retry(_ context.Context, _ uuid.UUID, msgID int64) (int64, error) { + if f.retryErr != nil { + return 0, f.retryErr + } + return msgID + 100, nil +} +func (f *fakeMsgSvc) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) { + return nil, nil +} + +// ===== Fake TemplateService ===== + +type fakeTplSvc struct { + createFunc func(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*PromptTemplate, error) +} + +func (f *fakeTplSvc) List(_ context.Context, _ uuid.UUID) ([]PromptTemplate, error) { return nil, nil } +func (f *fakeTplSvc) Create(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*PromptTemplate, error) { + if f.createFunc != nil { + return f.createFunc(ctx, userID, name, content, scope) + } + return &PromptTemplate{ID: uuid.New(), Name: name, Content: content, Scope: scope, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil +} +func (f *fakeTplSvc) Update(_ context.Context, _, _ uuid.UUID, _, _ string) error { return nil } +func (f *fakeTplSvc) Delete(_ context.Context, _, _ uuid.UUID) error { return nil } + +// ===== Fake EndpointService ===== + +type fakeEpSvc struct { + listEndpointsErr error +} + +func (f *fakeEpSvc) ListEndpoints(_ context.Context) ([]LLMEndpoint, error) { + if f.listEndpointsErr != nil { + return nil, f.listEndpointsErr + } + return nil, nil +} +func (f *fakeEpSvc) CreateEndpoint(_ context.Context, _ uuid.UUID, in CreateEndpointInput) (*LLMEndpoint, error) { + return &LLMEndpoint{ID: uuid.New(), Provider: in.Provider, DisplayName: in.DisplayName, BaseURL: in.BaseURL, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil +} +func (f *fakeEpSvc) UpdateEndpoint(_ context.Context, _ uuid.UUID, _ UpdateEndpointInput) error { + return nil +} +func (f *fakeEpSvc) DeleteEndpoint(_ context.Context, _ uuid.UUID) error { return nil } +func (f *fakeEpSvc) TestEndpoint(_ context.Context, _ uuid.UUID) error { return nil } +func (f *fakeEpSvc) ListModels(_ context.Context, _ bool) ([]LLMModel, error) { + return nil, nil +} +func (f *fakeEpSvc) CreateModel(_ context.Context, in CreateModelInput) (*LLMModel, error) { + return &LLMModel{ID: uuid.New(), ModelID: in.ModelID, DisplayName: in.DisplayName, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil +} +func (f *fakeEpSvc) UpdateModel(_ context.Context, _ uuid.UUID, _ UpdateModelInput) error { + return nil +} +func (f *fakeEpSvc) DeleteModel(_ context.Context, _ uuid.UUID) error { return nil } + +// ===== Fake UsageService ===== + +type fakeUsageSvc struct{} + +func (f *fakeUsageSvc) Insert(_ context.Context, _ UsageRecord) error { return nil } +func (f *fakeUsageSvc) SummarizeByUser(_ context.Context, _, _ time.Time) ([]UserUsageRow, error) { + return nil, nil +} +func (f *fakeUsageSvc) SummarizeByModel(_ context.Context, _, _ time.Time) ([]ModelUsageRow, error) { + return nil, nil +} +func (f *fakeUsageSvc) SummarizeByDay(_ context.Context, _, _ time.Time) ([]DayUsageRow, error) { + return nil, nil +} +func (f *fakeUsageSvc) UserDaily(_ context.Context, _ uuid.UUID, _, _ time.Time) ([]DayUsageRow, error) { + return nil, nil +} + +// ===== Test helper ===== + +// newTestHandler builds a Handler with the given services and mounts it on a chi router. +// The resolver resolves any non-empty token to uid; adminIDs are treated as admins. +func newTestHandler( + convSvc ConversationService, + msgSvc MessageService, + tplSvc TemplateService, + epSvc EndpointService, + uid uuid.UUID, + adminLookup AdminLookup, +) (*Handler, chi.Router) { + resolver := &fakeSessionResolver{uid: uid} + upload := NewUploader(newFakeRepo(), nil, nil, 1<<20) + h := NewHandler(convSvc, msgSvc, tplSvc, epSvc, &fakeUsageSvc{}, upload, resolver, adminLookup) + r := chi.NewRouter() + r.Use(middleware.RequestID) + h.Mount(r) + return h, r +} + +// authRequest creates a request with a Bearer token so Auth middleware passes. +func authRequest(method, path string, body []byte) *http.Request { + var r *http.Request + if body != nil { + r = httptest.NewRequest(method, path, bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequest(method, path, nil) + } + r.Header.Set("Authorization", "Bearer test-token") + return r +} + +// ===== Tests ===== + +// TestHandler_SendMessage_Returns200WithStreamURL verifies that sendMessage +// calls the service with the right arguments and returns a SendMessageResp +// with a correctly formed StreamURL. +func TestHandler_SendMessage_Returns200WithStreamURL(t *testing.T) { + uid := uuid.New() + convID := uuid.New() + + var capturedConvID uuid.UUID + var capturedContent string + msgSvc := &fakeMsgSvc{ + sendFunc: func(_ context.Context, _ uuid.UUID, cID uuid.UUID, content string, _ []uuid.UUID) (int64, int64, error) { + capturedConvID = cID + capturedContent = content + return 10, 20, nil + }, + } + + _, r := newTestHandler(&fakeConvSvc{}, msgSvc, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup()) + + body, _ := json.Marshal(SendMessageReq{Content: "hello"}) + req := authRequest(http.MethodPost, "/api/v1/conversations/"+convID.String()+"/messages", body) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp SendMessageResp + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.Equal(t, int64(10), resp.UserMessageID) + assert.Equal(t, int64(20), resp.AssistantMessageID) + assert.Contains(t, resp.StreamURL, convID.String()) + assert.Equal(t, convID, capturedConvID) + assert.Equal(t, "hello", capturedContent) +} + +// TestHandler_CreateConversation_LocksTemplateContent verifies that the handler +// passes the TemplateID and SystemPrompt fields through to the service unchanged. +// The service is responsible for resolving template content; the handler must +// not override or ignore these fields. +func TestHandler_CreateConversation_LocksTemplateContent(t *testing.T) { + uid := uuid.New() + templateID := uuid.New() + customPrompt := "my custom system prompt" + + var capturedInput CreateConversationInput + convSvc := &fakeConvSvc{ + createFunc: func(_ context.Context, _ uuid.UUID, in CreateConversationInput) (*Conversation, error) { + capturedInput = in + return &Conversation{ + ID: uuid.New(), + UserID: uid, + ModelID: in.ModelID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil + }, + } + + _, r := newTestHandler(convSvc, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup()) + + modelID := uuid.New() + body, _ := json.Marshal(CreateConversationReq{ + ModelID: modelID, + TemplateID: &templateID, + SystemPrompt: &customPrompt, + }) + req := authRequest(http.MethodPost, "/api/v1/conversations", body) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + // Handler must forward both TemplateID and SystemPrompt to the service + // (service decides precedence, not handler). + assert.Equal(t, modelID, capturedInput.ModelID) + require.NotNil(t, capturedInput.TemplateID) + assert.Equal(t, templateID, *capturedInput.TemplateID) + require.NotNil(t, capturedInput.SystemPrompt) + assert.Equal(t, customPrompt, *capturedInput.SystemPrompt) +} + +// TestHandler_AdminEndpoint_403WhenNotAdmin verifies that a non-admin user +// receives 403 when calling an admin-only endpoint. +func TestHandler_AdminEndpoint_403WhenNotAdmin(t *testing.T) { + uid := uuid.New() + // uid is NOT in the admin lookup. + _, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup()) + + req := authRequest(http.MethodGet, "/api/v1/admin/llm-endpoints", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + var body map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + assert.Equal(t, string(errs.CodeForbidden), body["code"]) +} + +// TestHandler_RetryMessage_404OnUnknownID verifies that when the message +// service returns a not-found error, the handler maps it to 404. +func TestHandler_RetryMessage_404OnUnknownID(t *testing.T) { + uid := uuid.New() + msgSvc := &fakeMsgSvc{ + retryErr: errs.New(errs.CodeChatMessageNotFound, "message not found"), + } + + _, r := newTestHandler(&fakeConvSvc{}, msgSvc, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup()) + + req := authRequest(http.MethodPost, "/api/v1/messages/99999/retry", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + var body map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + assert.Equal(t, string(errs.CodeChatMessageNotFound), body["code"]) +} + +// TestHandler_CreateTemplate_SystemScopeRequiresAdmin verifies that a +// non-admin user cannot create a system-scoped template and receives 403. +func TestHandler_CreateTemplate_SystemScopeRequiresAdmin(t *testing.T) { + uid := uuid.New() + // uid is not an admin. + _, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup()) + + body, _ := json.Marshal(map[string]string{ + "name": "sys tpl", + "content": "You are a system assistant", + "scope": "system", + }) + req := authRequest(http.MethodPost, "/api/v1/prompt-templates", body) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// TestHandler_CreateTemplate_SystemScopeAllowedForAdmin verifies that an +// admin user can create a system-scoped template. +func TestHandler_CreateTemplate_SystemScopeAllowedForAdmin(t *testing.T) { + uid := uuid.New() + // uid is an admin. + _, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup(uid)) + + body, _ := json.Marshal(map[string]string{ + "name": "sys tpl", + "content": "You are a system assistant", + "scope": "system", + }) + req := authRequest(http.MethodPost, "/api/v1/prompt-templates", body) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) +} + +// TestHandler_Unauthenticated_Returns401 verifies that requests without a +// Bearer token are rejected by the Auth middleware with 401. +func TestHandler_Unauthenticated_Returns401(t *testing.T) { + uid := uuid.New() + _, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup()) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/conversations", nil) + // No Authorization header. + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +// TestHandler_AdminAllowed_ListEndpoints verifies that an admin can call a +// admin-only endpoint and receives a 200. +func TestHandler_AdminAllowed_ListEndpoints(t *testing.T) { + uid := uuid.New() + _, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup(uid)) + + req := authRequest(http.MethodGet, "/api/v1/admin/llm-endpoints", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + // Response should be a JSON array (empty is fine). + assert.True(t, strings.HasPrefix(w.Body.String(), "[") || w.Body.String() == "[]\n") +}