package locations import ( "bytes" "encoding/json" "os" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" ) func validList() dnd.LocationList { refs := []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}} return dnd.LocationList{Locations: []dnd.Location{{ ID: identity.DeriveID("The Old Tavern", refs), Name: "The Old Tavern", SourceRefs: refs, }}} } func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { raw, err := os.ReadFile("testdata/dnd_locations.v1.json") if err != nil { t.Fatalf("read durable fixture: %v", err) } codec := New() value, err := codec.Decode(raw) if err != nil { t.Fatalf("Decode() error = %v, want nil", err) } if want := validList(); !reflect.DeepEqual(value, want) { t.Fatalf("Decode() = %#v, want %#v", value, want) } encoded, err := codec.Encode(value) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } var compact bytes.Buffer if err := json.Compact(&compact, raw); err != nil { t.Fatalf("compact durable fixture: %v", err) } if !bytes.Equal(encoded, compact.Bytes()) { t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes()) } } func TestCodecOwnsDurableSchemaAndMetadata(t *testing.T) { codec := New() schema := codec.Schema() if codec.Kind() != dnd.LocationListKind || codec.MediaType() != MediaType { t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType()) } if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { t.Fatalf("schema = %#v, want durable location schema", schema) } var document map[string]any if err := json.Unmarshal(schema.JSONSchema, &document); err != nil || document["$id"] != SchemaID { t.Fatalf("durable schema document = %#v, %v", document, err) } registry := pipeline.NewArtifactCodecRegistry() if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v", err) } if spec, ok := registry.Spec(dnd.LocationListKind); !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { t.Fatalf("registered spec = %#v, %t", spec, ok) } first := schema.JSONSchema first[0] = '[' if next := codec.Schema().JSONSchema; !json.Valid(next) || next[0] == '[' { t.Fatal("Schema() returned shared bytes") } metadata := codec.Metadata(validList()) metadata["other"] = true if next := codec.Metadata(validList()); len(next) != 1 || next["location_count"] != 1 { t.Fatalf("Metadata() = %#v", next) } } func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) { codec := New() empty := dnd.LocationList{Locations: []dnd.Location{}} if content, err := codec.Encode(empty); err != nil || string(content) != `{"locations":[]}` { t.Fatalf("Encode() = %s, %v", content, err) } for _, candidate := range []dnd.LocationList{ {}, empty, {Locations: []dnd.Location{{ID: "bad", Name: " ", SourceRefs: nil}}}, {Locations: []dnd.Location{{ID: "bad", Name: " ", SourceRefs: []source.SourceRef{}}}}, {Locations: []dnd.Location{{ID: "bad", Name: " ", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}}, } { content, err := codec.EncodeCandidate(candidate) if err != nil || !json.Valid(content) { t.Fatalf("EncodeCandidate() = %s, %v", content, err) } decoded, err := codec.DecodeCandidate(content) if err != nil || !reflect.DeepEqual(decoded, candidate) { t.Fatalf("DecodeCandidate() = %#v, %v, want %#v", decoded, err, candidate) } } } func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) { valid := `{"locations":[{"id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ {"malformed", `{`, "decode dnd location list"}, {"unknown top-level", `{"locations":[],"unexpected":true}`, "unknown field"}, {"unknown location field", strings.Replace(valid, `"name":"The Tavern"`, `"name":"The Tavern","unexpected":true`, 1), "unknown field"}, {"unknown source reference field", strings.Replace(valid, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"}, {"invalid type", `{"locations":"not-an-array"}`, "cannot unmarshal string"}, {"trailing", `{"locations":[]} {}`, "multiple JSON values"}, } { t.Run(test.name, func(t *testing.T) { if _, err := New().DecodeCandidate([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("DecodeCandidate() error = %v, want %q", err, test.want) } }) } } func TestCodecRejectsApprovedShapeBoundaries(t *testing.T) { base := validList().Locations[0] for _, test := range []struct { name string value dnd.LocationList want string }{ {"nil locations", dnd.LocationList{}, "locations must be present"}, {"invalid ID", dnd.LocationList{Locations: []dnd.Location{{ID: "bad", Name: base.Name, SourceRefs: base.SourceRefs}}}, "id must match location ID pattern"}, {"blank name", dnd.LocationList{Locations: []dnd.Location{{ID: base.ID, Name: " ", SourceRefs: base.SourceRefs}}}, "name must not be empty"}, {"empty source refs", dnd.LocationList{Locations: []dnd.Location{{ID: base.ID, Name: base.Name, SourceRefs: nil}}}, "source_refs must contain"}, {"malformed source reference", dnd.LocationList{Locations: []dnd.Location{{ID: base.ID, Name: base.Name, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 0, EndUnitID: 1}}}}}, "start_unit_id must be positive"}, } { t.Run(test.name, func(t *testing.T) { if _, err := New().Encode(test.value); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Encode() error = %v, want %q", err, test.want) } }) } }