package httpadapter import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/scriptorium/internal/profile" "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" ) type fakeRunner struct { result *domain.RunResult err error last domain.RunRequest } func (f *fakeRunner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) { f.last = req if f.err != nil { return nil, f.err } return f.result, nil } func TestHandlerPostRunsSuccess(t *testing.T) { start := time.Now().UTC() end := start.Add(2 * time.Second) r := &fakeRunner{result: &domain.RunResult{ RunID: "11111111-1111-4111-8111-111111111111", Artifact: domain.Artifact{ Name: "output", ContentType: "text/plain", Body: []byte("hello"), Size: 5, Hash: "abc", }, Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, PromptID: "prompt-1", PromptVersion: "1.0.0", PromptHash: "phash", RenderedPromptHash: "rhash", SelectedProfileID: "exec-default", ModelName: "m1", Endpoint: "http://llm/v1", EffectiveModelParams: domain.ExecutionTarget{ Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.2, MaxTokens: 42, TopP: 0.9, TimeoutSeconds: 120, }, InputHashes: map[string]string{"transcript": "h1"}, Usage: domain.TokenUsage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3}, StartTime: start, EndTime: end, Duration: 2 * time.Second, RawOutput: "hello", }} h := NewHandler(r) body := []byte(`{ "prompt_id": "prompt-1", "profile_id": "exec-default", "inputs": { "transcript": {"type": "file", "uri": "./t.md"} }, "vars": {"k": "v"}, "model": {"model": "gpt-x", "timeout_seconds": 120} }`) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body)) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid JSON response: %v", err) } artifact := resp["artifact"].(map[string]any) if artifact["body"] != "hello" { t.Fatalf("expected artifact body hello, got %#v", artifact["body"]) } validation := resp["validation"].(map[string]any) if validation["status"] != "passed" { t.Fatalf("expected validation.status passed, got %#v", validation["status"]) } metadata := resp["metadata"].(map[string]any) if metadata["run_id"] != "11111111-1111-4111-8111-111111111111" { t.Fatalf("unexpected metadata.run_id: %#v", metadata["run_id"]) } if metadata["prompt_hash"] != "phash" { t.Fatalf("unexpected metadata.prompt_hash: %#v", metadata["prompt_hash"]) } usage := metadata["usage"].(map[string]any) if usage["total_tokens"] != float64(3) { t.Fatalf("expected usage.total_tokens=3, got %#v", usage["total_tokens"]) } if metadata["duration_ms"] != float64(2000) { t.Fatalf("expected duration_ms=2000, got %#v", metadata["duration_ms"]) } if metadata["validation_mode"] != "basic" || metadata["validation_status"] != "passed" { t.Fatalf("unexpected validation metadata: mode=%#v status=%#v", metadata["validation_mode"], metadata["validation_status"]) } modelParams := metadata["model_params"].(map[string]any) if modelParams["model"] != "m1" { t.Fatalf("unexpected model_params.model: %#v", modelParams["model"]) } if resp["raw_model_output"] != "hello" { t.Fatalf("expected raw model output hello, got %#v", resp["raw_model_output"]) } if r.last.PromptID != "prompt-1" { t.Fatalf("expected request prompt_id prompt-1, got %q", r.last.PromptID) } if r.last.ProfileID != "exec-default" { t.Fatalf("expected request profile_id exec-default, got %q", r.last.ProfileID) } if r.last.Execution == nil || r.last.Execution.Model != "gpt-x" { t.Fatalf("expected model override, got %#v", r.last.Execution) } if r.last.Execution.TimeoutSeconds != 120 { t.Fatalf("expected timeout_seconds override 120, got %#v", r.last.Execution) } } func TestHandlerInvalidJSON(t *testing.T) { h := NewHandler(&fakeRunner{}) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString("{")) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestHandlerMissingPromptID(t *testing.T) { h := NewHandler(&fakeRunner{}) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"inputs":{"x":{"type":"file","uri":"a"}}}`)) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestHandlerUsecaseErrorMapping(t *testing.T) { tests := []struct { name string err error status int }{ {name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound}, {name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest}, {name: "prompt", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest}, {name: "llm", err: wrap(usecase.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway}, {name: "validation runtime", err: wrap(usecase.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { h := NewHandler(&fakeRunner{err: tc.err}) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`)) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != tc.status { t.Fatalf("expected %d, got %d body=%s", tc.status, w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid JSON response: %v", err) } errBody := resp["error"].(map[string]any) if _, ok := errBody["code"].(string); !ok { t.Fatalf("expected error code string, got %#v", errBody["code"]) } if msg, ok := errBody["message"].(string); !ok || msg == "" { t.Fatalf("expected non-empty error message, got %#v", errBody["message"]) } if strings.Contains(w.Body.String(), "read failed") || strings.Contains(w.Body.String(), "render failed") || strings.Contains(w.Body.String(), "llm failed") { t.Fatalf("expected response to avoid leaking internal cause details, got %s", w.Body.String()) } }) } } func TestHandlerValidationFailureStillSuccess(t *testing.T) { h := NewHandler(&fakeRunner{result: &domain.RunResult{ Artifact: domain.Artifact{Body: []byte("bad json")}, RawOutput: "bad json", Validation: domain.ValidationResult{ Status: domain.ValidationFailed, Mode: domain.ValidationJSON, Errors: []string{"invalid JSON"}, }, }}) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`)) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid JSON response: %v", err) } validation := resp["validation"].(map[string]any) if status, ok := validation["status"].(string); !ok || status != "failed" { t.Fatalf("expected validation status=failed, got %#v", validation["status"]) } } func wrap(stage error, cause error) error { return fmt.Errorf("%w: %w", stage, cause) }