Ctrl K
ComunidadGuías…

internal/rest/ai_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
	}
}