300 lines
11 KiB
Go
300 lines
11 KiB
Go
package httpadapter
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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/promptdef"
|
|
"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 TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|
start := time.Now().UTC()
|
|
end := start.Add(2 * time.Second)
|
|
const envName = "SCRIPTORIUM_API_KEY"
|
|
const secret = "never-include-me"
|
|
|
|
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,
|
|
APIKeyEnv: envName,
|
|
},
|
|
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, "api_key_env": "SCRIPTORIUM_API_KEY"}
|
|
}`)
|
|
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)
|
|
}
|
|
|
|
metadata := resp["metadata"].(map[string]any)
|
|
if metadata["prompt_id"] != "prompt-1" {
|
|
t.Fatalf("unexpected metadata.prompt_id: %#v", metadata["prompt_id"])
|
|
}
|
|
if metadata["prompt_version"] != "1.0.0" {
|
|
t.Fatalf("unexpected metadata.prompt_version: %#v", metadata["prompt_version"])
|
|
}
|
|
if metadata["selected_profile_id"] != "exec-default" {
|
|
t.Fatalf("unexpected metadata.selected_profile_id: %#v", metadata["selected_profile_id"])
|
|
}
|
|
if metadata["model_name"] != "m1" || metadata["endpoint"] != "http://llm/v1" {
|
|
t.Fatalf("unexpected model metadata: name=%#v endpoint=%#v", metadata["model_name"], metadata["endpoint"])
|
|
}
|
|
modelParams := metadata["model_params"].(map[string]any)
|
|
if modelParams["api_key_env"] != envName {
|
|
t.Fatalf("expected model_params.api_key_env=%q, got %#v", envName, modelParams["api_key_env"])
|
|
}
|
|
if strings.Contains(w.Body.String(), secret) {
|
|
t.Fatalf("response leaked raw API key value: %s", w.Body.String())
|
|
}
|
|
|
|
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 TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
|
r := &fakeRunner{result: &domain.RunResult{
|
|
Artifact: domain.Artifact{Body: []byte("ok")},
|
|
PromptID: "prompt-1",
|
|
PromptVersion: "1.0.0",
|
|
SelectedProfileID: "prompt-default",
|
|
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"prompt-1","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.ProfileID != "" {
|
|
t.Fatalf("expected empty request profile_id when omitted, got %q", r.last.ProfileID)
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
metadata := resp["metadata"].(map[string]any)
|
|
if metadata["selected_profile_id"] != "prompt-default" {
|
|
t.Fatalf("expected selected_profile_id from result, got %#v", metadata["selected_profile_id"])
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
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 errBody["code"] != "invalid_request" {
|
|
t.Fatalf("expected invalid_request code, got %#v", errBody["code"])
|
|
}
|
|
}
|
|
|
|
func TestHandlerUsecaseErrorMapping(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
status int
|
|
code string
|
|
message string
|
|
avoidCause string
|
|
}{
|
|
{name: "prompt not found", err: wrap(usecase.ErrProfileLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"},
|
|
{name: "prompt load invalid", err: wrap(usecase.ErrProfileLoad, promptdef.ErrInvalidPromptDefinition), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition"},
|
|
{name: "missing profile/default", err: wrap(usecase.ErrInvalidRequest, errors.New("profile id is required either in request or prompt default_profile")), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"},
|
|
{name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"},
|
|
{name: "profile invalid", err: wrap(usecase.ErrProfileLoad, profile.ErrInvalidProfile), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile"},
|
|
{name: "api key env missing", err: wrap(usecase.ErrInvalidRequest, errors.New(`api key environment variable "SCRIPTORIUM_API_KEY" is not set`)), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"},
|
|
{name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"},
|
|
{name: "prompt render", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"},
|
|
{name: "llm", err: wrap(usecase.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"},
|
|
{name: "validation runtime", err: wrap(usecase.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"},
|
|
}
|
|
|
|
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 errBody["code"] != tc.code {
|
|
t.Fatalf("expected code %q, got %#v", tc.code, errBody["code"])
|
|
}
|
|
if errBody["message"] != tc.message {
|
|
t.Fatalf("expected message %q, got %#v", tc.message, errBody["message"])
|
|
}
|
|
if tc.avoidCause != "" && strings.Contains(w.Body.String(), tc.avoidCause) {
|
|
t.Fatalf("expected response to avoid leaking cause details, got %s", w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRawAPIKeyRejectedByStrictJSON(t *testing.T) {
|
|
h := NewHandler(&fakeRunner{})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}},"model":{"model":"m","api_key":"secret"}}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d body=%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)
|
|
}
|
|
errBody := resp["error"].(map[string]any)
|
|
if errBody["code"] != "invalid_json" {
|
|
t.Fatalf("expected invalid_json code, got %#v", errBody["code"])
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|