diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs index 846f0d8..9c9bd3e 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -43,16 +43,7 @@ public sealed class ConceptSubjectRegistry( public static IReadOnlyList ParseManifest(string json) { using var document = ParseDocument(json); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - throw new ManifestRefusedException("ships", "Missing"); - } - - if (!document.RootElement.TryGetProperty("ships", out var ships)) - { - throw new ManifestRefusedException("ships", "Missing"); - } - + var ships = ShipsOf(document.RootElement); if (ships.ValueKind != JsonValueKind.Array) { throw new ManifestRefusedException("ships", "Missing"); @@ -91,6 +82,16 @@ public sealed class ConceptSubjectRegistry( return result; } + private static JsonElement ShipsOf(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + { + return default; + } + + return root.TryGetProperty("ships", out var ships) ? ships : default; + } + private static JsonDocument ParseDocument(string json) { try diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index bbf8dd3..804b1ba 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -170,6 +170,8 @@ public sealed class ConceptSubjectRegistryTests [InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")] [InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")] [InlineData("""{"fleet":[]}""", "ships", "Missing")] + [InlineData("[]", "ships", "Missing")] + [InlineData("""{"ships":5}""", "ships", "Missing")] [InlineData("not json", "manifest", "NotJson")] public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) { @@ -180,6 +182,28 @@ public sealed class ConceptSubjectRegistryTests Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } + [Fact] + public void A_title_of_exactly_the_column_width_is_accepted_and_one_more_is_refused() + { + var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength); + + Assert.Equal(fits, ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); + var refusal = Assert.Throws(() => + ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); + Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code)); + } + + [Fact] + public async Task Absent_keys_are_planned_in_key_order() + { + using var world = new World(); + await world.Apply(Fixture()); + + var plan = await world.Plan([new ManifestShip("zzz-new", "New")]); + + Assert.Equal(["zzz-new", "bison", "ibis-mk2", "kestrel"], plan.Entries.Select(e => e.Key)); + } + [Fact] public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row() { @@ -201,8 +225,10 @@ public sealed class ConceptSubjectRegistryTests using var world = new World(); var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); Assert.DoesNotContain("CANARY", collection.Message); + Assert.Equal(("collection", "InvalidKey"), (collection.Field, collection.Code)); var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); Assert.DoesNotContain("CANARY", retire.Message); + Assert.Equal(("retire", "InvalidKey"), (retire.Field, retire.Code)); } [Theory] @@ -282,6 +308,7 @@ public sealed class ConceptSubjectRegistryTests var missing = new StringWriter(); Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing)); Assert.DoesNotContain("CANARY", missing.ToString()); + Assert.Equal("refused manifest Unreadable", missing.ToString().Trim()); Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); }