Projet de tests Yavsc.Server.Tests : BlogSpotService.Details
Ajoute un projet de tests service-level pour BlogSpotService.Details (owner/public/sponsor/Administrator + ACL scrub + IsPublished + auteurs de commentaires). Le fixture supporte SQLite (défaut) et PostgreSQL via YAVSC_SERVER_TEST_DB_PROVIDER=npgsql, sur le modèle de ApiWebServerFixture (migrations Yavsc.Org, base dédiée yavscServerTestDb, reset par purge FK-ordonnée). La connexion admin Npgsql se lit depuis YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION (aucun mot de passe en source). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
5553f2f290
commit
ed3dc733d3
4 changed files with 721 additions and 0 deletions
222
src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
Normal file
222
src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Exceptions;
|
||||
using Yavsc.Server.Tests.Fixtures;
|
||||
|
||||
namespace Yavsc.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Service-level tests for <see cref="BlogSpotService.Details"/>.
|
||||
///
|
||||
/// <para><see cref="BlogSpotService.Details"/> loads a
|
||||
/// <see cref="BlogPost"/> (with Author, Tags, Comments, ACL and
|
||||
/// Publication navigations), runs a resource-based
|
||||
/// <c>ReadPermission</c> authorization against it, scrubs the ACL for
|
||||
/// non-owners, derives <see cref="BlogPost.IsPublished"/> from the
|
||||
/// loaded <c>Publication</c> navigation, and eagerly loads each
|
||||
/// comment's <c>Author</c>. These tests pin each of those behaviours
|
||||
/// at the service layer against the real
|
||||
/// <see cref="Yavsc.Services.PermissionHandler"/> (the public/owner/
|
||||
/// sponsor/Administrator disjunction) and a real SQLite store — no
|
||||
/// mocking.</para>
|
||||
///
|
||||
/// <para>Seeding and the service call resolve through separate DI
|
||||
/// scopes so the DbContext that writes the seed rows is disposed
|
||||
/// before the one <see cref="BlogSpotService.Details"/> reads through,
|
||||
/// mirroring the per-request DbContext lifetime in production.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Server")]
|
||||
public sealed class BlogSpotServiceDetailsTests : IClassFixture<ServerServicesFixture>
|
||||
{
|
||||
private readonly ServerServicesFixture _fixture;
|
||||
|
||||
public BlogSpotServiceDetailsTests(ServerServicesFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>An authenticated principal whose <c>sub</c> claim is
|
||||
/// <paramref name="userId"/>. <see cref="Yavsc.Server.Helpers.UserHelpers.GetUserId"/>
|
||||
/// reads <c>sub</c> first, which is what the JwtBearer middleware
|
||||
/// in production preserves (MapInboundClaims = false), and what
|
||||
/// <see cref="Yavsc.Services.PermissionHandler"/> compares against
|
||||
/// <see cref="BlogPost.AuthorId"/>.</summary>
|
||||
private static ClaimsPrincipal Authenticated(string userId,
|
||||
params Claim[] extra)
|
||||
{
|
||||
var claims = new List<Claim> { new("sub", userId) };
|
||||
claims.AddRange(extra);
|
||||
return new ClaimsPrincipal(
|
||||
new ClaimsIdentity(claims, authenticationType: "Bearer"));
|
||||
}
|
||||
|
||||
/// <summary>An unauthenticated principal: a
|
||||
/// <see cref="ClaimsIdentity"/> with no authentication type has
|
||||
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false. The
|
||||
/// service still runs authorization — a published post's
|
||||
/// <c>ReadPermission</c> succeeds purely on IsPublic, with no
|
||||
/// identity required.</summary>
|
||||
private static ClaimsPrincipal Anonymous()
|
||||
=> new(new ClaimsIdentity());
|
||||
|
||||
/// <summary>The Microsoft-format role claim
|
||||
/// <see cref="Yavsc.Server.Helpers.UserHelpers.IsInMsRole"/>
|
||||
/// reads when checking the "Administrator" bypass in
|
||||
/// <see cref="Yavsc.Services.PermissionHandler"/>.</summary>
|
||||
private static Claim AdminRole()
|
||||
=> new("http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
|
||||
"Administrator");
|
||||
|
||||
[Fact]
|
||||
public async Task Details_returns_the_post_for_its_owner_and_keeps_the_acl()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var circleId = _fixture.SeedCircle("alice", "alice-private");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Owner reads own draft");
|
||||
_fixture.SeedAcl(postId, circleId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), postId));
|
||||
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.Equal("Owner reads own draft", blog.Title);
|
||||
// The owner is the one viewer ScrubAclForViewer keeps the ACL
|
||||
// for — a non-owner would see an empty list.
|
||||
Assert.Single(blog.ACL!);
|
||||
// No BlogSpotPublication row was seeded, so IsPublished is
|
||||
// derived from the (null) Publication navigation as false.
|
||||
Assert.False(blog.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_serves_IsPublished_true_when_a_publication_row_exists()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Published post");
|
||||
_fixture.Publish(postId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), postId));
|
||||
|
||||
// IsPublished is a wire-only value the service derives from
|
||||
// the loaded Publication navigation (Details Includes it).
|
||||
Assert.True(blog.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_lets_an_anonymous_viewer_read_a_published_post_and_scrubs_its_acl()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var circleId = _fixture.SeedCircle("alice", "alice-private");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Public post");
|
||||
// The ACL is real, but a non-owner viewer must not see it.
|
||||
_fixture.SeedAcl(postId, circleId);
|
||||
_fixture.Publish(postId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Anonymous(), postId));
|
||||
|
||||
// ReadPermission succeeds purely on IsPublic (the publication
|
||||
// row) — no identity needed.
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.True(blog.IsPublished);
|
||||
// ScrubAclForViewer: the anonymous viewer is not the owner, so
|
||||
// the ACL is replaced with an empty list rather than leaked.
|
||||
Assert.Empty(blog.ACL!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_denies_a_draft_to_a_non_owner_who_is_not_a_sponsor()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Alice's draft");
|
||||
|
||||
// No publication row (not public), bob is not the owner, bob
|
||||
// is not a member of any circle alice owns, bob is not an
|
||||
// Administrator — every branch of ReadPermission fails.
|
||||
await Assert.ThrowsAsync<AuthorizationFailureException>(() =>
|
||||
_fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("bob"), postId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_lets_a_sponsor_read_a_draft_and_scrubs_its_acl()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
// Sponsorship = bob is a member of a circle alice owns.
|
||||
var circleId = _fixture.SeedCircle("alice", "alice-friends");
|
||||
_fixture.SeedCircleMember(circleId, "bob");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Draft for friends");
|
||||
_fixture.SeedAcl(postId, circleId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("bob"), postId));
|
||||
|
||||
// ReadPermission succeeds on the IsSponsor branch.
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.False(blog.IsPublished);
|
||||
// bob is not the owner, so the ACL is scrubbed even though he
|
||||
// was granted read access via the circle.
|
||||
Assert.Empty(blog.ACL!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_lets_an_administrator_read_a_draft()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("root");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Draft, admin view");
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("root", AdminRole()), postId));
|
||||
|
||||
// The Administrator bypass in PermissionHandler short-circuits
|
||||
// the public/owner/sponsor checks.
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.False(blog.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_loads_each_comment_author()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Post with comments");
|
||||
_fixture.SeedComment(postId, "bob", "Nice post!");
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), postId));
|
||||
|
||||
var comment = Assert.Single(blog.Comments);
|
||||
// Details explicitly loads c.Author from _context.Users for
|
||||
// every comment; the navigation must not be left null.
|
||||
Assert.NotNull(comment.Author);
|
||||
Assert.Equal("bob", comment.Author.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_throws_for_a_missing_post()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
|
||||
// The query uses SingleAsync, which throws
|
||||
// InvalidOperationException when no row matches — the
|
||||
// `if (blog == null) return null` guard below it is unreachable.
|
||||
// Pin that current behaviour so a future switch to
|
||||
// SingleOrDefaultAsync (which would make the guard live) is a
|
||||
// deliberate, reviewed change rather than a silent one.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), 424242)));
|
||||
}
|
||||
}
|
||||
452
src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
Normal file
452
src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Access;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Server.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Service-level test host for <see cref="BlogSpotService"/> and the
|
||||
/// other Yavsc.Server services that take an
|
||||
/// <see cref="ApplicationDbContext"/> + <see cref="IAuthorizationService"/>.
|
||||
///
|
||||
/// <para>Unlike <c>BlogsWebServerFixture</c> (in Yavsc.Blogs.Tests),
|
||||
/// this fixture does <b>not</b> spin up Kestrel, JWT bearer, or MVC
|
||||
/// controllers. <see cref="BlogSpotService.Details"/> takes the
|
||||
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> as a parameter,
|
||||
/// so the caller controls identity directly — there is no HTTP/auth
|
||||
/// middleware in the path under test. Building only the DI graph the
|
||||
/// service actually resolves keeps these tests fast and isolates the
|
||||
/// service layer from the blog API's transport concerns, which
|
||||
/// Yavsc.Blogs.Tests already covers end-to-end.</para>
|
||||
///
|
||||
/// <para><b>Database provider.</b> Mirrors
|
||||
/// <c>ApiWebServerFixture</c>: SQLite <c>:memory:</c> by default, or
|
||||
/// real PostgreSQL when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c> is
|
||||
/// set. Production runs on Npgsql, so the Npgsql path — which applies
|
||||
/// the real <c>Yavsc.Org</c> migrations against a dedicated test
|
||||
/// database — is the one that catches provider-specific bugs (FK
|
||||
/// enforcement, type mappings, migration drift). SQLite stays the
|
||||
/// default so the suite runs out-of-the-box with no external server.</para>
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>SQLite: a named <c>:memory:</c> database
|
||||
/// (Cache=Shared) backed by a single connection held open for the
|
||||
/// fixture's lifetime, schema via <c>EnsureCreated</c>. Reset is
|
||||
/// <c>EnsureDeleted</c> + <c>EnsureCreated</c>.</description></item>
|
||||
/// <item><description>Npgsql: a dedicated database
|
||||
/// <c>yavscServerTestDb</c> (created from the admin connection if it
|
||||
/// does not exist), schema via <c>Migrate</c> using the
|
||||
/// <c>Yavsc.Org</c> migrations assembly. Reset purges the blog-graph
|
||||
/// tables in FK order — children before parents — so no DELETE
|
||||
/// violates a constraint, instead of dropping the database (which
|
||||
/// would force a full re-migration per test).</description></item>
|
||||
/// <item><description>The real <see cref="BlogSpotService"/> and the
|
||||
/// real <see cref="PermissionHandler"/>: <c>Details</c> calls
|
||||
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new
|
||||
/// ReadPermission())</c>, and <see cref="PermissionHandler"/> answers
|
||||
/// that requirement with the public/owner/sponsor/Administrator
|
||||
/// disjunction that the tests below pin. No mocking.</description></item>
|
||||
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
|
||||
/// stub: <c>Details</c> never touches the file system, but the DI
|
||||
/// container needs an instance to construct the service.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Marked <see cref="CollectionDefinitionAttribute"/> so a single
|
||||
/// fixture instance (one store, one DI container) is shared across
|
||||
/// every <c>[Collection("Yavsc Server")]</c> test class. Each test
|
||||
/// resets the store via <see cref="ResetDatabase"/>.</para>
|
||||
/// </summary>
|
||||
[CollectionDefinition("Yavsc Server")]
|
||||
public sealed class ServerServicesFixture : IDisposable
|
||||
{
|
||||
private const string DbProviderEnvVar = "YAVSC_SERVER_TEST_DB_PROVIDER";
|
||||
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION";
|
||||
private const string DedicatedNpgsqlDatabaseName = "yavscServerTestDb";
|
||||
|
||||
private static readonly object _npgsqlLock = new();
|
||||
private static string? _sharedNpgsqlConnectionString;
|
||||
|
||||
private readonly SqliteConnection? _sqliteConnection;
|
||||
private readonly ServiceProvider _services;
|
||||
|
||||
public IServiceProvider Services => _services;
|
||||
|
||||
public ServerServicesFixture()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
if (UseNpgsqlProvider())
|
||||
{
|
||||
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
|
||||
services.AddDbContext<ApplicationDbContext>(opt =>
|
||||
opt.UseNpgsql(npgsqlConnectionString,
|
||||
x => x.MigrationsAssembly("Yavsc.Org")));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Mode=Memory + Cache=Shared: a named in-memory database
|
||||
// every connection string referencing "YavscServerTests"
|
||||
// resolves to the same backing store, as long as at least
|
||||
// one connection stays open against it. _sqliteConnection
|
||||
// is held for the fixture's lifetime (disposed in Dispose),
|
||||
// so the store survives across scoped DbContexts per test.
|
||||
_sqliteConnection = new SqliteConnection(
|
||||
"Data Source=YavscServerTests;Mode=Memory;Cache=Shared");
|
||||
_sqliteConnection.Open();
|
||||
|
||||
services.AddDbContext<ApplicationDbContext>(opt =>
|
||||
// UseSqlite(DbConnection) keeps _sqliteConnection alive
|
||||
// for the DbContext's lifetime instead of letting EF
|
||||
// open and close its own — without this each DbContext
|
||||
// would get a fresh connection pointing at an empty
|
||||
// store.
|
||||
opt.UseSqlite(_sqliteConnection));
|
||||
}
|
||||
|
||||
// BlogSpotService.AttachFiles writes under siteSettings.Blog.
|
||||
// Details itself doesn't read it, but the service constructor
|
||||
// throws if SiteSettings.Blog is null, so configure a temp
|
||||
// value to keep construction alive.
|
||||
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
|
||||
|
||||
// Trivial file-system auth: Details never calls into it, but
|
||||
// the DI container needs an instance to construct the service.
|
||||
services.AddSingleton<IFileSystemAuthManager>(
|
||||
new NoopFileSystemAuthManager());
|
||||
|
||||
// The real BlogSpotService — same shape the production host
|
||||
// builds (ApplicationDbContext, IAuthorizationService,
|
||||
// IFileSystemAuthManager, IOptions<SiteSettings>,
|
||||
// ILoggerFactory).
|
||||
services.AddScoped<BlogSpotService>();
|
||||
|
||||
// The real PermissionHandler: Details authorizes with
|
||||
// ReadPermission, and PermissionHandler resolves it via the
|
||||
// IsPublic / IsOwner / IsSponsor / Administrator disjunction.
|
||||
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
|
||||
// Default authorization services register
|
||||
// IAuthorizationService (DefaultAuthorizationService), which
|
||||
// collects the IAuthorizationHandler above and runs it against
|
||||
// the requirement passed to AuthorizeAsync.
|
||||
services.AddAuthorization();
|
||||
services.AddLogging();
|
||||
|
||||
_services = services.BuildServiceProvider();
|
||||
|
||||
// Create the schema once. Npgsql uses Migrate (the Yavsc.Org
|
||||
// migrations) so the test schema matches production; SQLite uses
|
||||
// EnsureCreated (idempotent, model-derived). ResetDatabase
|
||||
// re-creates / purges per test.
|
||||
using (var scope = _services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider
|
||||
.GetRequiredService<ApplicationDbContext>();
|
||||
if (UseNpgsqlProvider())
|
||||
db.Database.Migrate();
|
||||
else
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c>
|
||||
/// is set — the fixture then runs against a real PostgreSQL server
|
||||
/// instead of the SQLite in-memory default.</summary>
|
||||
public static bool UseNpgsqlProvider()
|
||||
=> string.Equals(
|
||||
Environment.GetEnvironmentVariable(DbProviderEnvVar),
|
||||
"npgsql",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Reset the store to a known empty state for the next
|
||||
/// test. SQLite: <c>EnsureDeleted</c> + <c>EnsureCreated</c> (the
|
||||
/// named in-memory store survives because _sqliteConnection is held
|
||||
/// open). Npgsql: purge the blog-graph tables in FK order —
|
||||
/// children before parents — so no DELETE trips a foreign key,
|
||||
/// avoiding a full re-migration per test.</summary>
|
||||
public void ResetDatabase()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider
|
||||
.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
if (UseNpgsqlProvider())
|
||||
{
|
||||
// Children before parents. BlogAttachedFile, Comment and
|
||||
// BlogSpotPublication reference BlogSpot; CircleAuthorization
|
||||
// ToBlogPost references BlogSpot + Circle; BlogTag references
|
||||
// BlogSpot + Tag; BlogSpot references ApplicationUser;
|
||||
// CircleMember references Circle + ApplicationUser.
|
||||
db.BlogAttachedFiles.RemoveRange(db.BlogAttachedFiles);
|
||||
db.Comment.RemoveRange(db.Comment);
|
||||
db.blogSpotPublications.RemoveRange(db.blogSpotPublications);
|
||||
db.CircleAuthorizationToBlogPost
|
||||
.RemoveRange(db.CircleAuthorizationToBlogPost);
|
||||
db.BlogTag.RemoveRange(db.BlogTag);
|
||||
db.UploadedFiles.RemoveRange(db.UploadedFiles);
|
||||
db.BlogSpot.RemoveRange(db.BlogSpot);
|
||||
db.CircleMembers.RemoveRange(db.CircleMembers);
|
||||
db.Circle.RemoveRange(db.Circle);
|
||||
db.Users.RemoveRange(db.Users);
|
||||
db.SaveChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
/// <summary>Resolve a fresh service scope and the
|
||||
/// <see cref="ApplicationDbContext"/> from it, run
|
||||
/// <paramref name="body"/>, and dispose the scope (and its
|
||||
/// DbContext). Use this for seeding writes that should not share
|
||||
/// a DbContext with the service call under test.</summary>
|
||||
public T WithDb<T>(Func<ApplicationDbContext, T> body)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider
|
||||
.GetRequiredService<ApplicationDbContext>();
|
||||
return body(db);
|
||||
}
|
||||
|
||||
public void SeedUser(string userName,
|
||||
Action<ApplicationUser>? configure = null)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
if (db.Users.Any(u => u.Id == userName)) return false;
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = userName,
|
||||
UserName = userName,
|
||||
Email = $"{userName}@example.test",
|
||||
};
|
||||
configure?.Invoke(user);
|
||||
db.Users.Add(user);
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public long SeedBlogPost(string authorId, string title,
|
||||
Action<BlogPost>? configure = null)
|
||||
{
|
||||
return WithDb(db =>
|
||||
{
|
||||
var post = new BlogPost
|
||||
{
|
||||
AuthorId = authorId,
|
||||
Title = title,
|
||||
Article = "Test article body.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
configure?.Invoke(post);
|
||||
db.BlogSpot.Add(post);
|
||||
db.SaveChanges();
|
||||
return post.Id;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Insert a <see cref="BlogSpotPublication"/> row for
|
||||
/// <paramref name="postId"/> — the existence of that row is what
|
||||
/// makes a post "public" (readable by anyone) in
|
||||
/// <see cref="PermissionHandler"/>. This is the same write
|
||||
/// <c>SetPublishAsync</c> performs in production.</summary>
|
||||
public void Publish(long postId)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.blogSpotPublications.Add(
|
||||
new BlogSpotPublication { PostId = postId });
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Seed a circle owned by <paramref name="ownerId"/> and
|
||||
/// return its server-assigned id. Sponsorship in
|
||||
/// <see cref="PermissionHandler"/> is "the viewer is a member of a
|
||||
/// circle the post's author owns", so a circle + a
|
||||
/// <see cref="SeedCircleMember"/> entry is what grants a non-owner
|
||||
/// read access to a draft.</summary>
|
||||
public long SeedCircle(string ownerId, string name)
|
||||
{
|
||||
return WithDb(db =>
|
||||
{
|
||||
var circle = new Circle
|
||||
{
|
||||
OwnerId = ownerId,
|
||||
Name = name,
|
||||
Public = false,
|
||||
};
|
||||
db.Circle.Add(circle);
|
||||
db.SaveChanges();
|
||||
return circle.Id;
|
||||
});
|
||||
}
|
||||
|
||||
public void SeedCircleMember(long circleId, string memberId)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.CircleMembers.Add(
|
||||
new CircleMember { CircleId = circleId, MemberId = memberId });
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Attach a <see cref="CircleAuthorizationToBlogPost"/> ACL
|
||||
/// entry to <paramref name="postId"/> for <paramref name="circleId"/>.
|
||||
/// The owner always sees the ACL; non-owners have it scrubbed by
|
||||
/// <see cref="BlogSpotService"/>'s ScrubAclForViewer.</summary>
|
||||
public void SeedAcl(long postId, long circleId)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.CircleAuthorizationToBlogPost.Add(
|
||||
new CircleAuthorizationToBlogPost
|
||||
{
|
||||
BlogPostId = postId,
|
||||
CircleId = circleId,
|
||||
});
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Seed a comment on <paramref name="postId"/> authored by
|
||||
/// <paramref name="authorId"/>. <see cref="BlogSpotService.Details"/>
|
||||
/// loads each comment's <c>Author</c> navigation from
|
||||
/// <c>_context.Users</c>; seeding the user first keeps the FK
|
||||
/// valid (SQLite and PostgreSQL both enforce it).</summary>
|
||||
public void SeedComment(long postId, string authorId, string article)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.Comment.Add(new Comment
|
||||
{
|
||||
ReceiverId = postId,
|
||||
AuthorId = authorId,
|
||||
Article = article,
|
||||
Visible = true,
|
||||
UserModified = authorId,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
|
||||
/// fixture's DI container, run <paramref name="body"/> against it,
|
||||
/// and dispose the request scope (and its scoped DbContext). The
|
||||
/// service is scoped, so it must be resolved from a scope — and the
|
||||
/// scope is what we dispose to avoid leaking a DbContext against
|
||||
/// the shared connection across tests.</summary>
|
||||
public async Task<T> WithServiceAsync<T>(
|
||||
Func<BlogSpotService, Task<T>> body)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var service = scope.ServiceProvider
|
||||
.GetRequiredService<BlogSpotService>();
|
||||
return await body(service);
|
||||
}
|
||||
|
||||
private static string EnsureNpgsqlDatabaseCreated()
|
||||
{
|
||||
lock (_npgsqlLock)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
|
||||
return _sharedNpgsqlConnectionString;
|
||||
|
||||
var adminConnectionString = BuildAdminConnectionString();
|
||||
var databaseName = DedicatedNpgsqlDatabaseName;
|
||||
|
||||
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
|
||||
{
|
||||
adminConnection.Open();
|
||||
using var existsCommand = adminConnection.CreateCommand();
|
||||
existsCommand.CommandText =
|
||||
"SELECT 1 FROM pg_database WHERE datname = @databaseName";
|
||||
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
|
||||
|
||||
if (existsCommand.ExecuteScalar() is null)
|
||||
{
|
||||
using var createCommand = adminConnection.CreateCommand();
|
||||
createCommand.CommandText =
|
||||
$"CREATE DATABASE \"{databaseName}\"";
|
||||
createCommand.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
|
||||
{
|
||||
Database = databaseName,
|
||||
Pooling = false,
|
||||
IncludeErrorDetail = true
|
||||
};
|
||||
|
||||
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
|
||||
return _sharedNpgsqlConnectionString;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAdminConnectionString()
|
||||
{
|
||||
// The admin connection string (host + credentials) is supplied
|
||||
// via environment, never embedded in source — committing a
|
||||
// real dev password would leak it into git history. The Npgsql
|
||||
// path is opt-in, so requiring the env var is fine: a missing
|
||||
// value produces a clear failure rather than a silent fallback
|
||||
// onto someone's local dev database.
|
||||
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
throw new InvalidOperationException(
|
||||
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
|
||||
+ "provider requires an admin PostgreSQL connection string "
|
||||
+ "in that environment variable (e.g. "
|
||||
+ "'Server=localhost;Port=5432;Database=postgres;"
|
||||
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
|
||||
|
||||
var builder = new NpgsqlConnectionStringBuilder(configured)
|
||||
{
|
||||
Pooling = false,
|
||||
IncludeErrorDetail = true
|
||||
};
|
||||
|
||||
// The admin connection targets the maintenance database
|
||||
// (postgres) so we can CREATE/DROP the dedicated test database;
|
||||
// whatever database the env var names is overridden here.
|
||||
builder.Database = "postgres";
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_services.Dispose();
|
||||
_sqliteConnection?.Dispose();
|
||||
}
|
||||
|
||||
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
||||
{
|
||||
public FileAccessRight GetFilePathAccess(
|
||||
System.Security.Claims.ClaimsPrincipal user,
|
||||
string fileRelativePath)
|
||||
=> FileAccessRight.None;
|
||||
|
||||
public void SetAccess(long circleId, string normalizedFullPath,
|
||||
FileAccessRight access) { }
|
||||
}
|
||||
}
|
||||
32
src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
Normal file
32
src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Yavsc.Server.Tests</RootNamespace>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.v3.common" />
|
||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
15
yavsc.sln
15
yavsc.sln
|
|
@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Server.Tests", "src\Yavsc.Server.Tests\Yavsc.Server.Tests.csproj", "{899B7240-729A-48BC-A993-300A6CCFCBEA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -243,6 +245,18 @@ Global
|
|||
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -265,5 +279,6 @@ Global
|
|||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||
{021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||
{49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F}
|
||||
{899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
Loading…
Reference in a new issue