diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index cb441a0b..b888ba0c 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -71,9 +71,6 @@ jobs: uses: actions/setup-dotnet@v6 with: global-json-file: global.json - source-url: https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json - env: - NUGET_AUTH_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }} - name: Set up dependency caching for faster builds uses: actions/cache@v6 @@ -87,11 +84,26 @@ jobs: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} ${{ runner.os }}-nuget- + # Restore against the private content feed so the tests below run against the real content package + # (placeholders only mirror its file shapes). This job only runs on push/workflow_dispatch, never for + # fork PRs. The credentials exist only for the restore and are removed before any repo code builds or runs. + - name: Generate NuGet feed credentials + env: + NUGET_FEED_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }} + run: | + mkdir -p "${HOME}/.nuget/NuGet" + bash scripts/nuget/emit-creds-config.sh > "${HOME}/.nuget/NuGet/NuGet.Config" + chmod 600 "${HOME}/.nuget/NuGet/NuGet.Config" + - name: Restore with dotnet - run: dotnet restore + run: dotnet restore -p:AccessToNugetFeed=true + + - name: Remove NuGet feed credentials + if: always() + run: rm -f "${HOME}/.nuget/NuGet/NuGet.Config" - name: Build with dotnet - run: dotnet build -p:ContinuousIntegrationBuild=True -p:ReleaseDateAttribute=True -p:SkipFrontendBuild=true --configuration Release --no-restore + run: dotnet build -p:AccessToNugetFeed=true -p:ContinuousIntegrationBuild=True -p:ReleaseDateAttribute=True -p:SkipFrontendBuild=true --configuration Release --no-restore - name: Expose GitHub Actions Runtime uses: actions/github-script@v9 @@ -332,6 +344,16 @@ jobs: --resource-group "$RESOURCEGROUP" \ --image "${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }}" + # Requests real pages, not just /health: content-backed services are built lazily on first page + # request, so a bad content file leaves /health green while pages return 500. + - name: Smoke test + env: + CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }} + RESOURCEGROUP: ${{ vars.RESOURCEGROUP }} + run: | + FQDN=$(az containerapp show --name "$CONTAINER_APP_NAME" --resource-group "$RESOURCEGROUP" --query "properties.configuration.ingress.fqdn" -o tsv) + bash scripts/smoke-test.sh "$FQDN" + - name: Logout of Azure CLI if: always() uses: azure/CLI@v3 @@ -433,8 +455,7 @@ jobs: --name "$CONTAINER_APP_NAME" \ --resource-group "$RESOURCEGROUP" \ --query "properties.configuration.ingress.fqdn" -o tsv) - # --retry-all-errors ensures HTTP 5xx (cold-start 503s) also trigger retries - curl --fail --retry 10 --retry-delay 15 --retry-all-errors --max-time 30 "https://$FQDN/health" + bash scripts/smoke-test.sh "$FQDN" - name: Cancel pending deployment workflow runs uses: actions/github-script@v9 diff --git a/EssentialCSharp.Web.Tests/ChapterDescriptionServiceTests.cs b/EssentialCSharp.Web.Tests/ChapterDescriptionServiceTests.cs index 9ba41312..f35eebb7 100644 --- a/EssentialCSharp.Web.Tests/ChapterDescriptionServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ChapterDescriptionServiceTests.cs @@ -39,6 +39,48 @@ public async Task WithoutGeneratedDescriptionsUsesPageHeading() await Assert.That(service.GetDescription(CreateMapping("sample", "Sample Heading"))).Contains("Sample Heading"); } + // Shape emitted by the content package (a single "Key" per entry). + [Test] + public async Task SingularKeyEntriesFromContentPackageAreLoaded() + { + ChapterDescriptionService service = CreateServiceFromJson( + """[{"Key":"example-page","Value":"Singular key description."}]"""); + + await Assert.That(service.Count).IsEqualTo(1); + await Assert.That(service.GetDescription(CreateMapping("EXAMPLE-PAGE", "Examples"))).IsEqualTo("Singular key description."); + } + + [Test] + [Arguments("""{"Key":"a","Value":"b"}""")] + [Arguments("""[null]""")] + [Arguments("""[{"Value":"no key"}]""")] + [Arguments("""[{"Key":"a"}]""")] + [Arguments("""[{"Keys":[""],"Value":"empty key"}]""")] + [Arguments("""[{"Keys":"a","Value":"keys is not an array"}]""")] + public async Task MalformedDescriptionsFailWithAClearMessage(string json) + { + await Assert.That(() => CreateServiceFromJson(json)) + .Throws() + .WithMessageContaining("sitemap.descriptions.json"); + } + + private static ChapterDescriptionService CreateServiceFromJson(string json) + { + string directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(Path.Combine(directory, "Chapters")); + try + { + File.WriteAllText(Path.Combine(directory, "Chapters", "sitemap.descriptions.json"), json); + Mock environment = new(); + environment.SetupGet(e => e.ContentRootPath).Returns(directory); + return new ChapterDescriptionService(environment.Object); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + private static SiteMapping CreateMapping(string key, string heading) => new([key], key, ["Chapters", "01", "Pages", "01.html"], 1, 1, 1, "Chapter One", heading, null, 1); diff --git a/EssentialCSharp.Web.Tests/ContentPackageContractTests.cs b/EssentialCSharp.Web.Tests/ContentPackageContractTests.cs new file mode 100644 index 00000000..5bd7ee7d --- /dev/null +++ b/EssentialCSharp.Web.Tests/ContentPackageContractTests.cs @@ -0,0 +1,33 @@ +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Tests; + +/// +/// Loads the content files the app actually has on disk through the real services. With +/// -p:AccessToNugetFeed=true these are the files from the content package; otherwise they are the +/// placeholders in Placeholders/, which must mirror the package's file shapes. Assertions are +/// structural only so a failure never prints book content into a public log. +/// +public class ContentPackageContractTests : IntegrationTestBase +{ + [Test] + public async Task ChapterDescriptionsLoad() + { + ChapterDescriptionService descriptions = Factory.Inner.Services.GetRequiredService(); + + await Assert.That(descriptions.Count).IsGreaterThan(0); + } + + [Test] + public async Task SiteMapAndGuidelinesLoad() + { + IServiceProvider services = Factory.Inner.Services; + + using (Assert.Multiple()) + { + await Assert.That(services.GetRequiredService().SiteMappings).IsNotEmpty(); + await Assert.That(services.GetRequiredService().Guidelines).IsNotEmpty(); + } + } +} diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs index 32b785f9..9c111d4a 100644 --- a/EssentialCSharp.Web.Tests/FunctionalTests.cs +++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs @@ -55,9 +55,9 @@ public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) [Arguments("/announcements", "https://essentialcsharp.com/announcements", "announcements")] [Arguments("/TermsOfService", "https://essentialcsharp.com/TermsOfService", "terms of service")] [Arguments("/mcp-setup", "https://essentialcsharp.com/mcp-setup", "Model Context Protocol")] - [Arguments("/introducing-c", "https://essentialcsharp.com/introducing-c", "Introducing C#")] - [Arguments("/hello-world", "https://essentialcsharp.com/introducing-c", "Hello")] - public async Task PagesHaveOneCanonicalAndPageSpecificDescriptions(string path, string expectedCanonical, string expectedDescription) + [Arguments("/introducing-c", "https://essentialcsharp.com/introducing-c", null)] + [Arguments("/hello-world", "https://essentialcsharp.com/introducing-c", null)] + public async Task PagesHaveOneCanonicalAndPageSpecificDescriptions(string path, string expectedCanonical, string? expectedDescription) { await using WebApplicationFactory factory = CreateFactoryWithBaseUrl("https://essentialcsharp.com"); await AssertPageMetadata(factory, path, expectedCanonical, expectedDescription); @@ -65,9 +65,9 @@ public async Task PagesHaveOneCanonicalAndPageSpecificDescriptions(string path, [Test] [Arguments("/about", "https://dev.essentialcsharp.com/about", "Learn about Essential C#")] - [Arguments("/introducing-c", "https://dev.essentialcsharp.com/introducing-c", "Introducing C#")] - [Arguments("/hello-world?rid=abc", "https://dev.essentialcsharp.com/introducing-c", "Hello")] - public async Task CanonicalUrlsUseConfiguredDeploymentOrigin(string path, string expectedCanonical, string expectedDescription) + [Arguments("/introducing-c", "https://dev.essentialcsharp.com/introducing-c", null)] + [Arguments("/hello-world?rid=abc", "https://dev.essentialcsharp.com/introducing-c", null)] + public async Task CanonicalUrlsUseConfiguredDeploymentOrigin(string path, string expectedCanonical, string? expectedDescription) { await using WebApplicationFactory factory = CreateFactoryWithBaseUrl("https://dev.essentialcsharp.com/"); await AssertPageMetadata(factory, path, expectedCanonical, expectedDescription); @@ -77,7 +77,7 @@ private WebApplicationFactory CreateFactoryWithBaseUrl(string baseUrl) Factory.Inner.WithWebHostBuilder(builder => builder.ConfigureServices(services => services.PostConfigure(settings => settings.BaseUrl = baseUrl))); - private static async Task AssertPageMetadata(WebApplicationFactory factory, string path, string expectedCanonical, string expectedDescription) + private static async Task AssertPageMetadata(WebApplicationFactory factory, string path, string expectedCanonical, string? expectedDescription) { using HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, path); @@ -92,7 +92,15 @@ private static async Task AssertPageMetadata(WebApplicationFactory fact await Assert.That((head.SelectSingleNode("meta[@property='og:url']") ?? throw new InvalidOperationException("Missing OG URL")).GetAttributeValue("content", "")).IsEqualTo(expectedCanonical); string description = (head.SelectSingleNode("meta[@name='description']") ?? throw new InvalidOperationException("Missing description")).GetAttributeValue("content", ""); - await Assert.That(description).Contains(expectedDescription); + if (expectedDescription is null) + { + await Assert.That(description).IsNotEmpty(); + await Assert.That(description).DoesNotContain("Accelerate your development knowledge"); + } + else + { + await Assert.That(description).Contains(expectedDescription); + } await Assert.That((head.SelectNodes("meta[@name='description']") ?? throw new InvalidOperationException("Missing description")).Count).IsEqualTo(1); await Assert.That((head.SelectSingleNode("meta[@property='og:description']") ?? throw new InvalidOperationException("Missing OG description")).GetAttributeValue("content", "")).IsEqualTo(description); await Assert.That((head.SelectSingleNode("meta[@name='twitter:description']") ?? throw new InvalidOperationException("Missing Twitter description")).GetAttributeValue("content", "")).IsEqualTo(description); diff --git a/EssentialCSharp.Web.Tests/PlaceholderShapeTests.cs b/EssentialCSharp.Web.Tests/PlaceholderShapeTests.cs new file mode 100644 index 00000000..e9f44e95 --- /dev/null +++ b/EssentialCSharp.Web.Tests/PlaceholderShapeTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Tests; + +/// +/// The placeholders in Placeholders/ stand in for the content package in PR builds, so they must have +/// the same JSON shape. When the build uses the real package (-p:AccessToNugetFeed=true) the content +/// files differ from the placeholders and this fails if the package changed shape. Without the package the +/// content files are the placeholders, so it passes trivially. Only property names and value kinds are +/// compared, and failures report only those, so no book content reaches a public log. +/// +public class PlaceholderShapeTests : IntegrationTestBase +{ + [Test] + [Arguments("Chapters/sitemap.json", "sitemap.json")] + [Arguments("Chapters/sitemap.descriptions.json", "sitemap.descriptions.json")] + [Arguments("Guidelines/guidelines.json", "guidelines.json")] + public async Task PlaceholderMatchesContentShape(string contentPath, string placeholderName) + { + string root = Factory.Inner.Services.GetRequiredService().ContentRootPath; + string placeholderPath = Path.Combine(root, "Placeholders", placeholderName); + string actualPath = Path.Combine(root, contentPath); + + Dictionary> placeholder = ReadShape(placeholderPath); + Dictionary> actual = ReadShape(actualPath); + + List differences = []; + foreach (string name in placeholder.Keys.Except(actual.Keys)) + { + differences.Add($"property \"{name}\" is only in the placeholder"); + } + foreach (string name in actual.Keys.Except(placeholder.Keys)) + { + differences.Add($"property \"{name}\" is only in the content package"); + } + foreach (string name in placeholder.Keys.Intersect(actual.Keys)) + { + // Nulls are ignored: optional fields may be null throughout one file and populated in the other. + if (placeholder[name].Count > 0 && actual[name].Count > 0 && !placeholder[name].SetEquals(actual[name])) + { + differences.Add($"property \"{name}\" is {string.Join("/", placeholder[name].Order())} in the placeholder but {string.Join("/", actual[name].Order())} in the content package"); + } + } + + await Assert.That(differences) + .IsEmpty() + .Because($"Placeholders/{placeholderName} must mirror {contentPath} from the content package: update the placeholder to the package's shape. {string.Join("; ", differences)}"); + } + + // Maps each property name used by the entries of a JSON array to the non-null value kinds seen for it + // (arrays are described by their element kinds, e.g. "Array"). + private static Dictionary> ReadShape(string path) + { + using FileStream stream = File.OpenRead(path); + using JsonDocument document = JsonDocument.Parse(stream); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException($"Expected a JSON array in {Path.GetFileName(path)}"); + } + + Dictionary> shape = []; + foreach (JsonElement entry in document.RootElement.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException($"Expected every entry in {Path.GetFileName(path)} to be an object"); + } + + foreach (JsonProperty property in entry.EnumerateObject()) + { + if (!shape.TryGetValue(property.Name, out HashSet? kinds)) + { + shape[property.Name] = kinds = []; + } + + if (property.Value.ValueKind != JsonValueKind.Null) + { + kinds.Add(Describe(property.Value)); + } + } + } + + return shape; + } + + private static string Describe(JsonElement value) => + value.ValueKind == JsonValueKind.Array + ? $"Array<{string.Join("|", value.EnumerateArray().Select(Describe).Distinct().Order())}>" + : value.ValueKind.ToString(); +} diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index be1eb33d..9ff05a0c 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -26,6 +26,7 @@ + @@ -87,6 +88,7 @@ + diff --git a/EssentialCSharp.Web/Placeholders/sitemap.descriptions.json b/EssentialCSharp.Web/Placeholders/sitemap.descriptions.json new file mode 100644 index 00000000..914e3273 --- /dev/null +++ b/EssentialCSharp.Web/Placeholders/sitemap.descriptions.json @@ -0,0 +1,158 @@ +[ + { + "Key": "introducing-c", + "Value": "Placeholder description for Introducing C# in Essential C#." + }, + { + "Key": "hello-world", + "Value": "Placeholder description for Hello, World in Essential C#." + }, + { + "Key": "creating-editing-compiling-and-running-c-source-code", + "Value": "Placeholder description for Creating, Editing, Compiling, and Running C# Source Code in Essential C#." + }, + { + "Key": "with-dotnet-cli", + "Value": "Placeholder description for With Dotnet CLI in Essential C#." + }, + { + "Key": "with-visual-studio-2022-windows-or-mac", + "Value": "Placeholder description for With Visual Studio 2022 (Windows or Mac) in Essential C#." + }, + { + "Key": "understanding-a-project", + "Value": "Placeholder description for Understanding a Project in Essential C#." + }, + { + "Key": "compilation-and-execution", + "Value": "Placeholder description for Compilation and Execution in Essential C#." + }, + { + "Key": "essential-c-source-code", + "Value": "Placeholder description for Essential C# Source Code in Essential C#." + }, + { + "Key": "c-syntax-fundamentals", + "Value": "Placeholder description for C# Syntax Fundamentals in Essential C#." + }, + { + "Key": "statements-and-statement-delimiters", + "Value": "Placeholder description for Statements and Statement Delimiters in Essential C#." + }, + { + "Key": "introducing-a-class-and-a-method", + "Value": "Placeholder description for Introducing a Class and a Method in Essential C#." + }, + { + "Key": "c-keywords", + "Value": "Placeholder description for C# Keywords in Essential C#." + }, + { + "Key": "identifiers", + "Value": "Placeholder description for Identifiers in Essential C#." + }, + { + "Key": "type-definition", + "Value": "Placeholder description for Type Definition in Essential C#." + }, + { + "Key": "main-method", + "Value": "Placeholder description for Main Method in Essential C#." + }, + { + "Key": "whitespace", + "Value": "Placeholder description for Whitespace in Essential C#." + }, + { + "Key": "working-with-variables", + "Value": "Placeholder description for Working with Variables in Essential C#." + }, + { + "Key": "introducing-data-types", + "Value": "Placeholder description for Introducing Data Types in Essential C#." + }, + { + "Key": "declaring-a-variable", + "Value": "Placeholder description for Declaring a Variable in Essential C#." + }, + { + "Key": "assigning-a-variable", + "Value": "Placeholder description for Assigning a Variable in Essential C#." + }, + { + "Key": "using-a-variable", + "Value": "Placeholder description for Using a Variable in Essential C#." + }, + { + "Key": "console-input-and-output", + "Value": "Placeholder description for Console Input and Output in Essential C#." + }, + { + "Key": "getting-input-from-the-console", + "Value": "Placeholder description for Getting Input from the Console in Essential C#." + }, + { + "Key": "writing-output-to-the-console", + "Value": "Placeholder description for Writing Output to the Console in Essential C#." + }, + { + "Key": "comments", + "Value": "Placeholder description for Comments in Essential C#." + }, + { + "Key": "debugging", + "Value": "Placeholder description for Debugging in Essential C#." + }, + { + "Key": "managed-execution-and-the-common-language-infrastructure", + "Value": "Placeholder description for Managed Execution and the Common Language Infrastructure in Essential C#." + }, + { + "Key": "common-intermediate-language-and-ildasm", + "Value": "Placeholder description for Common Intermediate Language and Ildasm in Essential C#." + }, + { + "Key": "multiple-net-frameworks", + "Value": "Placeholder description for Multiple .NET Frameworks in Essential C#." + }, + { + "Key": "application-programming-interface", + "Value": "Placeholder description for Application Programming Interface in Essential C#." + }, + { + "Key": "c-and-net-versioning", + "Value": "Placeholder description for C# and .NET Versioning in Essential C#." + }, + { + "Key": "chapter-1-summary", + "Value": "Placeholder description for Chapter 1: Summary in Essential C#." + }, + { + "Key": "data-types", + "Value": "Placeholder description for Data Types in Essential C#." + }, + { + "Key": "type-name-forms", + "Value": "Placeholder description for Type Name Forms in Essential C#." + }, + { + "Key": "fundamental-numeric-types", + "Value": "Placeholder description for Fundamental Numeric Types in Essential C#." + }, + { + "Key": "integer-types", + "Value": "Placeholder description for Integer Types in Essential C#." + }, + { + "Key": "floating-point-types-float-double", + "Value": "Placeholder description for Floating-Point Types (float, double) in Essential C#." + }, + { + "Key": "decimal-type", + "Value": "Placeholder description for Decimal Type in Essential C#." + }, + { + "Key": "literal-values", + "Value": "Placeholder description for Literal Values in Essential C#." + } +] diff --git a/EssentialCSharp.Web/Placeholders/sitemap.json b/EssentialCSharp.Web/Placeholders/sitemap.json index c43e0b62..b4610023 100644 --- a/EssentialCSharp.Web/Placeholders/sitemap.json +++ b/EssentialCSharp.Web/Placeholders/sitemap.json @@ -3,6 +3,7 @@ "Keys": [ "introducing-c" ], + "PrimaryKey": "introducing-c", "PagePath": [ "Chapters", "01", @@ -17,12 +18,14 @@ "ContentHash": "74B5", "AnchorId": "overview", "IndentLevel": 0, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "hello-world" ], + "PrimaryKey": "hello-world", "PagePath": [ "Chapters", "01", @@ -37,12 +40,14 @@ "ContentHash": "C68C", "AnchorId": "hello-world", "IndentLevel": 1, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "creating-editing-compiling-and-running-c-source-code" ], + "PrimaryKey": "creating-editing-compiling-and-running-c-source-code", "PagePath": [ "Chapters", "01", @@ -57,12 +62,14 @@ "ContentHash": "8185", "AnchorId": "creating-editing-compiling-and-running-c-source-code", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "with-dotnet-cli" ], + "PrimaryKey": "with-dotnet-cli", "PagePath": [ "Chapters", "01", @@ -77,12 +84,14 @@ "ContentHash": "E879", "AnchorId": "with-dotnet-cli", "IndentLevel": 3, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "with-visual-studio-2022-windows-or-mac" ], + "PrimaryKey": "with-visual-studio-2022-windows-or-mac", "PagePath": [ "Chapters", "01", @@ -97,12 +106,14 @@ "ContentHash": "ECF8", "AnchorId": "with-visual-studio-2022-windows-or-mac", "IndentLevel": 3, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "understanding-a-project" ], + "PrimaryKey": "understanding-a-project", "PagePath": [ "Chapters", "01", @@ -117,12 +128,14 @@ "ContentHash": "E9E5", "AnchorId": "understanding-a-project", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "compilation-and-execution" ], + "PrimaryKey": "compilation-and-execution", "PagePath": [ "Chapters", "01", @@ -137,12 +150,14 @@ "ContentHash": "A4C6", "AnchorId": "compilation-and-execution", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "essential-c-source-code" ], + "PrimaryKey": "essential-c-source-code", "PagePath": [ "Chapters", "01", @@ -157,12 +172,14 @@ "ContentHash": "527F", "AnchorId": "essential-c-source-code", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "c-syntax-fundamentals" ], + "PrimaryKey": "c-syntax-fundamentals", "PagePath": [ "Chapters", "01", @@ -177,12 +194,14 @@ "ContentHash": "983A", "AnchorId": "c-syntax-fundamentals", "IndentLevel": 1, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "statements-and-statement-delimiters" ], + "PrimaryKey": "statements-and-statement-delimiters", "PagePath": [ "Chapters", "01", @@ -197,12 +216,14 @@ "ContentHash": "A624", "AnchorId": "statements-and-statement-delimiters", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "introducing-a-class-and-a-method" ], + "PrimaryKey": "introducing-a-class-and-a-method", "PagePath": [ "Chapters", "01", @@ -217,12 +238,14 @@ "ContentHash": "F9FF", "AnchorId": "introducing-a-class-and-a-method", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "c-keywords" ], + "PrimaryKey": "c-keywords", "PagePath": [ "Chapters", "01", @@ -237,12 +260,14 @@ "ContentHash": "1E24", "AnchorId": "c-keywords", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "identifiers" ], + "PrimaryKey": "identifiers", "PagePath": [ "Chapters", "01", @@ -257,12 +282,14 @@ "ContentHash": "8156", "AnchorId": "identifiers", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "type-definition" ], + "PrimaryKey": "type-definition", "PagePath": [ "Chapters", "01", @@ -277,12 +304,14 @@ "ContentHash": "FA33", "AnchorId": "type-definition", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "main-method" ], + "PrimaryKey": "main-method", "PagePath": [ "Chapters", "01", @@ -297,12 +326,14 @@ "ContentHash": "52B1", "AnchorId": "main-method", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "whitespace" ], + "PrimaryKey": "whitespace", "PagePath": [ "Chapters", "01", @@ -317,12 +348,14 @@ "ContentHash": "0151", "AnchorId": "whitespace", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "working-with-variables" ], + "PrimaryKey": "working-with-variables", "PagePath": [ "Chapters", "01", @@ -337,12 +370,14 @@ "ContentHash": "134A", "AnchorId": "working-with-variables", "IndentLevel": 1, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "introducing-data-types" ], + "PrimaryKey": "introducing-data-types", "PagePath": [ "Chapters", "01", @@ -357,12 +392,14 @@ "ContentHash": "EC83", "AnchorId": "introducing-data-types", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "declaring-a-variable" ], + "PrimaryKey": "declaring-a-variable", "PagePath": [ "Chapters", "01", @@ -377,12 +414,14 @@ "ContentHash": "98A8", "AnchorId": "declaring-a-variable", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "assigning-a-variable" ], + "PrimaryKey": "assigning-a-variable", "PagePath": [ "Chapters", "01", @@ -397,12 +436,14 @@ "ContentHash": "0143", "AnchorId": "assigning-a-variable", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "using-a-variable" ], + "PrimaryKey": "using-a-variable", "PagePath": [ "Chapters", "01", @@ -417,12 +458,14 @@ "ContentHash": "8C38", "AnchorId": "using-a-variable", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "console-input-and-output" ], + "PrimaryKey": "console-input-and-output", "PagePath": [ "Chapters", "01", @@ -437,12 +480,14 @@ "ContentHash": "C93C", "AnchorId": "console-input-and-output", "IndentLevel": 1, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "getting-input-from-the-console" ], + "PrimaryKey": "getting-input-from-the-console", "PagePath": [ "Chapters", "01", @@ -457,12 +502,14 @@ "ContentHash": "A17C", "AnchorId": "getting-input-from-the-console", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "writing-output-to-the-console" ], + "PrimaryKey": "writing-output-to-the-console", "PagePath": [ "Chapters", "01", @@ -477,12 +524,14 @@ "ContentHash": "A935", "AnchorId": "writing-output-to-the-console", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "comments" ], + "PrimaryKey": "comments", "PagePath": [ "Chapters", "01", @@ -497,12 +546,14 @@ "ContentHash": "F51C", "AnchorId": "comments", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "debugging" ], + "PrimaryKey": "debugging", "PagePath": [ "Chapters", "01", @@ -517,12 +568,14 @@ "ContentHash": "B9AA", "AnchorId": "debugging", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "managed-execution-and-the-common-language-infrastructure" ], + "PrimaryKey": "managed-execution-and-the-common-language-infrastructure", "PagePath": [ "Chapters", "01", @@ -537,12 +590,14 @@ "ContentHash": "0B6A", "AnchorId": "managed-execution-and-the-common-language-infrastructure", "IndentLevel": 1, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "common-intermediate-language-and-ildasm" ], + "PrimaryKey": "common-intermediate-language-and-ildasm", "PagePath": [ "Chapters", "01", @@ -557,12 +612,14 @@ "ContentHash": "2200", "AnchorId": "common-intermediate-language-and-ildasm", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "multiple-net-frameworks" ], + "PrimaryKey": "multiple-net-frameworks", "PagePath": [ "Chapters", "01", @@ -577,12 +634,14 @@ "ContentHash": "9CE9", "AnchorId": "multiple-net-frameworks", "IndentLevel": 1, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "application-programming-interface" ], + "PrimaryKey": "application-programming-interface", "PagePath": [ "Chapters", "01", @@ -597,12 +656,14 @@ "ContentHash": "941F", "AnchorId": "application-programming-interface", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "c-and-net-versioning" ], + "PrimaryKey": "c-and-net-versioning", "PagePath": [ "Chapters", "01", @@ -617,12 +678,14 @@ "ContentHash": "3503", "AnchorId": "c-and-net-versioning", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "chapter-1-summary" ], + "PrimaryKey": "chapter-1-summary", "PagePath": [ "Chapters", "01", @@ -637,12 +700,14 @@ "ContentHash": "DE96", "AnchorId": "chapter-1-summary", "IndentLevel": 1, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "data-types" ], + "PrimaryKey": "data-types", "PagePath": [ "Chapters", "02", @@ -657,12 +722,14 @@ "ContentHash": "61DD", "AnchorId": "overview", "IndentLevel": 0, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "type-name-forms" ], + "PrimaryKey": "type-name-forms", "PagePath": [ "Chapters", "02", @@ -677,12 +744,14 @@ "ContentHash": "BAB3", "AnchorId": "type-name-forms", "IndentLevel": 1, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "fundamental-numeric-types" ], + "PrimaryKey": "fundamental-numeric-types", "PagePath": [ "Chapters", "02", @@ -697,12 +766,14 @@ "ContentHash": "7EC2", "AnchorId": "fundamental-numeric-types", "IndentLevel": 1, - "IncludeInSitemapXml": true + "IncludeInSitemapXml": true, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "integer-types" ], + "PrimaryKey": "integer-types", "PagePath": [ "Chapters", "02", @@ -717,12 +788,14 @@ "ContentHash": "FB7E", "AnchorId": "integer-types", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "floating-point-types-float-double" ], + "PrimaryKey": "floating-point-types-float-double", "PagePath": [ "Chapters", "02", @@ -737,12 +810,14 @@ "ContentHash": "95AB", "AnchorId": "floating-point-types-float-double", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "decimal-type" ], + "PrimaryKey": "decimal-type", "PagePath": [ "Chapters", "02", @@ -757,12 +832,14 @@ "ContentHash": "7C20", "AnchorId": "decimal-type", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" }, { "Keys": [ "literal-values" ], + "PrimaryKey": "literal-values", "PagePath": [ "Chapters", "02", @@ -777,6 +854,7 @@ "ContentHash": "BFB8", "AnchorId": "literal-values", "IndentLevel": 2, - "IncludeInSitemapXml": false + "IncludeInSitemapXml": false, + "LastModified": "2025-07-11T20:01:19.0699112Z" } ] \ No newline at end of file diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 1cb4de2a..95a8cbbe 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -615,6 +615,11 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( app.MapFallbackToController("Index", "Home"); + // Construct content-backed singletons now so an unreadable content file fails startup (the new + // revision never takes traffic) instead of surfacing as a 500 on the first page request. + app.Services.GetRequiredService(); + app.Services.GetRequiredService(); + // Validate sitemap data at startup — logs errors but allows startup to continue var siteMappingService = app.Services.GetRequiredService(); var logger = app.Services.GetRequiredService>(); diff --git a/EssentialCSharp.Web/Services/ChapterDescriptionService.cs b/EssentialCSharp.Web/Services/ChapterDescriptionService.cs index f4cc60bb..f2a24434 100644 --- a/EssentialCSharp.Web/Services/ChapterDescriptionService.cs +++ b/EssentialCSharp.Web/Services/ChapterDescriptionService.cs @@ -6,6 +6,9 @@ public sealed class ChapterDescriptionService { private readonly Dictionary _Descriptions; + /// Number of descriptions loaded from sitemap.descriptions.json (0 when the file is absent). + public int Count => _Descriptions.Count; + public ChapterDescriptionService(IWebHostEnvironment environment) { string path = Path.Combine(environment.ContentRootPath, "Chapters", "sitemap.descriptions.json"); @@ -16,19 +19,71 @@ public ChapterDescriptionService(IWebHostEnvironment environment) } using FileStream stream = File.OpenRead(path); - List entries = JsonSerializer.Deserialize>(stream) - ?? throw new InvalidDataException($"No chapter descriptions found in {path}"); + using JsonDocument document = JsonDocument.Parse(stream); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException($"Expected a JSON array of chapter descriptions in {path}"); + } + Dictionary descriptions = new(StringComparer.OrdinalIgnoreCase); - foreach (SiteMappingDescription entry in entries) + int index = 0; + foreach (JsonElement entry in document.RootElement.EnumerateArray()) { - foreach (string key in entry.Keys) + string value = ReadValue(entry, path, index); + foreach (string key in ReadKeys(entry, path, index)) { - descriptions.Add(key, entry.Value); + descriptions.Add(key, value); } + index++; } _Descriptions = descriptions; } + // The content package has emitted { "Key": "..." } while the shared SiteMappingDescription type + // declares "Keys". Accept both so a mismatch between the two cannot take the site down. + private static List ReadKeys(JsonElement entry, string path, int index) + { + if (entry.ValueKind == JsonValueKind.Object) + { + if (entry.TryGetProperty("Keys", out JsonElement keys) && keys.ValueKind == JsonValueKind.Array) + { + List result = []; + foreach (JsonElement key in keys.EnumerateArray()) + { + string? text = key.ValueKind == JsonValueKind.String ? key.GetString() : null; + result.Add(!string.IsNullOrWhiteSpace(text) ? text : throw InvalidEntry(path, index, "contains an empty key")); + } + return result; + } + + if (entry.TryGetProperty("Key", out JsonElement singleKey) && singleKey.ValueKind == JsonValueKind.String) + { + string? text = singleKey.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + { + return [text]; + } + } + } + + throw InvalidEntry(path, index, "has neither a \"Keys\" array nor a \"Key\" string"); + } + + private static string ReadValue(JsonElement entry, string path, int index) + { + if (entry.ValueKind == JsonValueKind.Object + && entry.TryGetProperty("Value", out JsonElement value) && value.ValueKind == JsonValueKind.String + && value.GetString() is string text) + { + return text; + } + + throw InvalidEntry(path, index, "has no \"Value\" string"); + } + + private static InvalidDataException InvalidEntry(string path, int index, string problem) => + new($"Chapter description entry {index} in {path} {problem}"); + public string GetDescription(SiteMapping mapping) { foreach (string key in mapping.Keys) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 00000000..fe75d4d9 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Requests the health endpoint plus real pages. Usage: smoke-test.sh +set -euo pipefail + +fqdn="${1:?usage: smoke-test.sh }" + +# /health stays green when content-backed services are broken, so also request pages that need them. +paths=(/health / /hello-world /guidelines) + +for path in "${paths[@]}"; do + echo "GET https://${fqdn}${path}" + # --retry-all-errors ensures HTTP 5xx (cold-start 503s) also trigger retries; -L follows page redirects + curl --fail --silent --show-error --location --retry 10 --retry-delay 15 --retry-all-errors --max-time 30 \ + --output /dev/null --write-out ' -> %{http_code}\n' "https://${fqdn}${path}" +done