426 lines
13 KiB
Go
426 lines
13 KiB
Go
package json
|
|
|
|
import (
|
|
"context"
|
|
stdjson "encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
|
)
|
|
|
|
func TestModuleSpecAndRegister(t *testing.T) {
|
|
want := pipeline.ModuleSpec{
|
|
Key: Key,
|
|
Stage: pipeline.StageOutput,
|
|
Requires: []string{"normalized"},
|
|
Provides: []string{"encoded"},
|
|
}
|
|
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
|
}
|
|
|
|
registry := pipeline.NewOutputEncoderRegistry()
|
|
if err := Register(registry); err != nil {
|
|
t.Fatalf("Register() error = %v, want nil", err)
|
|
}
|
|
spec, ok := registry.Spec(Key)
|
|
if !ok {
|
|
t.Fatalf("Spec(%q) ok = false, want true", Key)
|
|
}
|
|
if !reflect.DeepEqual(spec, want) {
|
|
t.Fatalf("registered spec = %#v, want %#v", spec, want)
|
|
}
|
|
}
|
|
|
|
func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
|
|
req := contracts.OutputRequest{
|
|
Manifest: artifacts.RunManifest{RunID: "run-1", PipelineID: "pipeline-1"},
|
|
NormalizeOutputs: []contracts.NormalizeOutput{
|
|
normalizeOutput("spells", `{"spell_casts":[{"spell":"Cure Wounds"}]}`),
|
|
normalizeOutput("notes/items", `{"items":[{"name":"Torch"}]}`),
|
|
},
|
|
Rejected: []contracts.RejectedOutput{
|
|
{
|
|
Stage: "extract",
|
|
LaneID: "spells",
|
|
ModuleKey: "dnd/spells",
|
|
ChunkID: "chunk-1",
|
|
ChunkIndex: 1,
|
|
ReasonCode: "invalid",
|
|
Message: "not accepted",
|
|
AttemptCount: 1,
|
|
ValidatorName: "validator",
|
|
},
|
|
},
|
|
Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "check source"}},
|
|
}
|
|
|
|
result, err := New().Encode(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
|
|
wantNames := []string{
|
|
"index.json",
|
|
"lanes/notes_items.json",
|
|
"lanes/spells.json",
|
|
"manifest.json",
|
|
"rejected.json",
|
|
"warnings.json",
|
|
}
|
|
if got := outputFileNames(result.Files); !reflect.DeepEqual(got, wantNames) {
|
|
t.Fatalf("file names = %#v, want %#v", got, wantNames)
|
|
}
|
|
|
|
for _, file := range result.Files {
|
|
if !strings.HasSuffix(string(file.Bytes), "\n") {
|
|
t.Fatalf("%s does not end with newline: %q", file.Name, string(file.Bytes))
|
|
}
|
|
if file.ContentType == contentTypeJSON && !stdjson.Valid(file.Bytes) {
|
|
t.Fatalf("%s has invalid JSON: %s", file.Name, file.Bytes)
|
|
}
|
|
}
|
|
|
|
spells := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json"))
|
|
spellCasts := spells["spell_casts"].([]any)
|
|
if spellCasts[0].(map[string]any)["spell"] != "Cure Wounds" {
|
|
t.Fatalf("spells output = %#v, want raw normalized content", spells)
|
|
}
|
|
|
|
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
|
|
outputFiles := index["output_files"].([]any)
|
|
if len(outputFiles) != 2 {
|
|
t.Fatalf("len(index output_files) = %d, want 2", len(outputFiles))
|
|
}
|
|
firstIndex := outputFiles[0].(map[string]any)
|
|
secondIndex := outputFiles[1].(map[string]any)
|
|
if firstIndex["lane_id"] != "notes/items" || secondIndex["lane_id"] != "spells" {
|
|
t.Fatalf("output_files = %#v, want sorted by lane id", outputFiles)
|
|
}
|
|
|
|
rejected := decodeObject(t, fileBytes(t, result.Files, "rejected.json"))
|
|
if got := rejected["rejected"].([]any); len(got) != 1 {
|
|
t.Fatalf("rejected = %#v, want one rejected output", got)
|
|
}
|
|
}
|
|
|
|
func TestEncodeIncludesRejectedAndWarningsWhenEmpty(t *testing.T) {
|
|
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
|
|
rejected := decodeObject(t, fileBytes(t, result.Files, "rejected.json"))
|
|
if got := rejected["rejected"].([]any); len(got) != 0 {
|
|
t.Fatalf("rejected = %#v, want empty array", got)
|
|
}
|
|
warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json"))
|
|
if got := warnings["warnings"].([]any); len(got) != 0 {
|
|
t.Fatalf("warnings = %#v, want empty array", got)
|
|
}
|
|
}
|
|
|
|
func TestEncodePrettyPrintsJSON(t *testing.T) {
|
|
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
|
|
manifest := string(fileBytes(t, result.Files, "manifest.json"))
|
|
if !strings.Contains(manifest, "\n \"run_id\": \"run-1\"\n") {
|
|
t.Fatalf("manifest JSON = %q, want two-space indentation", manifest)
|
|
}
|
|
}
|
|
|
|
func TestEncodeIncludesManifestReferences(t *testing.T) {
|
|
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
Manifest: artifacts.RunManifest{
|
|
RunID: "run-1",
|
|
References: []artifacts.ReferenceProvenance{
|
|
{
|
|
Stage: "extract",
|
|
LaneID: "events",
|
|
SlotName: "roster",
|
|
OriginType: "file",
|
|
OriginURI: "file:///tmp/roster.txt",
|
|
Digest: "sha256:reference",
|
|
MediaType: "text/plain; charset=utf-8",
|
|
SizeBytes: 12,
|
|
BindingSource: "config",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
|
|
manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json"))
|
|
references := manifest["references"].([]any)
|
|
if len(references) != 1 {
|
|
t.Fatalf("references = %#v, want one entry", references)
|
|
}
|
|
reference := references[0].(map[string]any)
|
|
if reference["lane_id"] != "events" || reference["slot_name"] != "roster" || reference["digest"] != "sha256:reference" {
|
|
t.Fatalf("reference manifest = %#v, want lane slot digest", reference)
|
|
}
|
|
if _, ok := reference["content"]; ok {
|
|
t.Fatalf("reference manifest = %#v, want no content field", reference)
|
|
}
|
|
}
|
|
|
|
func TestEncodeIncludesManifestRawOutputProvenance(t *testing.T) {
|
|
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
Manifest: artifacts.RunManifest{
|
|
RunID: "run-1",
|
|
NormalizedOutputs: []artifacts.NormalizedOutputManifest{
|
|
{
|
|
LaneID: "spells",
|
|
ModuleKey: "noop",
|
|
SourceID: "source-1",
|
|
MediaType: contentTypeJSON,
|
|
Schema: artifacts.OutputSchemaProvenance{
|
|
ID: "schema-id",
|
|
Name: "schema-name",
|
|
Version: "v1",
|
|
},
|
|
},
|
|
},
|
|
RejectedOutputs: []artifacts.RejectedOutputManifest{
|
|
{
|
|
Stage: "extract",
|
|
LaneID: "spells",
|
|
ModuleKey: "dnd/spells",
|
|
ChunkID: "chunk-0",
|
|
ReasonCode: "raw_output_rejected",
|
|
AttemptCount: 2,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
|
|
manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json"))
|
|
normalized := manifest["normalized_outputs"].([]any)
|
|
if len(normalized) != 1 {
|
|
t.Fatalf("normalized_outputs = %#v, want one entry", normalized)
|
|
}
|
|
normalizedEntry := normalized[0].(map[string]any)
|
|
if normalizedEntry["lane_id"] != "spells" || normalizedEntry["media_type"] != contentTypeJSON {
|
|
t.Fatalf("normalized output manifest = %#v, want lane and media type", normalizedEntry)
|
|
}
|
|
rejected := manifest["rejected_outputs"].([]any)
|
|
if len(rejected) != 1 {
|
|
t.Fatalf("rejected_outputs = %#v, want one entry", rejected)
|
|
}
|
|
rejectedEntry := rejected[0].(map[string]any)
|
|
if rejectedEntry["attempt_count"] != float64(2) || rejectedEntry["chunk_id"] != "chunk-0" {
|
|
t.Fatalf("rejected output manifest = %#v, want attempt count and chunk", rejectedEntry)
|
|
}
|
|
}
|
|
|
|
func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) {
|
|
_, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("///", `{"value":true}`)},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Encode() error = nil, want unsafe lane id error")
|
|
}
|
|
if !strings.Contains(err.Error(), "json output encoder") || !strings.Contains(err.Error(), "safe file name") {
|
|
t.Fatalf("Encode() error = %q, want safe file name context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestEncodeSanitizesParentPathSequences(t *testing.T) {
|
|
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("dnd..spell.", `{"value":true}`)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
|
|
if got := outputFileNames(result.Files); !containsString(got, "lanes/dnd__spell.json") {
|
|
t.Fatalf("file names = %#v, want sanitized output filename", got)
|
|
}
|
|
}
|
|
|
|
func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
output contracts.NormalizeOutput
|
|
want string
|
|
}{
|
|
{
|
|
name: "invalid JSON",
|
|
output: normalizeOutput("spells", `{"spell_casts":[`),
|
|
want: "invalid JSON",
|
|
},
|
|
{
|
|
name: "unsupported media type",
|
|
output: func() contracts.NormalizeOutput {
|
|
output := normalizeOutput("spells", `{"spell_casts":[]}`)
|
|
output.Payload.MediaType = "text/plain"
|
|
return output
|
|
}(),
|
|
want: "unsupported media type",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
NormalizeOutputs: []contracts.NormalizeOutput{test.output},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Encode() error = nil, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("Encode() error = %q, want %q", err.Error(), test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) {
|
|
_, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
NormalizeOutputs: []contracts.NormalizeOutput{
|
|
normalizeOutput("a/b", `{"value":"slash"}`),
|
|
normalizeOutput("a?b", `{"value":"question"}`),
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Encode() error = nil, want duplicate file error")
|
|
}
|
|
if !strings.Contains(err.Error(), "duplicate output file") {
|
|
t.Fatalf("Encode() error = %q, want duplicate file context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestEncodeDoesNotMutateInputs(t *testing.T) {
|
|
req := contracts.OutputRequest{
|
|
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
|
NormalizeOutputs: []contracts.NormalizeOutput{
|
|
normalizeOutput("spells", `{"name":"original"}`),
|
|
},
|
|
Rejected: []contracts.RejectedOutput{
|
|
{Stage: "extract", LaneID: "spells", Message: "not accepted"},
|
|
},
|
|
Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "message"}},
|
|
}
|
|
before := mustMarshal(t, req)
|
|
|
|
result, err := New().Encode(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
after := mustMarshal(t, req)
|
|
if before != after {
|
|
t.Fatalf("request mutated:\nbefore: %s\nafter: %s", before, after)
|
|
}
|
|
|
|
req.NormalizeOutputs[0].Payload.Content[0] = '['
|
|
req.NormalizeOutputs[0].Payload.Metadata["name"] = "changed"
|
|
req.Rejected[0].Message = "changed"
|
|
req.Warnings[0].Message = "changed"
|
|
|
|
if !stdjson.Valid(fileBytes(t, result.Files, "lanes/spells.json")) {
|
|
t.Fatal("output changed after request mutation")
|
|
}
|
|
warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json"))
|
|
gotWarnings := warnings["warnings"].([]any)
|
|
if gotWarnings[0].(map[string]any)["message"] != "message" {
|
|
t.Fatalf("warnings output changed after request mutation: %#v", gotWarnings)
|
|
}
|
|
}
|
|
|
|
func TestOutputFilesDoNotContainWarnings(t *testing.T) {
|
|
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
|
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("spells", `{"spell":"Shield"}`)},
|
|
Warnings: []contracts.Warning{
|
|
{ReasonCode: "pipeline-warning", Message: "warning"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Encode() error = %v, want nil", err)
|
|
}
|
|
|
|
outputFile := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json"))
|
|
if _, ok := outputFile["warnings"]; ok {
|
|
t.Fatalf("output file contains warnings: %#v", outputFile)
|
|
}
|
|
}
|
|
|
|
func normalizeOutput(laneID string, content string) contracts.NormalizeOutput {
|
|
return contracts.NormalizeOutput{
|
|
LaneID: laneID,
|
|
NormalizerKey: "noop",
|
|
SourceID: "source-1",
|
|
Schema: contracts.ResponseSchema{
|
|
ID: "schema-id",
|
|
Name: "schema-name",
|
|
Version: "v1",
|
|
},
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(content),
|
|
MediaType: contentTypeJSON,
|
|
Metadata: map[string]any{"name": laneID},
|
|
},
|
|
}
|
|
}
|
|
|
|
func outputFileNames(files []contracts.OutputFile) []string {
|
|
names := make([]string, 0, len(files))
|
|
for _, file := range files {
|
|
names = append(names, file.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func containsString(values []string, want string) bool {
|
|
for _, value := range values {
|
|
if value == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func fileBytes(t *testing.T, files []contracts.OutputFile, name string) []byte {
|
|
t.Helper()
|
|
for _, file := range files {
|
|
if file.Name == name {
|
|
return file.Bytes
|
|
}
|
|
}
|
|
t.Fatalf("file %q not found in %#v", name, outputFileNames(files))
|
|
return nil
|
|
}
|
|
|
|
func decodeObject(t *testing.T, data []byte) map[string]any {
|
|
t.Helper()
|
|
var got map[string]any
|
|
if err := stdjson.Unmarshal(data, &got); err != nil {
|
|
t.Fatalf("Unmarshal() error = %v, want nil\n%s", err, data)
|
|
}
|
|
return got
|
|
}
|
|
|
|
func mustMarshal(t *testing.T, value any) string {
|
|
t.Helper()
|
|
data, err := stdjson.Marshal(value)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v, want nil", err)
|
|
}
|
|
return string(data)
|
|
}
|