internal/rest/ai_test.go
Rama main · réplica de lectura del repositorio original.
package rest_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/sarathsp06/sparrow/internal/ai"
"github.com/sarathsp06/sparrow/internal/rest"
"github.com/sarathsp06/sparrow/internal/webhooks"
"github.com/sarathsp06/sparrow/internal/webhooks/store"
)
// eventOnlyService implements just GetEvent; every other method panics via
// the nil embedded interface, which is what we want in these tests.
type eventOnlyService struct {
webhooks.WebhookServiceInterface
events map[string]*store.EventRegistration
alertDelivery bool
}
func (s eventOnlyService) AlertDeliveryConfigured(context.Context) (bool, error) {
return s.alertDelivery, nil
}
func (s eventOnlyService) GetEvent(_ context.Context, name string) (*store.EventRegistration, error) {
return s.events[name], nil
}
func (s eventOnlyService) GetTemplateFunctions() []webhooks.TemplateFunctionInfo {
return []webhooks.TemplateFunctionInfo{{Name: "json", Description: "# json\n\nConverts a value to JSON."}}
}
type stubDrafter struct {
got ai.Request
res *ai.Result
err error
}
func (s *stubDrafter) DraftTemplate(_ context.Context, req ai.Request) (*ai.Result, error) {
s.got = req
return s.res, s.err
}
func (s *stubDrafter) Model() string { return "stub-model" }
func (s *stubDrafter) Provider() string { return "anthropic" }
func aiRouter(t *testing.T, drafter rest.TemplateDrafter) http.Handler {
t.Helper()
r := chi.NewRouter()
svc := eventOnlyService{events: map[string]*store.EventRegistration{
"order.created": {Name: "order.created", Schema: map[string]any{"type": "object"}, SamplePayload: map[string]any{"id": "ord_1"}},
}}
rest.Mount(r, svc, rest.AccessDeps{}, rest.AIDeps{Drafter: drafter}, nil)
return r
}
func do(h http.Handler, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestCapabilities_ReflectsDrafter(t *testing.T) {
var caps struct {
AIDrafting struct {
Enabled bool `json:"enabled"`
Model string `json:"model"`
} `json:"ai_drafting"`
}
rec := do(aiRouter(t, nil), http.MethodGet, "/v1/capabilities", "")
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
_ = json.Unmarshal(rec.Body.Bytes(), &caps)
if caps.AIDrafting.Enabled || caps.AIDrafting.Model != "" {
t.Fatalf("disabled server reports %+v", caps)
}
rec = do(aiRouter(t, &stubDrafter{}), http.MethodGet, "/v1/capabilities", "")
_ = json.Unmarshal(rec.Body.Bytes(), &caps)
if !caps.AIDrafting.Enabled || caps.AIDrafting.Model != "stub-model" {
t.Fatalf("enabled server reports %+v", caps)
}
}
func TestCapabilities_ReportsAlertDelivery(t *testing.T) {
for _, configured := range []bool{false, true} {
r := chi.NewRouter()
rest.Mount(r, eventOnlyService{alertDelivery: configured}, rest.AccessDeps{}, rest.AIDeps{}, nil)
rec := do(r, http.MethodGet, "/v1/capabilities", "")
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var caps struct {
AlertDelivery struct {
Configured bool `json:"configured"`
} `json:"alert_delivery"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &caps)
if caps.AlertDelivery.Configured != configured {
t.Fatalf("alert_delivery.configured = %v, want %v", caps.AlertDelivery.Configured, configured)
}
}
}
func TestDraftTemplate_DisabledIs503(t *testing.T) {
rec := do(aiRouter(t, nil), http.MethodPost, "/v1/subscriptions:draftTemplate",
`{"event_name":"order.created","instructions":"just the id"}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status %d, want 503: %s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "SPARROW_AI_ENABLED") {
t.Fatalf("body should tell the operator how to enable it: %s", rec.Body)
}
}
// Drafting switched on with a missing provider setting keeps the server up:
// capabilities report the problem, the draft answers 503 naming it, and the
// copy-and-paste prompt still works.
func TestDraftTemplate_EnabledButMisconfigured(t *testing.T) {
r := chi.NewRouter()
svc := eventOnlyService{events: map[string]*store.EventRegistration{
"order.created": {Name: "order.created", Schema: map[string]any{"type": "object"}, SamplePayload: map[string]any{"id": "ord_1"}},
}}
problem := errors.New("SPARROW_AI_ENABLED=true but SPARROW_AI_API_KEY is not set")
rest.Mount(r, svc, rest.AccessDeps{}, rest.AIDeps{ConfigErr: problem}, nil)
var caps struct {
AIDrafting struct {
Enabled bool `json:"enabled"`
Problem string `json:"problem"`
PromptOnly bool `json:"prompt_only"`
} `json:"ai_drafting"`
}
rec := do(r, http.MethodGet, "/v1/capabilities", "")
_ = json.Unmarshal(rec.Body.Bytes(), &caps)
if !caps.AIDrafting.Enabled || caps.AIDrafting.PromptOnly || caps.AIDrafting.Problem != problem.Error() {
t.Fatalf("capabilities = %+v, want enabled with the problem", caps.AIDrafting)
}
rec = do(r, http.MethodPost, "/v1/subscriptions:draftTemplate", `{"event_name":"order.created","instructions":"just the id"}`)
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "SPARROW_AI_API_KEY is not set") {
t.Fatalf("draft = %d %s, want 503 naming the missing setting", rec.Code, rec.Body)
}
rec = do(r, http.MethodPost, "/v1/subscriptions:draftTemplatePrompt", `{"event_name":"order.created","instructions":"just the id"}`)
if rec.Code != 200 {
t.Fatalf("prompt = %d %s, want 200", rec.Code, rec.Body)
}
}
func TestDraftTemplate_GroundsRequestAndReturnsDraft(t *testing.T) {
d := &stubDrafter{res: &ai.Result{Template: "{{ .payload.id | json }}", Rendered: `"ord_1"`, Notes: "used id", Attempts: 1, Model: "stub-model"}}
rec := do(aiRouter(t, d), http.MethodPost, "/v1/subscriptions:draftTemplate",
`{"event_name":"order.created","instructions":"just the id","recipe":"slack","target_example":"{\"x\":1}","current_template":"old","docs_url":"https://docs.example.com/hooks","sample_payload":{"id":"ord_9"}}`)
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if d.got.EventName != "order.created" || d.got.SamplePayload["id"] != "ord_9" || d.got.Schema["type"] != "object" {
t.Fatalf("event not grounded (provided sample should win): %+v", d.got)
}
if d.got.DocsURL != "https://docs.example.com/hooks" {
t.Fatalf("docs_url not passed: %+v", d.got)
}
if !strings.Contains(rec.Body.String(), `"sample_source":"provided"`) {
t.Fatalf("sample_source should be provided: %s", rec.Body)
}
if d.got.Recipe == nil || d.got.Recipe.Name != "slack" || d.got.Recipe.Guidance == "" || !strings.Contains(d.got.Recipe.Template, "blocks") {
t.Fatalf("recipe not resolved: %+v", d.got)
}
if d.got.TargetExample != `{"x":1}` || d.got.CurrentTemplate != "old" || d.got.Instructions != "just the id" {
t.Fatalf("optional inputs not passed: %+v", d.got)
}
var out struct {
Template, Rendered, Notes, Model string
Attempts int
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Template != d.res.Template || out.Rendered != d.res.Rendered || out.Notes != "used id" || out.Attempts != 1 || out.Model != "stub-model" {
t.Fatalf("unexpected body: %s", rec.Body)
}
// Without sample_payload the registered sample grounds the draft.
rec = do(aiRouter(t, d), http.MethodPost, "/v1/subscriptions:draftTemplate", `{"event_name":"order.created","instructions":"x"}`)
if rec.Code != 200 || d.got.SamplePayload["id"] != "ord_1" || !strings.Contains(rec.Body.String(), `"sample_source":"registered"`) {
t.Fatalf("registered sample not used: %d %s %+v", rec.Code, rec.Body, d.got)
}
}
func TestDraftTemplatePrompt_WorksWithoutAProvider(t *testing.T) {
rec := do(aiRouter(t, nil), http.MethodPost, "/v1/subscriptions:draftTemplatePrompt",
`{"event_name":"order.created","instructions":"short Slack text","recipe":"slack","sample_payload":{"id":"ord_9","email":"a@example.com"}}`)
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var out struct {
Prompt string `json:"prompt"`
SampleSource string `json:"sample_source"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
for _, want := range []string{"order.created", `"id": "ord_9"`, "a@example.com", "short Slack text", `# Part 2: Destination recipe "slack"`, "### json", "fenced code block", "# Part 3"} {
if !strings.Contains(out.Prompt, want) {
t.Errorf("prompt missing %q", want)
}
}
if out.SampleSource != "provided" {
t.Fatalf("sample_source = %q", out.SampleSource)
}
// Capabilities tell the UI to show the copy-prompt button instead.
rec = do(aiRouter(t, nil), http.MethodGet, "/v1/capabilities", "")
if !strings.Contains(rec.Body.String(), `"prompt_only":true`) {
t.Fatalf("capabilities should report prompt_only: %s", rec.Body)
}
// docs_url without a fetcher is refused clearly; unknown event is 404.
rec = do(aiRouter(t, nil), http.MethodPost, "/v1/subscriptions:draftTemplatePrompt",
`{"event_name":"order.created","instructions":"x","docs_url":"https://example.com/docs"}`)
if rec.Code != 400 || !strings.Contains(rec.Body.String(), "docs_url") {
t.Fatalf("docs_url without fetcher: %d %s", rec.Code, rec.Body)
}
rec = do(aiRouter(t, nil), http.MethodPost, "/v1/subscriptions:draftTemplatePrompt", `{"event_name":"nope","instructions":"x"}`)
if rec.Code != 404 {
t.Fatalf("unknown event: %d", rec.Code)
}
}
func TestDraftTemplate_NotFound(t *testing.T) {
d := &stubDrafter{res: &ai.Result{Template: "x"}}
rec := do(aiRouter(t, d), http.MethodPost, "/v1/subscriptions:draftTemplate",
`{"event_name":"nope","instructions":"x"}`)
if rec.Code != 404 {
t.Fatalf("unknown event: status %d, want 404: %s", rec.Code, rec.Body)
}
rec = do(aiRouter(t, d), http.MethodPost, "/v1/subscriptions:draftTemplate",
`{"event_name":"order.created","instructions":"x","recipe":"no-such-recipe"}`)
if rec.Code != 404 {
t.Fatalf("unknown recipe: status %d, want 404: %s", rec.Code, rec.Body)
}
rec = do(aiRouter(t, d), http.MethodPost, "/v1/subscriptions:draftTemplate",
`{"event_name":"order.created"}`)
if rec.Code != 422 && rec.Code != 400 {
t.Fatalf("missing instructions: status %d, want validation error: %s", rec.Code, rec.Body)
}
}