package pipeline import ( "context" "fmt" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) { tests := []struct { name string retries int operation func(int) erasedTypedResult validator *preparedValidator wantCalls int wantItem string wantWarnings []string wantRejected int wantDebug []string wantCheckpoint int }{ { name: "accepts zero-retry fallback", retries: 0, operation: func(int) erasedTypedResult { return retryableNormalizeResult("fallback", "ordinary", "fallback-warning") }, wantCalls: 1, wantItem: "fallback", wantWarnings: []string{"ordinary", "fallback-warning"}, wantDebug: []string{`"another_attempt":false`, `"fallback_accepted":true`}, wantCheckpoint: 1, }, { name: "retries before accepting ordinary result", retries: 1, operation: func(attempt int) erasedTypedResult { if attempt == 1 { return retryableNormalizeResult("discarded", "discarded-ordinary", "discarded-fallback") } return erasedTypedResult{Value: codecNotes{Items: []string{"accepted"}}, Warnings: []contracts.Warning{{Scope: "accepted", ReasonCode: "ordinary", Message: "accepted-warning"}}} }, wantCalls: 2, wantItem: "accepted", wantWarnings: []string{"accepted-warning"}, wantDebug: []string{`"another_attempt":true`, `"fallback_accepted":false`}, wantCheckpoint: 1, }, { name: "accepts final fallback after exhaustion", retries: 1, operation: func(attempt int) erasedTypedResult { return retryableNormalizeResult(fmt.Sprintf("fallback-%d", attempt), fmt.Sprintf("ordinary-%d", attempt), fmt.Sprintf("fallback-warning-%d", attempt)) }, wantCalls: 2, wantItem: "fallback-2", wantWarnings: []string{"ordinary-2", "fallback-warning-2"}, wantDebug: []string{`"another_attempt":false`, `"fallback_accepted":true`}, wantCheckpoint: 1, }, { name: "keeps final fallback rejection terminal", retries: 1, operation: func(int) erasedTypedResult { return retryableNormalizeResult("rejected", "ordinary", "fallback-warning") }, validator: &preparedValidator{ resolved: ResolvedValidator{Binding: Binding("reject-final-fallback"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) { return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "reject fallback"}, nil }, }, wantCalls: 2, wantRejected: 1, wantDebug: []string{`"fallback_accepted":true`, `"rejection"`}, wantCheckpoint: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { prepared := preparedAttemptDebugPipeline(t) lane := &prepared.Steps[0].lanes[0] lane.resolved.Normalize.Retries = tc.retries if tc.validator != nil { lane.normalizeValidators.validators = []preparedValidator{*tc.validator} } calls := 0 lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) { calls++ return tc.operation(calls), nil } debug := newCapturedDebugRecorder() checkpoints := &candidateCheckpointRecorder{CheckpointRecorder: NoopCheckpointRecorder()} output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug, Checkpoints: checkpoints}) if err != nil { t.Fatalf("Run() error = %v", err) } if calls != tc.wantCalls { t.Fatalf("normalize calls = %d, want %d", calls, tc.wantCalls) } if checkpoints.normalizeSucceeded != tc.wantCheckpoint { t.Fatalf("normalize checkpoints = %d, want %d", checkpoints.normalizeSucceeded, tc.wantCheckpoint) } if len(output.Rejected) != tc.wantRejected { t.Fatalf("rejected outputs = %#v, want %d", output.Rejected, tc.wantRejected) } var retryDebug strings.Builder for _, name := range debug.names() { if strings.HasPrefix(name, "normalize/notes/attempt-") && strings.HasSuffix(name, ".json") { retryDebug.Write(debug.json[name]) } } for _, fragment := range tc.wantDebug { if !strings.Contains(retryDebug.String(), fragment) { t.Fatalf("retry debug = %s, want %q", retryDebug.String(), fragment) } } if tc.wantRejected != 0 { if output.Rejected[0].AttemptCount != tc.wantCalls { t.Fatalf("rejection attempt count = %d, want %d", output.Rejected[0].AttemptCount, tc.wantCalls) } return } if len(output.NormalizeOutputs) != 1 { t.Fatalf("normalize outputs = %#v, want one", output.NormalizeOutputs) } decoded, err := lane.typed.codec.decode(output.NormalizeOutputs[0].Artifact.Content) if err != nil { t.Fatalf("decode normalized output: %v", err) } normalized, ok := decoded.(codecNotes) if !ok { t.Fatalf("decoded normalized output = %T, want codecNotes", decoded) } if got := firstNote(normalized); got != tc.wantItem { t.Fatalf("normalized item = %q, want %q", got, tc.wantItem) } gotWarnings := make([]string, len(output.Warnings)) for index, warning := range output.Warnings { gotWarnings[index] = warning.Message } if strings.Join(gotWarnings, "|") != strings.Join(tc.wantWarnings, "|") { t.Fatalf("durable warnings = %#v, want %#v", gotWarnings, tc.wantWarnings) } }) } } func TestRunnerRejectsBlankNormalizeRetryDiagnostic(t *testing.T) { prepared := preparedAttemptDebugPipeline(t) prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) { return erasedTypedResult{Value: codecNotes{Items: []string{"safe"}}, Retry: &contracts.NormalizeRetry{ReasonCode: " ", Message: "missing reason"}}, nil } _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: newCapturedDebugRecorder()}) if err == nil || !strings.Contains(err.Error(), "blank reason code or message") { t.Fatalf("Run() error = %v, want retry diagnostic contract error", err) } } func retryableNormalizeResult(item, ordinary, fallback string) erasedTypedResult { return erasedTypedResult{ Value: codecNotes{Items: []string{item}}, Warnings: []contracts.Warning{{Scope: "attempt", ReasonCode: "ordinary", Message: ordinary}}, Retry: &contracts.NormalizeRetry{ ReasonCode: "retryable_normalization", Message: "safe fallback is available", FallbackWarnings: []contracts.Warning{{Scope: "fallback", ReasonCode: "fallback", Message: fallback}}, }, } }