feat(tests): scaffold Yavsc.Blogs.Tests with BlogsWebServerFixture

Adds the test project that the next commit will use to assert the
blog API endpoints. The fixture inherits from the shared
WebHostFixture (commit "refactor: extract WebHostFixture…") and
wires up only the bits the blog API needs:

* In-memory ApplicationDbContext — BlogSpotService is used as-is,
  no mock. The first tests will exercise the real service against
  an empty table.
* Trivial IFileSystemAuthManager stub (the GET index path never
  reads the file system).
* TestAuthPolicyProvider swapped in, so X-Test-Role satisfies
  [Authorize("BlogScope")].

Two smoke tests verify the fixture boots and the controller
pipeline is reachable. The first behavioural test
(GET /api/v1/blog → 200) lands in the next commit.

Also promotes two xunit.v3.* package versions to the root
Directory.Packages.props so future test projects can share them.
This commit is contained in:
Paul Schneider 2026-07-06 21:49:53 +01:00
commit 8c38bab45a
7 changed files with 223 additions and 2 deletions

View file

@ -21,6 +21,8 @@
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,42 @@
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Smoke tests for the Yavsc.Blogs API host. These tests only assert
/// that the fixture boots and the test HTTP client reaches the
/// controller pipeline — they do not yet exercise the controller
/// surface. The first behavioural test (GET /api/v1/blog returns
/// 200) lands in a follow-up commit.
/// </summary>
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogApiSmokeTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public void Fixture_Binds_At_Least_One_Https_Address()
{
Assert.NotEmpty(_fixture.Addresses);
Assert.Contains(_fixture.Addresses, a => a.StartsWith("https://"));
}
[Fact]
public void Fixture_Exposes_Resolving_ServiceProvider()
{
// If the host built correctly, the service provider should
// be available and resolvable. We don't need to assert a
// specific service here — the GET 200 test will exercise
// the BlogSpotService indirectly.
Assert.NotNull(_fixture.Services);
using var scope = _fixture.Services.CreateScope();
Assert.NotNull(scope.ServiceProvider);
}
}

View file

@ -0,0 +1,104 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Yavsc;
using Yavsc.Models;
using Yavsc.Server.Services;
using Yavsc.Services;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Test host for the Yavsc.Blogs API surface. Specialisation of
/// <see cref="WebHostFixture"/> that wires up only the bits the
/// blog API actually depends on:
///
/// <list type="bullet">
/// <item><description>An in-memory <see cref="ApplicationDbContext"/>
/// (the real one — no mock) so <c>BlogSpotService.Index</c> can run
/// against an empty table and return an empty list.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: the GET index path doesn't read the file system, so any
/// implementation is fine.</description></item>
/// <item><description>The default <see cref="IAuthorizationService"/>
/// from <c>Microsoft.AspNetCore.Authorization</c>.</description></item>
/// <item><description>The test auth bypass from
/// <c>Yavsc.Tests.Shared</c> so the <c>[Authorize("BlogScope")]</c>
/// attribute on <c>BlogApiController</c> is satisfied when the
/// test sends the <c>X-Test-Role</c> header.</description></item>
/// </list>
///
/// No IdentityServer, no SMTP, no static assets — the Org fixture
/// owns all of that and we don't need any of it for blog integration
/// tests.
/// </summary>
public sealed class BlogsWebServerFixture : WebHostFixture
{
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
// Use the real ApplicationDbContext with an in-memory store.
// BlogSpotService reads _context.BlogSpot directly, so any
// attempt to mock it would be wasted work; the real service
// against an empty table returns an empty list, which is
// exactly what the first test wants to assert.
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests"));
// Trivial file-system auth: the GET index path never calls
// into it, but the DI container needs an instance.
builder.Services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager());
// Real BlogSpotService — same instance the production host
// builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager).
builder.Services.AddScoped<BlogSpotService>();
// The BlogApiController is reached through MVC, so register
// MVC + the BlogScope authorization policy.
builder.Services.AddControllers();
builder.Services.AddAuthorization(opt =>
{
// Mirror the production "BlogScope" policy: any
// authenticated user. The TestAuthPolicyProvider we
// register below short-circuits the role check via the
// X-Test-Role header.
opt.AddPolicy("BlogScope", p => p.RequireAssertion(_ => true));
});
// Test auth bypass — swapped in BEFORE the host builds the
// service collection, so it overrides any production
// policy provider registered by AddAuthorization above.
builder.Services.AddSingleton<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
return builder.Build();
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await Task.CompletedTask;
return app;
}
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
/// blog API endpoints exercised by the first tests don't read the
/// file system, so the implementation can be a no-op.</summary>
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(ClaimsPrincipal user, string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
{
}
}
}

View file

@ -0,0 +1,7 @@
<Project>
<!--
Yavsc.Blogs.Tests has no project-specific package versions. All
package versions are declared at the repository root.
-->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>

View file

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Blogs.Tests</RootNamespace>
<UserSecretsId>b1a9d0d6-3f5e-4a07-9f0a-7e4d5b6c1a82</UserSecretsId>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -13,9 +13,8 @@
inherit from the shared base classes. inherit from the shared base classes.
--> -->
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Hosting" /> <PackageReference Include="Microsoft.AspNetCore.Hosting" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -33,6 +33,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostI
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -187,6 +191,30 @@ Global
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.Build.0 = Release|Any CPU {4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.Build.0 = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.ActiveCfg = Release|Any CPU {4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.ActiveCfg = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.Build.0 = Release|Any CPU {4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.Build.0 = Release|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.ActiveCfg = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.Build.0 = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x86.ActiveCfg = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x86.Build.0 = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Release|Any CPU.Build.0 = Release|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x64.ActiveCfg = Release|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x64.Build.0 = Release|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x86.ActiveCfg = Release|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x86.Build.0 = Release|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x64.ActiveCfg = Debug|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x64.Build.0 = Debug|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x86.ActiveCfg = Debug|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x86.Build.0 = Debug|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|Any CPU.Build.0 = Release|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x64.ActiveCfg = Release|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x64.Build.0 = Release|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x86.ActiveCfg = Release|Any CPU
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -205,5 +233,7 @@ Global
{AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE} {AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE}
{EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE} {EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE}
{4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal