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:
Paul Schneider 2026-09-25 14:45:22 +01:00
commit ed3dc733d3
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
4 changed files with 721 additions and 0 deletions

View 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)));
}
}

View 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) { }
}
}

View 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>

View file

@ -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