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) } } }