Implement raw module output contracts
This commit is contained in:
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
@@ -37,20 +36,24 @@ func TestModuleSpecAndRegister(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeReturnsLogicalFilesGroupedByArtifactType(t *testing.T) {
|
||||
func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
|
||||
req := contracts.OutputRequest{
|
||||
Manifest: artifacts.RunManifest{RunID: "run-1", PipelineID: "pipeline-1"},
|
||||
Approved: []artifacts.Artifact{
|
||||
artifact("dnd.spell-cast", "first"),
|
||||
artifact("notes/item", "item"),
|
||||
artifact("dnd.spell-cast", "second"),
|
||||
NormalizeOutputs: []contracts.NormalizeOutput{
|
||||
normalizeOutput("spells", `{"spell_casts":[{"spell":"Cure Wounds"}]}`),
|
||||
normalizeOutput("notes/items", `{"items":[{"name":"Torch"}]}`),
|
||||
},
|
||||
Rejected: []artifacts.RejectedArtifact{
|
||||
Rejected: []contracts.RejectedOutput{
|
||||
{
|
||||
Candidate: candidate("bad type", "bad"),
|
||||
ValidatorName: "validator",
|
||||
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"}},
|
||||
@@ -62,51 +65,46 @@ func TestEncodeReturnsLogicalFilesGroupedByArtifactType(t *testing.T) {
|
||||
}
|
||||
|
||||
wantNames := []string{
|
||||
"artifacts/dnd.spell-cast.json",
|
||||
"artifacts/notes_item.json",
|
||||
"index.json",
|
||||
"manifest.json",
|
||||
"outputs/notes_items.json",
|
||||
"outputs/spells.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 file.ContentType != contentTypeJSON {
|
||||
t.Fatalf("%s ContentType = %q, want %q", file.Name, file.ContentType, contentTypeJSON)
|
||||
}
|
||||
if !strings.HasSuffix(string(file.Bytes), "\n") {
|
||||
t.Fatalf("%s does not end with newline: %q", file.Name, string(file.Bytes))
|
||||
}
|
||||
if !stdjson.Valid(file.Bytes) {
|
||||
if file.ContentType == contentTypeJSON && !stdjson.Valid(file.Bytes) {
|
||||
t.Fatalf("%s has invalid JSON: %s", file.Name, file.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
spellFile := decodeObject(t, fileBytes(t, result.Files, "artifacts/dnd.spell-cast.json"))
|
||||
if spellFile["artifact_type"] != "dnd.spell-cast" {
|
||||
t.Fatalf("artifact_type = %#v, want dnd.spell-cast", spellFile["artifact_type"])
|
||||
}
|
||||
spells := spellFile["artifacts"].([]any)
|
||||
if len(spells) != 2 {
|
||||
t.Fatalf("len(spells) = %d, want 2", len(spells))
|
||||
}
|
||||
firstPayload := spells[0].(map[string]any)["payload"].(map[string]any)
|
||||
secondPayload := spells[1].(map[string]any)["payload"].(map[string]any)
|
||||
if firstPayload["name"] != "first" || secondPayload["name"] != "second" {
|
||||
t.Fatalf("spell order payloads = %#v then %#v, want runner order", firstPayload, secondPayload)
|
||||
spells := decodeObject(t, fileBytes(t, result.Files, "outputs/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"))
|
||||
artifactFiles := index["artifact_files"].([]any)
|
||||
if len(artifactFiles) != 2 {
|
||||
t.Fatalf("len(index artifact_files) = %d, want 2", len(artifactFiles))
|
||||
outputFiles := index["output_files"].([]any)
|
||||
if len(outputFiles) != 2 {
|
||||
t.Fatalf("len(index output_files) = %d, want 2", len(outputFiles))
|
||||
}
|
||||
firstIndex := artifactFiles[0].(map[string]any)
|
||||
secondIndex := artifactFiles[1].(map[string]any)
|
||||
if firstIndex["artifact_type"] != "dnd.spell-cast" || secondIndex["artifact_type"] != "notes/item" {
|
||||
t.Fatalf("artifact_files = %#v, want sorted by artifact type", artifactFiles)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,12 +177,12 @@ func TestEncodeIncludesManifestReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRejectsArtifactTypeWithoutSafeFileName(t *testing.T) {
|
||||
func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) {
|
||||
_, err := New().Encode(context.Background(), contracts.OutputRequest{
|
||||
Approved: []artifacts.Artifact{artifact("///", "unsafe")},
|
||||
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("///", `{"value":true}`)},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Encode() error = nil, want unsafe artifact type error")
|
||||
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())
|
||||
@@ -193,22 +191,22 @@ func TestEncodeRejectsArtifactTypeWithoutSafeFileName(t *testing.T) {
|
||||
|
||||
func TestEncodeSanitizesParentPathSequences(t *testing.T) {
|
||||
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
||||
Approved: []artifacts.Artifact{artifact("dnd..spell.", "spell")},
|
||||
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, "artifacts/dnd__spell.json") {
|
||||
t.Fatalf("file names = %#v, want sanitized artifact filename", got)
|
||||
if got := outputFileNames(result.Files); !containsString(got, "outputs/dnd__spell.json") {
|
||||
t.Fatalf("file names = %#v, want sanitized output filename", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) {
|
||||
_, err := New().Encode(context.Background(), contracts.OutputRequest{
|
||||
Approved: []artifacts.Artifact{
|
||||
artifact("a/b", "slash"),
|
||||
artifact("a?b", "question"),
|
||||
NormalizeOutputs: []contracts.NormalizeOutput{
|
||||
normalizeOutput("a/b", `{"value":"slash"}`),
|
||||
normalizeOutput("a?b", `{"value":"question"}`),
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
@@ -222,16 +220,11 @@ func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) {
|
||||
func TestEncodeDoesNotMutateInputs(t *testing.T) {
|
||||
req := contracts.OutputRequest{
|
||||
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
||||
Approved: []artifacts.Artifact{
|
||||
artifact("dnd.spell", "original"),
|
||||
NormalizeOutputs: []contracts.NormalizeOutput{
|
||||
normalizeOutput("spells", `{"name":"original"}`),
|
||||
},
|
||||
Rejected: []artifacts.RejectedArtifact{
|
||||
{
|
||||
Candidate: candidate("bad", "rejected"),
|
||||
ValidatorName: "validator",
|
||||
ReasonCode: "invalid",
|
||||
Message: "not accepted",
|
||||
},
|
||||
Rejected: []contracts.RejectedOutput{
|
||||
{Stage: "extract", LaneID: "spells", Message: "not accepted"},
|
||||
},
|
||||
Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "message"}},
|
||||
}
|
||||
@@ -246,14 +239,13 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) {
|
||||
t.Fatalf("request mutated:\nbefore: %s\nafter: %s", before, after)
|
||||
}
|
||||
|
||||
req.Approved[0].Payload[0] = '['
|
||||
req.Approved[0].SourceRefs[0].StartUnitID = 99
|
||||
req.Approved[0].Metadata["name"] = "changed"
|
||||
req.Rejected[0].Candidate.Payload[0] = '['
|
||||
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, "artifacts/dnd.spell.json")) {
|
||||
t.Fatal("artifact output changed after request mutation")
|
||||
if !stdjson.Valid(fileBytes(t, result.Files, "outputs/spells.json")) {
|
||||
t.Fatal("output changed after request mutation")
|
||||
}
|
||||
warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json"))
|
||||
gotWarnings := warnings["warnings"].([]any)
|
||||
@@ -262,9 +254,9 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactFilesDoNotContainWarnings(t *testing.T) {
|
||||
func TestOutputFilesDoNotContainWarnings(t *testing.T) {
|
||||
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
||||
Approved: []artifacts.Artifact{artifact("dnd.spell", "spell")},
|
||||
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("spells", `{"spell":"Shield"}`)},
|
||||
Warnings: []contracts.Warning{
|
||||
{ReasonCode: "pipeline-warning", Message: "warning"},
|
||||
},
|
||||
@@ -273,36 +265,27 @@ func TestArtifactFilesDoNotContainWarnings(t *testing.T) {
|
||||
t.Fatalf("Encode() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
artifactFile := decodeObject(t, fileBytes(t, result.Files, "artifacts/dnd.spell.json"))
|
||||
if _, ok := artifactFile["warnings"]; ok {
|
||||
t.Fatalf("artifact file contains warnings: %#v", artifactFile)
|
||||
outputFile := decodeObject(t, fileBytes(t, result.Files, "outputs/spells.json"))
|
||||
if _, ok := outputFile["warnings"]; ok {
|
||||
t.Fatalf("output file contains warnings: %#v", outputFile)
|
||||
}
|
||||
}
|
||||
|
||||
func artifact(artifactType, name string) artifacts.Artifact {
|
||||
return artifacts.Artifact{
|
||||
ExtractorKey: "extractor",
|
||||
ArtifactType: artifactType,
|
||||
SchemaVersion: "v1",
|
||||
Payload: stdjson.RawMessage(`{"name":"` + name + `"}`),
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1},
|
||||
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",
|
||||
},
|
||||
Metadata: map[string]any{"name": name},
|
||||
}
|
||||
}
|
||||
|
||||
func candidate(artifactType, name string) artifacts.ArtifactCandidate {
|
||||
return artifacts.ArtifactCandidate{
|
||||
Index: 1,
|
||||
ExtractorKey: "extractor",
|
||||
ArtifactType: artifactType,
|
||||
SchemaVersion: "v1",
|
||||
Payload: stdjson.RawMessage(`{"name":"` + name + `"}`),
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1},
|
||||
Payload: contracts.RawPayload{
|
||||
Content: []byte(content),
|
||||
MediaType: contentTypeJSON,
|
||||
Metadata: map[string]any{"name": laneID},
|
||||
},
|
||||
Metadata: map[string]any{"name": name},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user