2026-05-04 21:19:29 -05:00
package httpadapter
import (
"bytes"
"context"
"encoding/json"
2026-05-05 10:54:35 -05:00
"errors"
2026-05-04 21:19:29 -05:00
"fmt"
"net/http"
"net/http/httptest"
2026-05-05 08:41:07 -05:00
"strings"
2026-05-04 21:19:29 -05:00
"testing"
"time"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
2026-05-05 10:54:35 -05:00
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
2026-05-04 21:19:29 -05:00
"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
}
2026-05-05 10:54:35 -05:00
func TestHandlerPostRunsSuccessWithExplicitProfileID ( t * testing . T ) {
2026-05-04 21:19:29 -05:00
start := time . Now ( ) . UTC ( )
end := start . Add ( 2 * time . Second )
2026-05-05 10:54:35 -05:00
const envName = "SCRIPTORIUM_API_KEY"
const secret = "never-include-me"
2026-05-04 21:19:29 -05:00
r := & fakeRunner { result : & domain . RunResult {
2026-05-05 08:41:07 -05:00
RunID : "11111111-1111-4111-8111-111111111111" ,
2026-05-04 21:19:29 -05:00
Artifact : domain . Artifact {
Name : "output" ,
ContentType : "text/plain" ,
Body : [ ] byte ( "hello" ) ,
Size : 5 ,
Hash : "abc" ,
} ,
2026-05-05 10:09:31 -05:00
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 {
2026-05-05 08:41:07 -05:00
Endpoint : "http://llm/v1" ,
Model : "m1" ,
Temperature : 0.2 ,
MaxTokens : 42 ,
TopP : 0.9 ,
TimeoutSeconds : 120 ,
2026-05-05 10:54:35 -05:00
APIKeyEnv : envName ,
2026-05-05 08:41:07 -05:00
} ,
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" ,
2026-05-04 21:19:29 -05:00
} }
h := NewHandler ( r )
body := [ ] byte ( ` {
2026-05-05 10:09:31 -05:00
"prompt_id" : "prompt-1" ,
"profile_id" : "exec-default" ,
2026-05-04 21:19:29 -05:00
"inputs" : {
"transcript" : { "type" : "file" , "uri" : "./t.md" }
} ,
"vars" : { "k" : "v" } ,
2026-05-05 10:54:35 -05:00
"model" : { "model" : "gpt-x" , "timeout_seconds" : 120 , "api_key_env" : "SCRIPTORIUM_API_KEY" }
2026-05-04 21:19:29 -05:00
} ` )
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 )
}
2026-05-05 08:25:32 -05:00
metadata := resp [ "metadata" ] . ( map [ string ] any )
2026-05-05 10:54:35 -05:00
if metadata [ "prompt_id" ] != "prompt-1" {
t . Fatalf ( "unexpected metadata.prompt_id: %#v" , metadata [ "prompt_id" ] )
2026-05-05 08:41:07 -05:00
}
2026-05-05 10:54:35 -05:00
if metadata [ "prompt_version" ] != "1.0.0" {
t . Fatalf ( "unexpected metadata.prompt_version: %#v" , metadata [ "prompt_version" ] )
2026-05-05 08:41:07 -05:00
}
2026-05-05 10:54:35 -05:00
if metadata [ "selected_profile_id" ] != "exec-default" {
t . Fatalf ( "unexpected metadata.selected_profile_id: %#v" , metadata [ "selected_profile_id" ] )
2026-05-05 08:25:32 -05:00
}
2026-05-05 10:54:35 -05:00
if metadata [ "model_name" ] != "m1" || metadata [ "endpoint" ] != "http://llm/v1" {
t . Fatalf ( "unexpected model metadata: name=%#v endpoint=%#v" , metadata [ "model_name" ] , metadata [ "endpoint" ] )
2026-05-05 08:41:07 -05:00
}
modelParams := metadata [ "model_params" ] . ( map [ string ] any )
2026-05-05 10:54:35 -05:00
if modelParams [ "api_key_env" ] != envName {
t . Fatalf ( "expected model_params.api_key_env=%q, got %#v" , envName , modelParams [ "api_key_env" ] )
2026-05-05 08:41:07 -05:00
}
2026-05-05 10:54:35 -05:00
if strings . Contains ( w . Body . String ( ) , secret ) {
t . Fatalf ( "response leaked raw API key value: %s" , w . Body . String ( ) )
2026-05-04 21:19:29 -05:00
}
2026-05-05 20:01:45 -05:00
if _ , ok := resp [ "raw_model_output" ] ; ok {
t . Fatalf ( "expected raw_model_output to be omitted by default, got %#v" , resp [ "raw_model_output" ] )
}
2026-05-04 21:19:29 -05:00
2026-05-05 10:09:31 -05:00
if r . last . PromptID != "prompt-1" {
t . Fatalf ( "expected request prompt_id prompt-1, got %q" , r . last . PromptID )
2026-05-04 21:19:29 -05:00
}
2026-05-05 10:09:31 -05:00
if r . last . ProfileID != "exec-default" {
t . Fatalf ( "expected request profile_id exec-default, got %q" , r . last . ProfileID )
2026-05-04 21:19:29 -05:00
}
2026-05-05 10:09:31 -05:00
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 )
2026-05-04 22:38:20 -05:00
}
2026-05-04 21:19:29 -05:00
}
2026-05-05 10:54:35 -05:00
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" ] )
}
}
2026-05-04 21:19:29 -05:00
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 )
}
}
2026-05-05 10:09:31 -05:00
func TestHandlerMissingPromptID ( t * testing . T ) {
2026-05-04 21:19:29 -05:00
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 )
}
2026-05-05 10:54:35 -05:00
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" ] )
}
2026-05-04 21:19:29 -05:00
}
func TestHandlerUsecaseErrorMapping ( t * testing . T ) {
tests := [ ] struct {
2026-05-05 10:54:35 -05:00
name string
err error
status int
code string
message string
avoidCause string
2026-05-04 21:19:29 -05:00
} {
2026-05-05 10:54:35 -05:00
{ 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" } ,
2026-05-04 21:19:29 -05:00
}
for _ , tc := range tests {
t . Run ( tc . name , func ( t * testing . T ) {
h := NewHandler ( & fakeRunner { err : tc . err } )
2026-05-05 10:09:31 -05:00
req := httptest . NewRequest ( http . MethodPost , "/v1/runs" , bytes . NewBufferString ( ` { "prompt_id":"p","inputs": { "x": { "type":"file","uri":"a"}}} ` ) )
2026-05-04 21:19:29 -05:00
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 ( ) )
}
2026-05-05 08:41:07 -05:00
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 )
2026-05-05 10:54:35 -05:00
if errBody [ "code" ] != tc . code {
t . Fatalf ( "expected code %q, got %#v" , tc . code , errBody [ "code" ] )
2026-05-05 08:41:07 -05:00
}
2026-05-05 10:54:35 -05:00
if errBody [ "message" ] != tc . message {
t . Fatalf ( "expected message %q, got %#v" , tc . message , errBody [ "message" ] )
2026-05-05 08:41:07 -05:00
}
2026-05-05 10:54:35 -05:00
if tc . avoidCause != "" && strings . Contains ( w . Body . String ( ) , tc . avoidCause ) {
t . Fatalf ( "expected response to avoid leaking cause details, got %s" , w . Body . String ( ) )
2026-05-05 08:41:07 -05:00
}
2026-05-04 21:19:29 -05:00
} )
}
}
2026-05-05 10:54:35 -05:00
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" ] )
}
}
2026-05-05 20:01:45 -05:00
func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn ( t * testing . T ) {
2026-05-04 21:19:29 -05:00
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" } ,
} ,
} } )
2026-05-05 10:09:31 -05:00
req := httptest . NewRequest ( http . MethodPost , "/v1/runs" , bytes . NewBufferString ( ` { "prompt_id":"p","inputs": { "x": { "type":"file","uri":"a"}}} ` ) )
2026-05-04 21:19:29 -05:00
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 )
2026-05-05 08:25:32 -05:00
if status , ok := validation [ "status" ] . ( string ) ; ! ok || status != "failed" {
t . Fatalf ( "expected validation status=failed, got %#v" , validation [ "status" ] )
2026-05-04 21:19:29 -05:00
}
2026-05-05 20:01:45 -05:00
if _ , ok := resp [ "raw_model_output" ] ; ok {
t . Fatalf ( "expected raw_model_output omitted by default, got %#v" , resp [ "raw_model_output" ] )
}
reqWithRaw := httptest . NewRequest ( http . MethodPost , "/v1/runs" , bytes . NewBufferString ( ` { "prompt_id":"p","inputs": { "x": { "type":"file","uri":"a"}},"include_raw_output":true} ` ) )
wWithRaw := httptest . NewRecorder ( )
h . ServeHTTP ( wWithRaw , reqWithRaw )
if wWithRaw . Code != http . StatusOK {
t . Fatalf ( "expected 200, got %d: %s" , wWithRaw . Code , wWithRaw . Body . String ( ) )
}
var respWithRaw map [ string ] any
if err := json . Unmarshal ( wWithRaw . Body . Bytes ( ) , & respWithRaw ) ; err != nil {
t . Fatalf ( "invalid JSON response: %v" , err )
}
if got , ok := respWithRaw [ "raw_model_output" ] . ( string ) ; ! ok || got != "bad json" {
t . Fatalf ( "expected raw_model_output to be included when requested, got %#v" , respWithRaw [ "raw_model_output" ] )
}
2026-05-04 21:19:29 -05:00
}
func wrap ( stage error , cause error ) error {
return fmt . Errorf ( "%w: %w" , stage , cause )
}