package catalog import ( "encoding/json" "reflect" "sort" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) func TestResolveEffectiveCatalogBaseAndImmutability(t *testing.T) { effective, err := ResolveEffectiveCatalog(contracts.ReferenceSet{}) if err != nil { t.Fatal(err) } if effective.BaseID() != SRD5E2014ID || effective.Ruleset() != SRD5E2014Ruleset { t.Fatalf("identity = %q/%q", effective.BaseID(), effective.Ruleset()) } if got := effective.OverlayIDs(); len(got) != 0 { t.Fatalf("overlay IDs = %#v", got) } names := effective.CanonicalNames() if len(names) != 319 || !sort.StringsAreSorted(names) { t.Fatalf("canonical names count/order = %d/%t", len(names), sort.StringsAreSorted(names)) } if got, ok := effective.Lookup(" HUNTER'S MARK "); !ok || got != "Hunter’s Mark" { t.Fatalf("Hunter's Mark lookup = %q, present=%t", got, ok) } if got, ok := effective.Lookup("Definitely Not A Spell"); ok || got != "" { t.Fatalf("unknown lookup = %q, present=%t", got, ok) } if !strings.HasPrefix(effective.Digest(), "sha256:") { t.Fatalf("digest = %q", effective.Digest()) } names[0] = "changed" ids := effective.OverlayIDs() ids = append(ids, "changed") again, err := ResolveEffectiveCatalog(contracts.ReferenceSet{}) if err != nil { t.Fatal(err) } if reflect.DeepEqual(names, again.CanonicalNames()) || len(again.OverlayIDs()) != 0 { t.Fatal("effective catalog exposed mutable result storage") } } func TestResolveEffectiveCatalogAddsAndAugmentsSpells(t *testing.T) { overlay := testOverlayJSON(t, testOverlayCatalog( "campaign.example", testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"), testOverlaySpell("Cure Wounds", "Healing Touch"), )) effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json")) if err != nil { t.Fatal(err) } if got := effective.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) { t.Fatalf("overlay IDs = %#v", got) } if len(effective.CanonicalNames()) != 320 { t.Fatalf("canonical name count = %d, want 320", len(effective.CanonicalNames())) } if got, ok := effective.Lookup("aegis of emberfall"); !ok || got != "Aegis of Emberfall" { t.Fatalf("new spell lookup = %q, present=%t", got, ok) } if got, ok := effective.Lookup("Emberfall Aegis"); !ok || got != "Aegis of Emberfall" { t.Fatalf("new alias lookup = %q, present=%t", got, ok) } if got, ok := effective.Lookup("Healing Touch"); !ok || got != "Cure Wounds" { t.Fatalf("augmentation alias lookup = %q, present=%t", got, ok) } for _, name := range effective.CanonicalNames() { if name == "Emberfall Aegis" || name == "Healing Touch" { t.Fatalf("alias %q was exposed as a canonical name", name) } } } func TestResolveEffectiveCatalogTreatsRepeatedAliasesAsIdempotent(t *testing.T) { single := testOverlayJSON(t, testOverlayCatalog( "campaign.example", testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"), )) repeated := testOverlayJSON(t, testOverlayCatalog( "campaign.example", testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"), testOverlaySpell("Aegis of Emberfall", "emberfall aegis"), )) first, err := ResolveEffectiveCatalog(overlayReference([]byte(single), "application/json")) if err != nil { t.Fatal(err) } second, err := ResolveEffectiveCatalog(overlayReference([]byte(repeated), "application/json")) if err != nil { t.Fatal(err) } if first.Digest() != second.Digest() || !reflect.DeepEqual(first.CanonicalNames(), second.CanonicalNames()) { t.Fatalf("repeated alias changed effective result: digest=%q/%q", first.Digest(), second.Digest()) } } func TestResolveEffectiveCatalogDigestIgnoresOrderingAndFormatting(t *testing.T) { first := `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.z","ruleset":"dnd-5e-2014","source":{"title":"Z spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Z Alias","Another Alias"]}]},{"id":"campaign.a","ruleset":"dnd-5e-2014","source":{"title":"A spells","version":"1","url":"","license":""},"spells":[{"name":"Cure Wounds","aliases":["Healing Touch","Cure Mend"]}]}]}` second := `{ "catalogs": [ {"source":{"license":"","url":"","version":"1","title":"A spells"},"spells":[{"aliases":["Cure Mend","Healing Touch"],"name":"Cure Wounds"}],"ruleset":"dnd-5e-2014","id":"campaign.a"}, {"spells":[{"aliases":["Another Alias","Z Alias"],"name":"Aegis of Emberfall"}],"source":{"title":"Z spells","version":"1","url":"","license":""},"ruleset":"dnd-5e-2014","id":"campaign.z"} ], "schema_version":"notarius.dnd.spell-catalog-overlay.v1" }` firstCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(first), "application/json")) if err != nil { t.Fatal(err) } secondCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(second), "application/json")) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()) || firstCatalog.Digest() != secondCatalog.Digest() { t.Fatalf("reordered overlays changed effective result: names=%t digest=%q/%q", reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()), firstCatalog.Digest(), secondCatalog.Digest()) } } func TestResolveEffectiveCatalogRejectsInvalidOverlays(t *testing.T) { valid := validOverlayJSON() tests := []struct { name string content string }{ {name: "wrong schema version", content: strings.Replace(valid, "notarius.dnd.spell-catalog-overlay.v1", "notarius.dnd.spell-catalog-overlay.v2", 1)}, {name: "empty catalogs", content: `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[]}`}, {name: "duplicate IDs", content: testOverlayJSON(t, testOverlayCatalog("campaign.example", testOverlaySpell("Aegis of Emberfall")), testOverlayCatalog("campaign.example", testOverlaySpell("Another Spell")))}, {name: "wrong ruleset", content: strings.Replace(valid, `"ruleset":"dnd-5e-2014"`, `"ruleset":"dnd-5e-other"`, 1)}, {name: "empty ID", content: strings.Replace(valid, `"id":"campaign.example"`, `"id":""`, 1)}, {name: "empty source title", content: strings.Replace(valid, `"title":"Example campaign spells"`, `"title":""`, 1)}, {name: "empty spells", content: testOverlayJSON(t, testOverlayCatalog("campaign.example"))}, {name: "empty spell name", content: strings.Replace(valid, `"name":"Aegis of Emberfall"`, `"name":""`, 1)}, {name: "null aliases", content: strings.Replace(valid, `"aliases":["Emberfall Aegis"]`, `"aliases":null`, 1)}, {name: "unknown bundle field", content: strings.Replace(valid, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs"`, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","unexpected":true,"catalogs"`, 1)}, {name: "unknown catalog field", content: strings.Replace(valid, `"id":"campaign.example","ruleset"`, `"id":"campaign.example","unexpected":true,"ruleset"`, 1)}, {name: "unknown source field", content: strings.Replace(valid, `"title":"Example campaign spells","version"`, `"title":"Example campaign spells","unexpected":true,"version"`, 1)}, {name: "unknown spell field", content: strings.Replace(valid, `"name":"Aegis of Emberfall","aliases"`, `"name":"Aegis of Emberfall","unexpected":true,"aliases"`, 1)}, {name: "trailing JSON", content: valid + `{}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, err := ResolveEffectiveCatalog(overlayReference([]byte(test.content), "application/json")); err == nil { t.Fatal("invalid overlay was accepted") } }) } } func TestResolveEffectiveCatalogRejectsReferenceMultiplicityAndMediaType(t *testing.T) { valid := []byte(validOverlayJSON()) tests := []struct { name string refs contracts.ReferenceSet }{ { name: "multiple items", refs: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ SpellCatalogReferenceSlot: {Items: []contracts.ReferenceItem{ {MediaType: "application/json", Content: valid}, {MediaType: "application/json", Content: valid}, }}, }}, }, { name: "non JSON item", refs: overlayReference(valid, "text/plain"), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, err := ResolveEffectiveCatalog(test.refs); err == nil { t.Fatal("invalid reference set was accepted") } }) } for _, refs := range []contracts.ReferenceSet{ {}, {Slots: map[string]contracts.ResolvedReferenceSlot{SpellCatalogReferenceSlot: {}}}, } { if effective, err := ResolveEffectiveCatalog(refs); err != nil || len(effective.CanonicalNames()) != 319 { t.Fatalf("missing overlay should use base catalog: effective=%#v err=%v", effective, err) } } } func TestResolveEffectiveCatalogRejectsCrossSpellCollisions(t *testing.T) { tests := []struct { name string spells []overlaySpell content string }{ { name: "same key display conflict with embedded canonical", spells: []overlaySpell{testOverlaySpell("cure wounds")}, }, { name: "same key display conflict between canonical names", spells: []overlaySpell{testOverlaySpell("Moon Beam"), testOverlaySpell("moon beam")}, }, { name: "canonical versus alias", spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Beta Spell"), testOverlaySpell("Beta Spell")}, }, { name: "alias versus canonical", spells: []overlaySpell{testOverlaySpell("Alpha Spell"), testOverlaySpell("Beta Spell", "Alpha Spell")}, }, { name: "alias versus alias", spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Shared Name"), testOverlaySpell("Beta Spell", "Shared Name")}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { content := test.content if content == "" { content = testOverlayJSON(t, testOverlayCatalog("campaign.example", test.spells...)) } if _, err := ResolveEffectiveCatalog(overlayReference([]byte(content), "application/json")); err == nil { t.Fatal("cross-spell collision was accepted") } }) } } func overlayReference(content []byte, mediaType string) contracts.ReferenceSet { return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ SpellCatalogReferenceSlot: { Items: []contracts.ReferenceItem{{ SlotName: SpellCatalogReferenceSlot, MediaType: mediaType, Content: append([]byte(nil), content...), }}, }, }} } func testOverlaySpell(name string, aliases ...string) overlaySpell { content, err := json.Marshal(aliases) if err != nil { panic(err) } return overlaySpell{Name: name, Aliases: content} } func testOverlayCatalog(id string, spells ...overlaySpell) overlayCatalog { return overlayCatalog{ ID: id, Ruleset: SRD5E2014Ruleset, Source: overlaySource{ Title: id + " spells", Version: json.RawMessage(`"1"`), URL: json.RawMessage(`""`), License: json.RawMessage(`""`), }, Spells: spells, } } func testOverlayJSON(t *testing.T, catalogs ...overlayCatalog) string { t.Helper() content, err := json.Marshal(overlayBundle{SchemaVersion: overlaySchemaVersion, Catalogs: catalogs}) if err != nil { t.Fatal(err) } return string(content) } func validOverlayJSON() string { return `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Example campaign spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}` }