diff --git a/internal/modules/dnd/codec/locationoccurrences/assets/schemas/dnd_location_occurrences.v1.json b/internal/modules/dnd/codec/locationoccurrences/assets/schemas/dnd_location_occurrences.v1.json new file mode 100644 index 0000000..fe7a9b0 --- /dev/null +++ b/internal/modules/dnd/codec/locationoccurrences/assets/schemas/dnd_location_occurrences.v1.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.location_occurrences", + "type": "object", + "additionalProperties": false, + "required": ["occurrences"], + "properties": { + "occurrences": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["location_id", "name", "kind", "source_refs"], + "properties": { + "location_id": { + "type": "string", + "pattern": "^location:sha256:[0-9a-f]{64}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ["visited", "planned", "recalled", "mentioned"] + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "start_unit_id": { + "type": "integer", + "minimum": 1 + }, + "end_unit_id": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/codec/locationoccurrences/codec.go b/internal/modules/dnd/codec/locationoccurrences/codec.go new file mode 100644 index 0000000..7afc4a9 --- /dev/null +++ b/internal/modules/dnd/codec/locationoccurrences/codec.go @@ -0,0 +1,126 @@ +// Package locationoccurrences encodes durable D&D location occurrence artifacts. +package locationoccurrences + +import ( + "embed" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" +) + +const ( + SchemaID = "notarius.dnd.location_occurrences" + SchemaName = "notarius_dnd_location_occurrences_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +//go:embed assets/schemas/dnd_location_occurrences.v1.json +var schemaAssets embed.FS + +var _ contracts.ArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil) + +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.LocationOccurrenceListKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_location_occurrences.v1.json") + if err != nil { + return contracts.ArtifactSchema{} + } + return contracts.ArtifactSchema{ + ID: SchemaID, + Name: SchemaName, + Version: SchemaVersion, + JSONSchema: append([]byte(nil), raw...), + } +} + +func (c *Codec) MediaType() string { return MediaType } + +func (c *Codec) Metadata(value dnd.LocationOccurrenceList) map[string]any { + return map[string]any{"occurrence_count": len(value.Occurrences)} +} + +func (c *Codec) Encode(value dnd.LocationOccurrenceList) ([]byte, error) { + if err := validate(value); err != nil { + return nil, fmt.Errorf("encode dnd location occurrence list: %w", err) + } + return c.EncodeCandidate(value) +} + +// EncodeCandidate provides the durable representation before semantic +// validators have approved a value. +func (c *Codec) EncodeCandidate(value dnd.LocationOccurrenceList) ([]byte, error) { + return candidatejson.EncodeCandidate("dnd location occurrence list", value) +} + +func (c *Codec) Decode(content []byte) (dnd.LocationOccurrenceList, error) { + value, err := c.DecodeCandidate(content) + if err != nil { + return dnd.LocationOccurrenceList{}, err + } + if err := validate(value); err != nil { + return dnd.LocationOccurrenceList{}, fmt.Errorf("decode dnd location occurrence list: %w", err) + } + return value, nil +} + +// DecodeCandidate reads one strict durable JSON value before semantic +// validators have approved it. +func (c *Codec) DecodeCandidate(content []byte) (dnd.LocationOccurrenceList, error) { + return candidatejson.DecodeCandidate[dnd.LocationOccurrenceList]("dnd location occurrence list", content) +} + +func validate(value dnd.LocationOccurrenceList) error { + if value.Occurrences == nil { + return fmt.Errorf("occurrences must be present") + } + for index, occurrence := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + if !identity.IsValidID(occurrence.LocationID) { + return fmt.Errorf("%s.location_id must match location ID pattern", prefix) + } + if strings.TrimSpace(occurrence.Name) == "" { + return fmt.Errorf("%s.name must not be empty", prefix) + } + if !validOccurrenceKind(occurrence.Kind) { + return fmt.Errorf("%s.kind must be supported", prefix) + } + if len(occurrence.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) + } + for refIndex, ref := range occurrence.SourceRefs { + refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) + if strings.TrimSpace(ref.SourceID) == "" { + return fmt.Errorf("%s.source_id must not be empty", refPrefix) + } + if ref.StartUnitID <= 0 { + return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) + } + if ref.EndUnitID <= 0 { + return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + } + } + } + return nil +} + +func validOccurrenceKind(value dnd.LocationOccurrenceKind) bool { + switch value { + case dnd.LocationOccurrenceKindVisited, + dnd.LocationOccurrenceKindPlanned, + dnd.LocationOccurrenceKindRecalled, + dnd.LocationOccurrenceKindMentioned: + return true + default: + return false + } +} diff --git a/internal/modules/dnd/codec/locationoccurrences/codec_test.go b/internal/modules/dnd/codec/locationoccurrences/codec_test.go new file mode 100644 index 0000000..a495104 --- /dev/null +++ b/internal/modules/dnd/codec/locationoccurrences/codec_test.go @@ -0,0 +1,163 @@ +package locationoccurrences + +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.LocationOccurrenceList { + refs := []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}} + return dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{ + LocationID: identity.DeriveID("The Old Tavern", refs), + Name: "The Old Tavern", + Kind: dnd.LocationOccurrenceKindVisited, + SourceRefs: refs, + }}} +} + +func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/dnd_location_occurrences.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.LocationOccurrenceListKind || 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 occurrence 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.LocationOccurrenceListKind); !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["occurrence_count"] != 1 { + t.Fatalf("Metadata() = %#v", next) + } +} + +func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) { + codec := New() + empty := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{}} + if content, err := codec.Encode(empty); err != nil || string(content) != `{"occurrences":[]}` { + t.Fatalf("Encode() = %s, %v", content, err) + } + for _, candidate := range []dnd.LocationOccurrenceList{ + {}, + empty, + {Occurrences: []dnd.LocationOccurrence{{LocationID: "bad", Name: " ", Kind: "unsupported", SourceRefs: nil}}}, + {Occurrences: []dnd.LocationOccurrence{{LocationID: "bad", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}}, + {Occurrences: []dnd.LocationOccurrence{{LocationID: "bad", Name: " ", Kind: "unsupported", 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 := `{"occurrences":[{"location_id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","kind":"visited","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 occurrence list"}, + {"unknown top-level", `{"occurrences":[],"unexpected":true}`, "unknown field"}, + {"unknown occurrence field", strings.Replace(valid, `"kind":"visited"`, `"kind":"visited","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", `{"occurrences":"not-an-array"}`, "cannot unmarshal string"}, + {"trailing", `{"occurrences":[]} {}`, "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 TestCodecRejectsApprovedShapeEnumAndReferenceBoundaries(t *testing.T) { + base := validList().Occurrences[0] + for _, test := range []struct { + name string + value dnd.LocationOccurrenceList + want string + }{ + {"nil occurrences", dnd.LocationOccurrenceList{}, "occurrences must be present"}, + {"invalid ID", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: "bad", Name: base.Name, Kind: base.Kind, SourceRefs: base.SourceRefs}}}, "location_id must match location ID pattern"}, + {"blank name", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: base.LocationID, Name: " ", Kind: base.Kind, SourceRefs: base.SourceRefs}}}, "name must not be empty"}, + {"invalid kind", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: base.LocationID, Name: base.Name, Kind: "unsupported", SourceRefs: base.SourceRefs}}}, "kind must be supported"}, + {"empty source refs", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: base.LocationID, Name: base.Name, Kind: base.Kind}}}, "source_refs must contain"}, + {"malformed source reference", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: base.LocationID, Name: base.Name, Kind: base.Kind, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 0}}}}}, "end_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) + } + }) + } +} + +func TestCodecAcceptsEveryOccurrenceKind(t *testing.T) { + for _, kind := range []dnd.LocationOccurrenceKind{ + dnd.LocationOccurrenceKindVisited, + dnd.LocationOccurrenceKindPlanned, + dnd.LocationOccurrenceKindRecalled, + dnd.LocationOccurrenceKindMentioned, + } { + value := validList() + value.Occurrences[0].Kind = kind + if _, err := New().Encode(value); err != nil { + t.Fatalf("Encode(%q) error = %v", kind, err) + } + } +} diff --git a/internal/modules/dnd/codec/locationoccurrences/testdata/dnd_location_occurrences.v1.json b/internal/modules/dnd/codec/locationoccurrences/testdata/dnd_location_occurrences.v1.json new file mode 100644 index 0000000..d6856a1 --- /dev/null +++ b/internal/modules/dnd/codec/locationoccurrences/testdata/dnd_location_occurrences.v1.json @@ -0,0 +1,12 @@ +{ + "occurrences": [ + { + "location_id": "location:sha256:cdd2b57615b56a4e92d506cf053cd3985e11d4df4895c5383b30ec2ce9f39ed0", + "name": "The Old Tavern", + "kind": "visited", + "source_refs": [ + {"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2} + ] + } + ] +} diff --git a/internal/modules/dnd/codec/locations/assets/schemas/dnd_locations.v1.json b/internal/modules/dnd/codec/locations/assets/schemas/dnd_locations.v1.json new file mode 100644 index 0000000..b7089da --- /dev/null +++ b/internal/modules/dnd/codec/locations/assets/schemas/dnd_locations.v1.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.locations", + "type": "object", + "additionalProperties": false, + "required": ["locations"], + "properties": { + "locations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "source_refs"], + "properties": { + "id": { + "type": "string", + "pattern": "^location:sha256:[0-9a-f]{64}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "start_unit_id": { + "type": "integer", + "minimum": 1 + }, + "end_unit_id": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/codec/locations/codec.go b/internal/modules/dnd/codec/locations/codec.go new file mode 100644 index 0000000..0fb43f6 --- /dev/null +++ b/internal/modules/dnd/codec/locations/codec.go @@ -0,0 +1,111 @@ +// Package locations encodes durable D&D location artifacts. +package locations + +import ( + "embed" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" +) + +const ( + SchemaID = "notarius.dnd.locations" + SchemaName = "notarius_dnd_locations_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +//go:embed assets/schemas/dnd_locations.v1.json +var schemaAssets embed.FS + +var _ contracts.ArtifactCodec[dnd.LocationList] = (*Codec)(nil) + +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.LocationListKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_locations.v1.json") + if err != nil { + return contracts.ArtifactSchema{} + } + return contracts.ArtifactSchema{ + ID: SchemaID, + Name: SchemaName, + Version: SchemaVersion, + JSONSchema: append([]byte(nil), raw...), + } +} + +func (c *Codec) MediaType() string { return MediaType } + +func (c *Codec) Metadata(value dnd.LocationList) map[string]any { + return map[string]any{"location_count": len(value.Locations)} +} + +func (c *Codec) Encode(value dnd.LocationList) ([]byte, error) { + if err := validate(value); err != nil { + return nil, fmt.Errorf("encode dnd location list: %w", err) + } + return c.EncodeCandidate(value) +} + +// EncodeCandidate provides the durable representation before semantic +// validators have approved a value. +func (c *Codec) EncodeCandidate(value dnd.LocationList) ([]byte, error) { + return candidatejson.EncodeCandidate("dnd location list", value) +} + +func (c *Codec) Decode(content []byte) (dnd.LocationList, error) { + value, err := c.DecodeCandidate(content) + if err != nil { + return dnd.LocationList{}, err + } + if err := validate(value); err != nil { + return dnd.LocationList{}, fmt.Errorf("decode dnd location list: %w", err) + } + return value, nil +} + +// DecodeCandidate reads one strict durable JSON value before semantic +// validators have approved it. +func (c *Codec) DecodeCandidate(content []byte) (dnd.LocationList, error) { + return candidatejson.DecodeCandidate[dnd.LocationList]("dnd location list", content) +} + +func validate(value dnd.LocationList) error { + if value.Locations == nil { + return fmt.Errorf("locations must be present") + } + for index, location := range value.Locations { + prefix := fmt.Sprintf("locations[%d]", index) + if !identity.IsValidID(location.ID) { + return fmt.Errorf("%s.id must match location ID pattern", prefix) + } + if strings.TrimSpace(location.Name) == "" { + return fmt.Errorf("%s.name must not be empty", prefix) + } + if len(location.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) + } + for refIndex, ref := range location.SourceRefs { + refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) + if strings.TrimSpace(ref.SourceID) == "" { + return fmt.Errorf("%s.source_id must not be empty", refPrefix) + } + if ref.StartUnitID <= 0 { + return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) + } + if ref.EndUnitID <= 0 { + return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + } + } + } + return nil +} diff --git a/internal/modules/dnd/codec/locations/codec_test.go b/internal/modules/dnd/codec/locations/codec_test.go new file mode 100644 index 0000000..605b0de --- /dev/null +++ b/internal/modules/dnd/codec/locations/codec_test.go @@ -0,0 +1,146 @@ +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) + } + }) + } +} diff --git a/internal/modules/dnd/codec/locations/testdata/dnd_locations.v1.json b/internal/modules/dnd/codec/locations/testdata/dnd_locations.v1.json new file mode 100644 index 0000000..abb6354 --- /dev/null +++ b/internal/modules/dnd/codec/locations/testdata/dnd_locations.v1.json @@ -0,0 +1,11 @@ +{ + "locations": [ + { + "id": "location:sha256:cdd2b57615b56a4e92d506cf053cd3985e11d4df4895c5383b30ec2ce9f39ed0", + "name": "The Old Tavern", + "source_refs": [ + {"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2} + ] + } + ] +}