diff --git a/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs b/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
new file mode 100644
index 000000000..713957804
--- /dev/null
+++ b/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
@@ -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;
+
+///
+/// Service-level tests for .
+///
+/// loads a
+/// (with Author, Tags, Comments, ACL and
+/// Publication navigations), runs a resource-based
+/// ReadPermission authorization against it, scrubs the ACL for
+/// non-owners, derives from the
+/// loaded Publication navigation, and eagerly loads each
+/// comment's Author. These tests pin each of those behaviours
+/// at the service layer against the real
+/// (the public/owner/
+/// sponsor/Administrator disjunction) and a real SQLite store — no
+/// mocking.
+///
+/// Seeding and the service call resolve through separate DI
+/// scopes so the DbContext that writes the seed rows is disposed
+/// before the one reads through,
+/// mirroring the per-request DbContext lifetime in production.
+///
+[Collection("Yavsc Server")]
+public sealed class BlogSpotServiceDetailsTests : IClassFixture
+{
+ private readonly ServerServicesFixture _fixture;
+
+ public BlogSpotServiceDetailsTests(ServerServicesFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ /// An authenticated principal whose sub claim is
+ /// .
+ /// reads sub first, which is what the JwtBearer middleware
+ /// in production preserves (MapInboundClaims = false), and what
+ /// compares against
+ /// .
+ private static ClaimsPrincipal Authenticated(string userId,
+ params Claim[] extra)
+ {
+ var claims = new List { new("sub", userId) };
+ claims.AddRange(extra);
+ return new ClaimsPrincipal(
+ new ClaimsIdentity(claims, authenticationType: "Bearer"));
+ }
+
+ /// An unauthenticated principal: a
+ /// with no authentication type has
+ /// == false. The
+ /// service still runs authorization — a published post's
+ /// ReadPermission succeeds purely on IsPublic, with no
+ /// identity required.
+ private static ClaimsPrincipal Anonymous()
+ => new(new ClaimsIdentity());
+
+ /// The Microsoft-format role claim
+ ///
+ /// reads when checking the "Administrator" bypass in
+ /// .
+ 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(() =>
+ _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(() =>
+ _fixture.WithServiceAsync(svc =>
+ svc.Details(Authenticated("alice"), 424242)));
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs b/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
new file mode 100644
index 000000000..f57f65839
--- /dev/null
+++ b/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
@@ -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;
+
+///
+/// Service-level test host for and the
+/// other Yavsc.Server services that take an
+/// + .
+///
+/// Unlike BlogsWebServerFixture (in Yavsc.Blogs.Tests),
+/// this fixture does not spin up Kestrel, JWT bearer, or MVC
+/// controllers. takes the
+/// 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.
+///
+/// Database provider. Mirrors
+/// ApiWebServerFixture: SQLite :memory: by default, or
+/// real PostgreSQL when YAVSC_SERVER_TEST_DB_PROVIDER=npgsql is
+/// set. Production runs on Npgsql, so the Npgsql path — which applies
+/// the real Yavsc.Org 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.
+///
+///
+/// - SQLite: a named :memory: database
+/// (Cache=Shared) backed by a single connection held open for the
+/// fixture's lifetime, schema via EnsureCreated. Reset is
+/// EnsureDeleted + EnsureCreated.
+/// - Npgsql: a dedicated database
+/// yavscServerTestDb (created from the admin connection if it
+/// does not exist), schema via Migrate using the
+/// Yavsc.Org 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).
+/// - The real and the
+/// real : Details calls
+/// IAuthorizationService.AuthorizeAsync(user, blog, new
+/// ReadPermission()), and answers
+/// that requirement with the public/owner/sponsor/Administrator
+/// disjunction that the tests below pin. No mocking.
+/// - A trivial
+/// stub: Details never touches the file system, but the DI
+/// container needs an instance to construct the service.
+///
+///
+/// Marked so a single
+/// fixture instance (one store, one DI container) is shared across
+/// every [Collection("Yavsc Server")] test class. Each test
+/// resets the store via .
+///
+[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(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(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(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(
+ new NoopFileSystemAuthManager());
+
+ // The real BlogSpotService — same shape the production host
+ // builds (ApplicationDbContext, IAuthorizationService,
+ // IFileSystemAuthManager, IOptions,
+ // ILoggerFactory).
+ services.AddScoped();
+
+ // The real PermissionHandler: Details authorizes with
+ // ReadPermission, and PermissionHandler resolves it via the
+ // IsPublic / IsOwner / IsSponsor / Administrator disjunction.
+ services.AddScoped();
+
+ // 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();
+ if (UseNpgsqlProvider())
+ db.Database.Migrate();
+ else
+ db.Database.EnsureCreated();
+ }
+ }
+
+ /// True when YAVSC_SERVER_TEST_DB_PROVIDER=npgsql
+ /// is set — the fixture then runs against a real PostgreSQL server
+ /// instead of the SQLite in-memory default.
+ public static bool UseNpgsqlProvider()
+ => string.Equals(
+ Environment.GetEnvironmentVariable(DbProviderEnvVar),
+ "npgsql",
+ StringComparison.OrdinalIgnoreCase);
+
+ /// Reset the store to a known empty state for the next
+ /// test. SQLite: EnsureDeleted + EnsureCreated (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.
+ public void ResetDatabase()
+ {
+ using var scope = _services.CreateScope();
+ var db = scope.ServiceProvider
+ .GetRequiredService();
+
+ 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();
+ }
+
+ /// Resolve a fresh service scope and the
+ /// from it, run
+ /// , and dispose the scope (and its
+ /// DbContext). Use this for seeding writes that should not share
+ /// a DbContext with the service call under test.
+ public T WithDb(Func body)
+ {
+ using var scope = _services.CreateScope();
+ var db = scope.ServiceProvider
+ .GetRequiredService();
+ return body(db);
+ }
+
+ public void SeedUser(string userName,
+ Action? 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? 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;
+ });
+ }
+
+ /// Insert a row for
+ /// — the existence of that row is what
+ /// makes a post "public" (readable by anyone) in
+ /// . This is the same write
+ /// SetPublishAsync performs in production.
+ public void Publish(long postId)
+ {
+ WithDb(db =>
+ {
+ db.blogSpotPublications.Add(
+ new BlogSpotPublication { PostId = postId });
+ db.SaveChanges();
+ return true;
+ });
+ }
+
+ /// Seed a circle owned by and
+ /// return its server-assigned id. Sponsorship in
+ /// is "the viewer is a member of a
+ /// circle the post's author owns", so a circle + a
+ /// entry is what grants a non-owner
+ /// read access to a draft.
+ 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;
+ });
+ }
+
+ /// Attach a ACL
+ /// entry to for .
+ /// The owner always sees the ACL; non-owners have it scrubbed by
+ /// 's ScrubAclForViewer.
+ public void SeedAcl(long postId, long circleId)
+ {
+ WithDb(db =>
+ {
+ db.CircleAuthorizationToBlogPost.Add(
+ new CircleAuthorizationToBlogPost
+ {
+ BlogPostId = postId,
+ CircleId = circleId,
+ });
+ db.SaveChanges();
+ return true;
+ });
+ }
+
+ /// Seed a comment on authored by
+ /// .
+ /// loads each comment's Author navigation from
+ /// _context.Users; seeding the user first keeps the FK
+ /// valid (SQLite and PostgreSQL both enforce it).
+ 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;
+ });
+ }
+
+ /// Resolve the real from the
+ /// fixture's DI container, run 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.
+ public async Task WithServiceAsync(
+ Func> body)
+ {
+ using var scope = _services.CreateScope();
+ var service = scope.ServiceProvider
+ .GetRequiredService();
+ 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) { }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj b/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
new file mode 100644
index 000000000..1ccb802cf
--- /dev/null
+++ b/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ Yavsc.Server.Tests
+ true
+ 1.1.0.0
+ 1.1.0.0
+ 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d
+ 1.1.0-estimate.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/yavsc.sln b/yavsc.sln
index 6085a85fd..d7cffd18f 100644
--- a/yavsc.sln
+++ b/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