Files
weatherreporter/internal/promptexec/promptexec_test.go

266 lines
11 KiB
Go
Raw Permalink Blame History

package promptexec
import (
"context"
"errors"
"strings"
"testing"
"unicode/utf8"
)
type fakeExecutor struct{}
func (fakeExecutor) InspectPrompt(context.Context, string, string) (PromptInspection, error) {
return PromptInspection{}, nil
}
func (fakeExecutor) InspectProfile(context.Context, string) (ProfileInspection, error) {
return ProfileInspection{}, nil
}
func (fakeExecutor) Execute(context.Context, ExecuteRequest, PreparationCallback) (*Execution, error) {
return nil, nil
}
var _ Executor = fakeExecutor{}
type lifecycleExecutor struct {
providerCalled bool
operationalFailure error
validationRejected bool
}
func (executor *lifecycleExecutor) InspectPrompt(context.Context, string, string) (PromptInspection, error) {
return PromptInspection{}, nil
}
func (executor *lifecycleExecutor) InspectProfile(context.Context, string) (ProfileInspection, error) {
return ProfileInspection{}, nil
}
func (executor *lifecycleExecutor) Execute(_ context.Context, request ExecuteRequest, callback PreparationCallback) (*Execution, error) {
if executor.operationalFailure != nil {
return nil, executor.operationalFailure
}
preparation := Preparation{PromptID: request.PromptID, PromptVersion: request.PromptVersion}
var debug *PreparationDebug
if request.CaptureDebug {
debug = &PreparationDebug{RenderedMessages: []RenderedMessage{{Role: "user", Content: "sensitive rendered message"}}}
}
if callback != nil {
if err := callback(copyPreparation(preparation), copyPreparationDebug(debug)); err != nil {
return nil, err
}
}
executor.providerCalled = true
status := ValidationPassed
if executor.validationRejected {
status = ValidationFailed
}
result := Execution{PromptID: request.PromptID, PromptVersion: request.PromptVersion, Validation: Validation{Status: status}, RawOutput: []byte("generated content")}
if request.CaptureDebug {
result.Debug = &ExecutionDebug{RawOutput: []byte("generated content")}
}
return &result, nil
}
func TestExecutorLifecycleFixtures(t *testing.T) {
request := ExecuteRequest{PromptID: "weather.daily_generated_text", PromptVersion: "1.0.0"}
t.Run("callback failure prevents provider execution", func(t *testing.T) {
executor := &lifecycleExecutor{}
callbackError := errors.New("persistence failed")
result, err := executor.Execute(context.Background(), request, func(Preparation, *PreparationDebug) error { return callbackError })
if result != nil || !errors.Is(err, callbackError) || executor.providerCalled {
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
}
})
t.Run("validation rejection is completed result", func(t *testing.T) {
executor := &lifecycleExecutor{validationRejected: true}
result, err := executor.Execute(context.Background(), request, nil)
if err != nil || result == nil || result.Validation.Status != ValidationFailed || !executor.providerCalled {
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
}
})
t.Run("operational failure has no completed result", func(t *testing.T) {
failure := NewError(Generation, "generation failed", nil)
executor := &lifecycleExecutor{operationalFailure: failure}
result, err := executor.Execute(context.Background(), request, nil)
if result != nil || !errors.Is(err, failure) || executor.providerCalled {
t.Fatalf("result/error/provider = %#v/%v/%t", result, err, executor.providerCalled)
}
})
t.Run("debug requires explicit request", func(t *testing.T) {
executor := &lifecycleExecutor{}
var callbackDebug *PreparationDebug
result, err := executor.Execute(context.Background(), request, func(_ Preparation, debug *PreparationDebug) error {
callbackDebug = debug
return nil
})
if err != nil || callbackDebug != nil || result.Debug != nil {
t.Fatalf("debug = %#v/%#v, error = %v", callbackDebug, result.Debug, err)
}
request.CaptureDebug = true
result, err = executor.Execute(context.Background(), request, func(_ Preparation, debug *PreparationDebug) error {
callbackDebug = debug
return nil
})
if err != nil || callbackDebug == nil || result.Debug == nil {
t.Fatalf("debug = %#v/%#v, error = %v", callbackDebug, result.Debug, err)
}
})
}
func TestErrorCategoriesAndCapacityError(t *testing.T) {
categories := []ErrorCategory{
InvalidConfiguration,
InvalidRequest,
PromptNotFound,
PromptLoad,
ProfileNotFound,
ProfileLoad,
MissingCredential,
ArtifactLoad,
PromptRender,
Capacity,
Generation,
OperationalValidation,
ValidationRejected,
Canceled,
DeadlineExceeded,
}
cause := errors.New("dependency details must not become safe error text")
for _, category := range categories {
t.Run(string(category), func(t *testing.T) {
err := NewError(category, "safe workflow failure", cause)
if err.Category() != category || CategoryOf(err) != category {
t.Fatalf("category = %q/%q, want %q", err.Category(), CategoryOf(err), category)
}
if !errors.Is(err, cause) {
t.Fatal("errors.Is() = false, want preserved cause")
}
if strings.Contains(err.Error(), cause.Error()) {
t.Fatalf("error leaks cause: %q", err)
}
})
}
capacity := NewCapacityError("local", "safe capacity failure", cause)
if capacity.Category() != Capacity || CategoryOf(capacity) != Capacity || capacity.BackendID != "local" {
t.Fatalf("capacity error = %#v", capacity)
}
if !errors.Is(capacity, cause) {
t.Fatal("capacity error does not preserve cause")
}
}
func TestBoundDiagnosticAndErrorText(t *testing.T) {
longUTF8 := strings.Repeat("é", maxDiagnosticBytes)
values := make([]string, maxValidationDiagnostics+2)
for index := range values {
values[index] = longUTF8
}
values[0] = string([]byte{'a', 0xff, 'b'})
bounded := boundDiagnostics(values)
if len(bounded) != maxValidationDiagnostics {
t.Fatalf("diagnostics length = %d, want %d", len(bounded), maxValidationDiagnostics)
}
for index, value := range bounded {
if len(value) > maxDiagnosticBytes || !utf8.ValidString(value) {
t.Fatalf("diagnostic %d = %q, want valid UTF-8 within %d bytes", index, value, maxDiagnosticBytes)
}
}
if bounded[0] != "a<>b" {
t.Fatalf("invalid UTF-8 diagnostic = %q, want replacement", bounded[0])
}
validation := NewValidation(ValidationFailed, "json_schema", "daily.schema.json", values)
if len(validation.Diagnostics) != maxValidationDiagnostics || validation.Diagnostics[0] != "a<>b" {
t.Fatalf("validation = %#v, want bounded diagnostics", validation)
}
err := NewError(Generation, strings.Repeat("é", maxErrorMessageBytes), nil)
if len(err.Error()) > maxErrorMessageBytes || !utf8.ValidString(err.Error()) {
t.Fatalf("error = %q, want valid UTF-8 within %d bytes", err, maxErrorMessageBytes)
}
capacity := NewCapacityError(strings.Repeat("x", 300), strings.Repeat("é", maxErrorMessageBytes), nil)
if len(capacity.Error()) > maxErrorMessageBytes || !utf8.ValidString(capacity.Error()) {
t.Fatalf("capacity error = %q, want valid UTF-8 within %d bytes", capacity, maxErrorMessageBytes)
}
}
func TestContractCopiesMutableValues(t *testing.T) {
preparation := Preparation{InputHashes: map[string]string{"data_package": "input-hash"}}
preparationDebug := &PreparationDebug{
RenderedMessages: []RenderedMessage{{Role: "user", Content: "rendered input"}},
StructuredSchema: []byte("schema body"),
ParametersJSON: []byte(`{"temperature":0.2}`),
}
execution := Execution{
InputHashes: map[string]string{"data_package": "input-hash"},
Validation: Validation{Diagnostics: []string{"validation detail"}},
RawOutput: []byte("generated output"),
Debug: &ExecutionDebug{
RawOutput: []byte("provider output"),
ValidationDiagnostics: []string{"detailed validation"},
},
}
preparationCopy := copyPreparation(preparation)
debugCopy := copyPreparationDebug(preparationDebug)
executionCopy := copyExecution(execution)
preparation.InputHashes["data_package"] = "changed"
preparationDebug.RenderedMessages[0].Content = "changed"
preparationDebug.StructuredSchema[0] = 'x'
preparationDebug.ParametersJSON[0] = 'x'
execution.InputHashes["data_package"] = "changed"
execution.Validation.Diagnostics[0] = "changed"
execution.RawOutput[0] = 'x'
execution.Debug.RawOutput[0] = 'x'
execution.Debug.ValidationDiagnostics[0] = "changed"
if preparationCopy.InputHashes["data_package"] != "input-hash" {
t.Fatalf("preparation copy = %#v", preparationCopy)
}
if debugCopy.RenderedMessages[0].Content != "rendered input" || string(debugCopy.StructuredSchema) != "schema body" || string(debugCopy.ParametersJSON) != `{"temperature":0.2}` {
t.Fatalf("preparation debug copy = %#v", debugCopy)
}
if executionCopy.InputHashes["data_package"] != "input-hash" || executionCopy.Validation.Diagnostics[0] != "validation detail" || string(executionCopy.RawOutput) != "generated output" || string(executionCopy.Debug.RawOutput) != "provider output" || executionCopy.Debug.ValidationDiagnostics[0] != "detailed validation" {
t.Fatalf("execution copy = %#v", executionCopy)
}
}
func TestSafeContractValuesExcludeSensitiveFields(t *testing.T) {
preparation := Preparation{
PromptID: "weather.daily_generated_text",
PromptVersion: "1.0.0",
PromptHash: "prompt-hash",
RenderedPromptHash: "rendered-hash",
InputHashes: map[string]string{"data_package": "input-hash"},
ProfileID: "configured-profile",
BackendID: "local",
ModelName: "model-name",
Output: OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: "daily.generated_text.schema.json"},
}
execution := Execution{
RunID: "run-id",
PromptID: preparation.PromptID,
PromptVersion: preparation.PromptVersion,
PromptHash: preparation.PromptHash,
RenderedPromptHash: preparation.RenderedPromptHash,
InputHashes: copyStringMap(preparation.InputHashes),
ProfileID: preparation.ProfileID,
BackendID: preparation.BackendID,
ModelName: preparation.ModelName,
GeneratedHash: "generated-hash",
RawOutput: []byte("generated content"),
}
text := preparation.PromptID + preparation.PromptVersion + preparation.PromptHash + preparation.RenderedPromptHash + preparation.ProfileID + preparation.BackendID + preparation.ModelName + preparation.Output.SchemaPath + execution.RunID + execution.GeneratedHash
for _, unwanted := range []string{"https://provider.example", "API_KEY_ENV", "rendered message", "schema body", "input body", "provider response body", "full parameters"} {
if strings.Contains(text, unwanted) {
t.Fatalf("safe values contain %q: %s", unwanted, text)
}
}
if string(execution.RawOutput) != "generated content" {
t.Fatalf("raw output = %q, want generated content", execution.RawOutput)
}
}