package itemevents import ( "bytes" "encoding/json" "errors" "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" ) func validList() dnd.ItemEventList { quantity := 12 return dnd.ItemEventList{Events: []dnd.ItemEvent{ {Name: "Hidden Cache", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}, {Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)}, {Name: "Torch", Kind: dnd.ItemEventKindLost, From: "party", SourceRefs: refs(3, 3)}, {Name: "Healing Potion", Kind: dnd.ItemEventKindConsumed, From: "party", SourceRefs: refs(4, 4)}, {Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)}, }} } func refs(start, end int) []source.SourceRef { return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}} } func TestCodecRoundTripAndIdentities(t *testing.T) { codec := New() value := validList() content, err := codec.Encode(value) if err != nil { t.Fatalf("Encode() error = %v", err) } decoded, err := codec.Decode(content) if err != nil || !reflect.DeepEqual(decoded, value) { t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value) } schema := codec.Schema() if codec.Kind() != dnd.ItemEventListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema) } registry := pipeline.NewArtifactCodecRegistry() if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { t.Fatal(err) } spec, ok := registry.Spec(dnd.ItemEventListKind) if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { t.Fatalf("registered spec = %#v, %t", spec, ok) } if _, err := registry.Encode(dnd.ItemEventListKind, dnd.NPCList{}); err == nil { t.Fatal("Encode() error = nil, want exact type rejection") } else { var typeErr *pipeline.ArtifactCodecTypeError if !errors.As(err, &typeErr) { t.Fatalf("Encode() error = %T, want ArtifactCodecTypeError", err) } } } func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) { codec := New() empty := dnd.ItemEventList{Events: []dnd.ItemEvent{}} if content, err := codec.Encode(empty); err != nil || string(content) != `{"events":[]}` { t.Fatalf("Encode() = %s, %v", content, err) } zero := 0 candidate := dnd.ItemEventList{Events: []dnd.ItemEvent{{ Name: " ", Kind: dnd.ItemEventKindTransferred, Quantity: &zero, From: "party", To: "Party", SourceRefs: []source.SourceRef{{SourceID: "", 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) } if _, err := codec.Decode(content); err == nil { t.Fatal("Decode() error = nil, want semantic candidate rejection") } } func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) { validJSON := `{"events":[{"name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` tests := []struct { name, raw, want string }{ {"malformed", `{`, "decode dnd item event list"}, {"unknown top level", `{"events":[],"unexpected":true}`, "unknown field"}, {"unknown event field", strings.Replace(validJSON, `"to":"party"`, `"to":"party","unexpected":true`, 1), "unknown field"}, {"unknown reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"}, {"trailing", `{"events":[]} {}`, "multiple JSON values"}, {"missing list", `{}`, "events must be present"}, {"zero quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":0,"to":"party"`, 1), "quantity must be positive"}, {"negative quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":-1,"to":"party"`, 1), "quantity must be positive"}, {"party transfer", strings.Replace(validJSON, `"kind":"acquired","to":"party"`, `"kind":"transferred","from":"party","to":"Borin"`, 1), "holders are incompatible"}, {"self transfer", strings.Replace(validJSON, `"kind":"acquired","to":"party"`, `"kind":"transferred","from":"Aria","to":"aria"`, 1), "holders are incompatible"}, {"missing source refs", strings.Replace(validJSON, `,"source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]`, "", 1), "source_refs must contain"}, {"empty source refs", strings.Replace(validJSON, `[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]`, `[]`, 1), "source_refs must contain"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Decode() error = %v, want %q", err, test.want) } }) } } func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) { codec := New() value := validList() content, err := codec.EncodeCandidate(value) if err != nil { t.Fatal(err) } decoded, err := codec.DecodeCandidate(content) if err != nil { t.Fatal(err) } if decoded.Events[1].Quantity == value.Events[1].Quantity || &decoded.Events[1].SourceRefs[0] == &value.Events[1].SourceRefs[0] { t.Fatal("DecodeCandidate() retained caller-owned event fields") } *decoded.Events[1].Quantity = 99 decoded.Events[1].SourceRefs[0].SourceID = "changed" if *value.Events[1].Quantity != 12 || value.Events[1].SourceRefs[0].SourceID != "session" { t.Fatal("decoded item event aliases input") } first := codec.Schema() first.JSONSchema[0] = '[' if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' { t.Fatal("Schema() returned shared bytes") } metadata := codec.Metadata(value) metadata["payload"] = bytes.Repeat([]byte("x"), 10) if next := codec.Metadata(value); len(next) != 1 || next["event_count"] != len(value.Events) { t.Fatalf("Metadata() = %#v", next) } }