From 49fe692ec7f96f4b8b0cda254521f6c28901deda Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:34:28 -0500 Subject: [PATCH 1/3] fix: remove metadata issue --- .../posts/chunked-resumable-file-uploads-dotnet/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Blog/wwwroot/posts/chunked-resumable-file-uploads-dotnet/index.md b/src/Blog/wwwroot/posts/chunked-resumable-file-uploads-dotnet/index.md index 4282d5f..7528618 100644 --- a/src/Blog/wwwroot/posts/chunked-resumable-file-uploads-dotnet/index.md +++ b/src/Blog/wwwroot/posts/chunked-resumable-file-uploads-dotnet/index.md @@ -3,7 +3,7 @@ "title": "Building a Chunked, Resumable File Upload System in .NET and Vanilla JS", "lead": "How to bypass reverse proxy payload limits, eliminate double-storage disk overhead, and build a resumable file uploader.", "isPublished": true, - n"publishedAt": "2026-09-03", + "publishedAt": "2026-09-03", "openGraphImage": "posts/chunked-resumable-file-uploads-dotnet/og-image.png" } ``` From 4a7c8b31c5d8602f4d86e2fa73a0ede7c9d544cc Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:47:34 -0500 Subject: [PATCH 2/3] tests: add test that validates the metadata of all posts --- test/Blog.Tests/Unit/PostMetadataTests.cs | 95 +++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/Blog.Tests/Unit/PostMetadataTests.cs diff --git a/test/Blog.Tests/Unit/PostMetadataTests.cs b/test/Blog.Tests/Unit/PostMetadataTests.cs new file mode 100644 index 0000000..a449215 --- /dev/null +++ b/test/Blog.Tests/Unit/PostMetadataTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +using Markdig; +using Markdig.Syntax; + +namespace Blog.Tests.Unit; + +[TestFixture] +public class PostMetadataTests +{ + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + }; + + private const string MetaFence = "meta"; + + + [Test] + public void PostsDirectory_ShouldContainIndexFiles() + { + var postsDir = GetPostsDirectory(); + var files = Directory.GetFiles(postsDir, "index.md", SearchOption.AllDirectories); + + files.Should().NotBeEmpty("The wwwroot/posts directory should contain blog posts with index.md files."); + } + + [TestCaseSource(nameof(GetPostIndexFiles))] + public async Task IndexMd_Metadata_CanBeProperlyParsedAndRead(string filePath) + { + var markdown = await File.ReadAllTextAsync(filePath); + var document = Markdown.Parse(markdown, MarkdownPipeline); + + var metadataBlock = document + .OfType() + .FirstOrDefault(block => + block.Arguments is not null && + block.Arguments.Contains(MetaFence) + ); + + metadataBlock.Should().NotBeNull($"file '{filePath}' should contain a fenced code block with '{MetaFence}' argument."); + + var jsonContent = metadataBlock.Lines.ToString(); + + jsonContent.Should().NotBeNullOrWhiteSpace($"metadata code block in '{filePath}' should not be empty."); + + Post? post = null; + + Action deserializeAction = () => post = JsonSerializer.Deserialize(jsonContent, JsonSerializerOptions); + + deserializeAction.Should().NotThrow($"JSON metadata in '{filePath}' should be valid JSON matching the Post record schema."); + + post.Should().NotBeNull($"deserialized Post from '{filePath}' should not be null."); + post.Title.Should().NotBeNullOrWhiteSpace($"post in '{filePath}' must have a title."); + post.Lead.Should().NotBeNullOrWhiteSpace($"post in '{filePath}' must have a lead summary."); + post.PublishedAt.Should().NotBe(default, $"post in '{filePath}' must have a valid published date."); + post.OpenGraphImage.Should().NotBeNullOrWhiteSpace($"post in '{filePath}' must have an openGraphImage path."); + } + + private static string GetPostsDirectory() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + + while (current is not null) + { + var candidate = Path.Combine(current.FullName, "src", "Blog", "wwwroot", "posts"); + + if (Directory.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException($"Could not locate 'src/Blog/wwwroot/posts' starting from '{AppContext.BaseDirectory}'."); + } + + private static IEnumerable GetPostIndexFiles() + { + var postsDir = GetPostsDirectory(); + var files = Directory.GetFiles(postsDir, "index.md", SearchOption.AllDirectories); + + foreach (var file in files) + { + var relativePath = Path.GetRelativePath(postsDir, file); + yield return new TestCaseData(file).SetName($"PostMetadata_{relativePath.Replace('\\', '/')}"); + } + } +} \ No newline at end of file From 81cc1e4f6493749772cbe5246d56b3aa76736fa3 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:13:02 -0500 Subject: [PATCH 3/3] tests: fix blog host factory to use built in use kestrel call and dump composite host. --- test/Blog.Tests/EndToEnd/BlogHostFactory.cs | 56 ++++----------------- test/Blog.Tests/EndToEnd/BlogTest.cs | 12 ++--- test/Blog.Tests/EndToEnd/CompositeHost.cs | 28 ----------- 3 files changed, 17 insertions(+), 79 deletions(-) delete mode 100644 test/Blog.Tests/EndToEnd/CompositeHost.cs diff --git a/test/Blog.Tests/EndToEnd/BlogHostFactory.cs b/test/Blog.Tests/EndToEnd/BlogHostFactory.cs index f260fb9..6176643 100644 --- a/test/Blog.Tests/EndToEnd/BlogHostFactory.cs +++ b/test/Blog.Tests/EndToEnd/BlogHostFactory.cs @@ -2,62 +2,28 @@ namespace Blog.Tests.EndToEnd; public class BlogHostFactory : WebApplicationFactory where TProgram : class { - private IHost? _host; - - private void EnsureServer() + public BlogHostFactory() { - if (_host is null) - { - using var _ = CreateDefaultClient(); - } - } - - protected override void Dispose(bool disposing) - { - _host?.Dispose(); + UseKestrel(server => server.Listen(IPAddress.Loopback, 0)); } public string ServerAddress { get { - EnsureServer(); + StartServer(); return ClientOptions.BaseAddress.ToString(); } } - protected override IHost CreateHost(IHostBuilder builder) + protected override void ConfigureWebHost(IWebHostBuilder builder) { - var testHost = builder - .ConfigureWebHost(webHostBuilder => - { - webHostBuilder.ConfigureLogging(config => config.ClearProviders()); - webHostBuilder.ConfigureTestServices(services => - { - var postsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestPosts"); - var postServiceOptions = new FilePostServiceOptions() { PostsDirectory = postsDirectory }; - services.AddSingleton(Options.Create(postServiceOptions)); - }); - }) - .Build(); - - builder.ConfigureWebHost( - webHostBuilder => webHostBuilder.UseKestrel( - o => o.Listen(IPAddress.Loopback, 0) - ) - ); - - _host = builder.Build(); - _host.Start(); - - var server = _host.Services.GetRequiredService(); - var addresses = server.Features.GetRequiredFeature(); - - ClientOptions.BaseAddress = addresses.Addresses - .Select(x => new Uri(x)) - .Last(); - - testHost.Start(); - return testHost; + builder.ConfigureLogging(config => config.ClearProviders()); + builder.ConfigureTestServices(services => + { + var postsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestPosts"); + var postServiceOptions = new FilePostServiceOptions() { PostsDirectory = postsDirectory }; + services.AddSingleton(Options.Create(postServiceOptions)); + }); } } \ No newline at end of file diff --git a/test/Blog.Tests/EndToEnd/BlogTest.cs b/test/Blog.Tests/EndToEnd/BlogTest.cs index 6326d36..c9aca52 100644 --- a/test/Blog.Tests/EndToEnd/BlogTest.cs +++ b/test/Blog.Tests/EndToEnd/BlogTest.cs @@ -3,12 +3,12 @@ namespace Blog.Tests.EndToEnd; [TestFixture] public abstract class BlogTest : PageTest { - private readonly BlogHostFactory _factory = new(); + private static readonly BlogHostFactory Factory = new(); public override BrowserNewContextOptions ContextOptions() { var options = base.ContextOptions(); - options.BaseURL = _factory.ServerAddress; + options.BaseURL = Factory.ServerAddress; return options; } @@ -30,10 +30,10 @@ public abstract class BlogTest : PageTest await Context.Tracing.StopAsync(new() { Path = Path.Combine( - TestContext.CurrentContext.WorkDirectory, - "playwright-traces", - $"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip" - ) + TestContext.CurrentContext.WorkDirectory, + "playwright-traces", + $"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip" + ) }); } } \ No newline at end of file diff --git a/test/Blog.Tests/EndToEnd/CompositeHost.cs b/test/Blog.Tests/EndToEnd/CompositeHost.cs deleted file mode 100644 index 7989fb7..0000000 --- a/test/Blog.Tests/EndToEnd/CompositeHost.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Blog.Tests.EndToEnd; - -public class CompositeHost(IHost testHost, IHost kestrelHost) : IHost -{ - private readonly IHost _testHost = testHost; - private readonly IHost _kestrelHost = kestrelHost; - - public IServiceProvider Services => _testHost.Services; - - public void Dispose() - { - _testHost.Dispose(); - _kestrelHost.Dispose(); - GC.SuppressFinalize(this); - } - - public async Task StartAsync(CancellationToken cancellationToken = default) - { - await _testHost.StartAsync(cancellationToken); - await _kestrelHost.StartAsync(cancellationToken); - } - - public async Task StopAsync(CancellationToken cancellationToken = default) - { - await _testHost.StopAsync(cancellationToken); - await _kestrelHost.StopAsync(cancellationToken); - } -} \ No newline at end of file