diff --git a/Directory.Packages.props b/Directory.Packages.props index 47ee98e..6d0cfaf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,11 +7,11 @@ - + - + diff --git a/tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs b/tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs new file mode 100644 index 0000000..27e7815 --- /dev/null +++ b/tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs @@ -0,0 +1,67 @@ +namespace WarHub.Catalog.Publish.Tests; + +/// +/// The happy path (every generated document validating) is covered by +/// via the fixture's full publish run. These cover the rejection path: a schema violation must +/// fail the build with a message that actually names the offending location. +/// +public sealed class SchemaValidatorTests +{ + private static SchemaValidator Validator() + => SchemaValidator.LoadFrom(Path.Combine(AppContext.BaseDirectory, "schema")); + + [Fact] + public void Valid_document_passes() + { + Validator().Validate("manifest", ValidManifest, "manifest.json"); + } + + [Fact] + public void Missing_required_property_is_reported() + { + // "files" is required by the manifest schema. + const string json = """ + {"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z", + "source":{"repo":"WarHub/warhub-catalog"},"counts":{}} + """; + + var ex = Assert.Throws( + () => Validator().Validate("manifest", json, "manifest.json")); + + Assert.Contains("manifest.json", ex.Message, StringComparison.Ordinal); + Assert.Contains("files", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Nested_constraint_violation_reports_instance_location() + { + // sha256 must match ^[0-9a-f]{64}$ — this one is far too short. + const string json = """ + {"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z", + "source":{"repo":"WarHub/warhub-catalog"},"counts":{}, + "files":[{"path":"products.json","kind":"products","bytes":10,"sha256":"abc"}]} + """; + + var ex = Assert.Throws( + () => Validator().Validate("manifest", json, "manifest.json")); + + // The error detail must pinpoint where it failed, not just say "invalid". + Assert.Contains("/files/0/sha256", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Unknown_schema_name_throws() + { + var ex = Assert.Throws( + () => Validator().Validate("no-such-schema", ValidManifest, "whatever.json")); + + Assert.Contains("no-such-schema", ex.Message, StringComparison.Ordinal); + } + + private const string ValidManifest = """ + {"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z", + "source":{"repo":"WarHub/warhub-catalog"},"counts":{"products":2}, + "files":[{"path":"products.json","kind":"products","bytes":10, + "sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]} + """; +} diff --git a/tools/WarHub.Catalog.Publish/SchemaValidator.cs b/tools/WarHub.Catalog.Publish/SchemaValidator.cs index c484c44..6feea5e 100644 --- a/tools/WarHub.Catalog.Publish/SchemaValidator.cs +++ b/tools/WarHub.Catalog.Publish/SchemaValidator.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Nodes; +using System.Text.Json; using Json.Schema; namespace WarHub.Catalog.Publish; @@ -15,10 +15,15 @@ private SchemaValidator() { } public static SchemaValidator LoadFrom(string schemaDir) { + // Build into a registry owned by this instance. The default is a process-wide global + // registry that refuses to re-register an $id, which would make a second LoadFrom in + // the same process throw. + var options = new BuildOptions { SchemaRegistry = new SchemaRegistry() }; + var v = new SchemaValidator(); foreach (string file in Directory.EnumerateFiles(schemaDir, "*.json")) { - v._schemas[Path.GetFileNameWithoutExtension(file)] = JsonSchema.FromText(File.ReadAllText(file)); + v._schemas[Path.GetFileNameWithoutExtension(file)] = JsonSchema.FromText(File.ReadAllText(file), options); } return v; @@ -31,12 +36,12 @@ public void Validate(string schemaName, string json, string relPath) throw new InvalidOperationException($"No schema '{schemaName}' loaded (validating {relPath})."); } - JsonNode? node = JsonNode.Parse(json); - EvaluationResults results = schema.Evaluate(node, new EvaluationOptions { OutputFormat = OutputFormat.List }); + using JsonDocument doc = JsonDocument.Parse(json); + EvaluationResults results = schema.Evaluate(doc.RootElement, new EvaluationOptions { OutputFormat = OutputFormat.List }); if (!results.IsValid) { - IEnumerable errors = results.Details - .Where(d => d.HasErrors) + IEnumerable errors = (results.Details ?? []) + .Where(d => d.Errors is { Count: > 0 }) .SelectMany(d => d.Errors!.Select(e => $"{d.InstanceLocation}: {e.Value}")) .Distinct() .Take(10); diff --git a/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs b/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs index 9a4803c..1ecc6ec 100644 --- a/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs +++ b/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs @@ -10,12 +10,15 @@ internal static partial class HtmlCleaner { private const int DefaultMaxLength = 10000; + // GithubFlavored enables GFM extras (pipe tables, ~~strikethrough~~). It is NOT the same + // as Flavor = MarkdownFlavor.GitHub, which selects the round-trip-faithful GitHub writer + // that preserves raw HTML — that would emit x instead of **x**. private static readonly Converter MarkdownConverter = new(new Config { - UnknownTags = Config.UnknownTagsOption.Drop, GithubFlavored = true, - RemoveComments = true, - SmartHrefHandling = true, + Tags = { Unknown = Config.UnknownTagsOption.Drop }, + Formatting = { RemoveComments = true }, + Links = { SmartHref = true }, }); ///