Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions .github/workflows/Build-Test-And-Deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions EssentialCSharp.Web.Tests/ChapterDescriptionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidDataException>()
.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<IWebHostEnvironment> 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);
Expand Down
33 changes: 33 additions & 0 deletions EssentialCSharp.Web.Tests/ContentPackageContractTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using EssentialCSharp.Web.Services;
using Microsoft.Extensions.DependencyInjection;

namespace EssentialCSharp.Web.Tests;

/// <summary>
/// Loads the content files the app actually has on disk through the real services. With
/// <c>-p:AccessToNugetFeed=true</c> these are the files from the content package; otherwise they are the
/// placeholders in <c>Placeholders/</c>, which must mirror the package's file shapes. Assertions are
/// structural only so a failure never prints book content into a public log.
/// </summary>
public class ContentPackageContractTests : IntegrationTestBase
{
[Test]
public async Task ChapterDescriptionsLoad()
{
ChapterDescriptionService descriptions = Factory.Inner.Services.GetRequiredService<ChapterDescriptionService>();

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<ISiteMappingService>().SiteMappings).IsNotEmpty();
await Assert.That(services.GetRequiredService<IGuidelinesService>().Guidelines).IsNotEmpty();
}
}
}
24 changes: 16 additions & 8 deletions EssentialCSharp.Web.Tests/FunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,19 @@ 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<Program> factory = CreateFactoryWithBaseUrl("https://essentialcsharp.com");
await AssertPageMetadata(factory, path, expectedCanonical, expectedDescription);
}

[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<Program> factory = CreateFactoryWithBaseUrl("https://dev.essentialcsharp.com/");
await AssertPageMetadata(factory, path, expectedCanonical, expectedDescription);
Expand All @@ -77,7 +77,7 @@ private WebApplicationFactory<Program> CreateFactoryWithBaseUrl(string baseUrl)
Factory.Inner.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
services.PostConfigure<SiteSettings>(settings => settings.BaseUrl = baseUrl)));

private static async Task AssertPageMetadata(WebApplicationFactory<Program> factory, string path, string expectedCanonical, string expectedDescription)
private static async Task AssertPageMetadata(WebApplicationFactory<Program> factory, string path, string expectedCanonical, string? expectedDescription)
{
using HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, path);
Expand All @@ -92,7 +92,15 @@ private static async Task AssertPageMetadata(WebApplicationFactory<Program> 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);
Expand Down
92 changes: 92 additions & 0 deletions EssentialCSharp.Web.Tests/PlaceholderShapeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace EssentialCSharp.Web.Tests;

/// <summary>
/// The placeholders in <c>Placeholders/</c> stand in for the content package in PR builds, so they must have
/// the same JSON shape. When the build uses the real package (<c>-p:AccessToNugetFeed=true</c>) 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.
/// </summary>
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<IWebHostEnvironment>().ContentRootPath;
string placeholderPath = Path.Combine(root, "Placeholders", placeholderName);
string actualPath = Path.Combine(root, contentPath);

Dictionary<string, HashSet<string>> placeholder = ReadShape(placeholderPath);
Dictionary<string, HashSet<string>> actual = ReadShape(actualPath);

List<string> 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<String>").
private static Dictionary<string, HashSet<string>> 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<string, HashSet<string>> 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<string>? 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();
}
2 changes: 2 additions & 0 deletions EssentialCSharp.Web/EssentialCSharp.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<PlaceholderChapterOneHtmlFile Include="$(ProjectDir)/Placeholders/Chapters/01/Pages/*.html" />
<PlaceholderChapterTwoHtmlFile Include="$(ProjectDir)/Placeholders/Chapters/02/Pages/*.html" />
<PlaceholderSitemapJsonFile Include="$(ProjectDir)/Placeholders/sitemap.json" />
<PlaceholderSitemapDescriptionsJsonFile Include="$(ProjectDir)/Placeholders/sitemap.descriptions.json" />
<PlaceholderGuidelinesJsonFile Include="$(ProjectDir)/Placeholders/guidelines.json" />
</ItemGroup>

Expand Down Expand Up @@ -87,6 +88,7 @@
</ItemGroup>
<Target Condition="!$(AccessToNugetFeed)" Name="CopyPlaceholderContent" BeforeTargets="Build">
<Copy SourceFiles="@(PlaceholderSitemapJsonFile)" DestinationFolder="$(ProjectDir)/Chapters/" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PlaceholderSitemapDescriptionsJsonFile)" DestinationFolder="$(ProjectDir)/Chapters/" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PlaceholderGuidelinesJsonFile)" DestinationFolder="$(ProjectDir)/Guidelines/" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PlaceholderChapterOneHtmlFile)" DestinationFolder="$(ProjectDir)/Chapters/01/Pages/" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PlaceholderChapterTwoHtmlFile)" DestinationFolder="$(ProjectDir)/Chapters/02/Pages/" SkipUnchangedFiles="true" />
Expand Down
Loading
Loading