diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f60a95b95..c80c0526f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,3 +22,45 @@ jobs:
uses: DavidAnson/markdownlint-cli2-action@v24
with:
globs: "**/*.md"
+
+ build:
+ name: Build and test
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ services:
+ postgres:
+ image: postgres:17
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: fluentcms_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+ env:
+ DOTNET_NOLOGO: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ FLUENTCMS_TEST_POSTGRES: Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v6
+ with:
+ global-json-file: global.json
+ cache: true
+ cache-dependency-path: Directory.Packages.props
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release
diff --git a/.gitignore b/.gitignore
index ffe9ae04c..a0b9a240a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,11 @@ artifacts/
*.swp
*.DotSettings.user
+# Local SQLite databases
+*.db
+*.db-shm
+*.db-wal
+
# Tests and coverage
TestResults/
*.trx
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 99c084654..78faeae6a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,7 +41,16 @@ Product decisions live in `docs/requirements/00-decision-record.md`. Propose a c
## Building and testing
-Not yet. The solution skeleton is the next piece of work after the repository bootstrap. Once it exists, this section will cover `dotnet build`, `dotnet test` and the Tailwind standalone step. Building will need the .NET 10 SDK and nothing else; Node is not required.
+You need the .NET 10 SDK and nothing else; `global.json` pins the version and Node is not required.
+
+```bash
+dotnet build
+dotnet test
+```
+
+Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `FLUENTCMS_TEST_POSTGRES` to a connection string for an empty database, for example `Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres`. CI does this with a service container. Without the variable those tests are skipped.
+
+To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted) and `JobQueue__WorkerCount`, and from an optional `appsettings.json` next to the binary.
## Code style
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 000000000..76022c167
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ true
+ false
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 000000000..31d78ef48
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,23 @@
+
+
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FluentCMS.slnx b/FluentCMS.slnx
new file mode 100644
index 000000000..f8f688b65
--- /dev/null
+++ b/FluentCMS.slnx
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 000000000..765346e53
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs b/src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs
new file mode 100644
index 000000000..4b18ba8a6
--- /dev/null
+++ b/src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.Admin;
+
+public static class AdminServiceCollectionExtensions
+{
+ // The Blazor shell arrives with the admin shell issue; until then this registers nothing.
+ public static IServiceCollection AddFluentCmsAdmin(this IServiceCollection services)
+ {
+ return services;
+ }
+}
diff --git a/src/FluentCMS.Admin/FluentCMS.Admin.csproj b/src/FluentCMS.Admin/FluentCMS.Admin.csproj
new file mode 100644
index 000000000..460b63b29
--- /dev/null
+++ b/src/FluentCMS.Admin/FluentCMS.Admin.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs
new file mode 100644
index 000000000..64e34f94c
--- /dev/null
+++ b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.Api;
+
+public static class ApiServiceCollectionExtensions
+{
+ // The delivery API arrives with F-API; until then this registers nothing.
+ public static IServiceCollection AddFluentCmsApi(this IServiceCollection services)
+ {
+ return services;
+ }
+}
diff --git a/src/FluentCMS.Api/FluentCMS.Api.csproj b/src/FluentCMS.Api/FluentCMS.Api.csproj
new file mode 100644
index 000000000..460b63b29
--- /dev/null
+++ b/src/FluentCMS.Api/FluentCMS.Api.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs
new file mode 100644
index 000000000..0cf139c86
--- /dev/null
+++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.Core;
+
+public static class CoreServiceCollectionExtensions
+{
+ // The composition seam for domain and application services; each epic adds its registrations here.
+ public static IServiceCollection AddFluentCmsCore(this IServiceCollection services)
+ {
+ return services;
+ }
+}
diff --git a/src/FluentCMS.Core/FluentCMS.Core.csproj b/src/FluentCMS.Core/FluentCMS.Core.csproj
new file mode 100644
index 000000000..4b6ff369e
--- /dev/null
+++ b/src/FluentCMS.Core/FluentCMS.Core.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Host/FluentCMS.Host.csproj b/src/FluentCMS.Host/FluentCMS.Host.csproj
new file mode 100644
index 000000000..56e2fa6fc
--- /dev/null
+++ b/src/FluentCMS.Host/FluentCMS.Host.csproj
@@ -0,0 +1,16 @@
+
+
+
+ fluentcms
+ FluentCMS.Host
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs
new file mode 100644
index 000000000..3eb816b9e
--- /dev/null
+++ b/src/FluentCMS.Host/FluentCmsHost.cs
@@ -0,0 +1,80 @@
+using FluentCMS.Admin;
+using FluentCMS.Api;
+using FluentCMS.Core;
+using FluentCMS.Host.Jobs;
+using FluentCMS.Infrastructure;
+using FluentCMS.Mcp;
+using Microsoft.Extensions.Logging.Console;
+
+namespace FluentCMS.Host;
+
+public static class FluentCmsHost
+{
+ public static async Task RunWebAsync(string[] args)
+ {
+ var app = BuildWebApplication(args);
+ await app.RunAsync();
+ return 0;
+ }
+
+ public static async Task RunMcpAsync(string[] args)
+ {
+ using var host = BuildMcpHost(args);
+ await host.RunAsync();
+ return 0;
+ }
+
+ public static WebApplication BuildWebApplication(string[] args, Action? configure = null)
+ {
+ var builder = WebApplication.CreateBuilder(args);
+
+ ConfigureLogging(builder.Logging, logToStandardError: false);
+ AddFluentCms(builder.Services, builder.Configuration);
+ builder.Services.AddHealthChecks();
+ configure?.Invoke(builder);
+
+ var app = builder.Build();
+ app.MapHealthChecks("/health");
+ return app;
+ }
+
+ public static IHost BuildMcpHost(string[] args, Stream? standardInput = null, Action? configure = null)
+ {
+ var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args);
+
+ ConfigureLogging(builder.Logging, logToStandardError: true);
+ AddFluentCms(builder.Services, builder.Configuration);
+ builder.Services.AddSingleton(standardInput is null
+ ? StandardInputSource.Console
+ : new StandardInputSource(() => standardInput));
+ builder.Services.AddHostedService();
+ configure?.Invoke(builder);
+
+ return builder.Build();
+ }
+
+ // One composition for both entry modes, so the stdio host opens the same database and runs the same services.
+ private static void AddFluentCms(IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddFluentCmsCore();
+ services.AddFluentCmsInfrastructure(configuration);
+ services.AddFluentCmsMcp();
+ services.AddFluentCmsApi();
+ services.AddFluentCmsAdmin();
+
+ services.Configure(configuration.GetSection(JobQueueOptions.SectionName));
+ services.AddHostedService();
+ }
+
+ private static void ConfigureLogging(ILoggingBuilder logging, bool logToStandardError)
+ {
+ logging.ClearProviders();
+ logging.AddJsonConsole();
+
+ if (logToStandardError)
+ {
+ // Standard output belongs to the MCP transport, so every log line goes to standard error.
+ logging.Services.Configure(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
+ }
+ }
+}
diff --git a/src/FluentCMS.Host/Jobs/JobQueueOptions.cs b/src/FluentCMS.Host/Jobs/JobQueueOptions.cs
new file mode 100644
index 000000000..02c9a57f1
--- /dev/null
+++ b/src/FluentCMS.Host/Jobs/JobQueueOptions.cs
@@ -0,0 +1,10 @@
+namespace FluentCMS.Host.Jobs;
+
+public sealed class JobQueueOptions
+{
+ public const string SectionName = "JobQueue";
+
+ public int WorkerCount { get; set; } = 2;
+
+ public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(5);
+}
diff --git a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs
new file mode 100644
index 000000000..1d201d320
--- /dev/null
+++ b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs
@@ -0,0 +1,36 @@
+using Microsoft.Extensions.Options;
+
+namespace FluentCMS.Host.Jobs;
+
+// The bounded worker pool from ADR-0010. There is no jobs table yet, so the workers only keep the polling rhythm.
+public sealed class JobQueueWorker(IOptions options, ILogger logger) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var settings = options.Value;
+ var workerCount = Math.Max(1, settings.WorkerCount);
+ var interval = settings.PollingInterval > TimeSpan.Zero ? settings.PollingInterval : TimeSpan.FromSeconds(1);
+
+ logger.LogInformation("Job queue started with {WorkerCount} worker(s), polling every {Interval}.", workerCount, interval);
+
+ var workers = Enumerable.Range(1, workerCount).Select(_ => IdleAsync(interval, stoppingToken));
+ await Task.WhenAll(workers);
+
+ logger.LogInformation("Job queue stopped.");
+ }
+
+ private static async Task IdleAsync(TimeSpan interval, CancellationToken stoppingToken)
+ {
+ try
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await Task.Delay(interval, stoppingToken);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutdown requested.
+ }
+ }
+}
diff --git a/src/FluentCMS.Host/Program.cs b/src/FluentCMS.Host/Program.cs
new file mode 100644
index 000000000..58971949e
--- /dev/null
+++ b/src/FluentCMS.Host/Program.cs
@@ -0,0 +1,5 @@
+using FluentCMS.Host;
+
+return args is ["mcp", ..]
+ ? await FluentCmsHost.RunMcpAsync(args[1..])
+ : await FluentCmsHost.RunWebAsync(args);
diff --git a/src/FluentCMS.Host/StandardInputWatcher.cs b/src/FluentCMS.Host/StandardInputWatcher.cs
new file mode 100644
index 000000000..cbd161550
--- /dev/null
+++ b/src/FluentCMS.Host/StandardInputWatcher.cs
@@ -0,0 +1,53 @@
+namespace FluentCMS.Host;
+
+// Stops the stdio host when standard input closes, which is how an MCP client ends the session.
+public sealed class StandardInputWatcher(
+ IHostApplicationLifetime lifetime,
+ ILogger logger,
+ StandardInputSource source) : BackgroundService
+{
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // A blocked console read cannot be cancelled, so the loop runs detached and shutdown never waits for it.
+ _ = Task.Run(() => ReadUntilClosedAsync(stoppingToken), CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ private async Task ReadUntilClosedAsync(CancellationToken stoppingToken)
+ {
+ var buffer = new byte[4096];
+
+ try
+ {
+ await using var input = source.Open();
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ // Input is discarded until the MCP transport (F-MCP) takes over standard input.
+ if (await input.ReadAsync(buffer, stoppingToken) == 0)
+ {
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (IOException exception)
+ {
+ logger.LogWarning(exception, "Standard input failed; stopping.");
+ }
+
+ if (!stoppingToken.IsCancellationRequested)
+ {
+ logger.LogInformation("Standard input closed; stopping.");
+ lifetime.StopApplication();
+ }
+ }
+}
+
+public sealed record StandardInputSource(Func Open)
+{
+ public static StandardInputSource Console { get; } = new(() => System.Console.OpenStandardInput());
+}
diff --git a/src/FluentCMS.Host/appsettings.json b/src/FluentCMS.Host/appsettings.json
new file mode 100644
index 000000000..e1fccd9fe
--- /dev/null
+++ b/src/FluentCMS.Host/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Database": {
+ "ConnectionString": "Data Source=fluentcms.db"
+ },
+ "JobQueue": {
+ "WorkerCount": 2,
+ "PollingInterval": "00:00:05"
+ }
+}
diff --git a/src/FluentCMS.Infrastructure/FluentCMS.Infrastructure.csproj b/src/FluentCMS.Infrastructure/FluentCMS.Infrastructure.csproj
new file mode 100644
index 000000000..f84991b11
--- /dev/null
+++ b/src/FluentCMS.Infrastructure/FluentCMS.Infrastructure.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs
new file mode 100644
index 000000000..aa8153d5a
--- /dev/null
+++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs
@@ -0,0 +1,37 @@
+using FluentCMS.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace FluentCMS.Infrastructure;
+
+public static class InfrastructureServiceCollectionExtensions
+{
+ public static IServiceCollection AddFluentCmsInfrastructure(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection(DatabaseOptions.SectionName));
+
+ services.AddDbContext((provider, options) =>
+ {
+ var database = provider.GetRequiredService>().Value;
+
+ switch (database.ResolveProvider())
+ {
+ case DatabaseProviders.Sqlite:
+ options.UseSqlite(database.ConnectionString);
+ break;
+ case DatabaseProviders.PostgreSql:
+ options.UseNpgsql(database.ConnectionString);
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unknown database provider '{database.Provider}'. Use '{DatabaseProviders.Sqlite}' or '{DatabaseProviders.PostgreSql}'.");
+ }
+ });
+
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/FluentCMS.Infrastructure/Persistence/DatabaseMigrator.cs b/src/FluentCMS.Infrastructure/Persistence/DatabaseMigrator.cs
new file mode 100644
index 000000000..165bbc3e2
--- /dev/null
+++ b/src/FluentCMS.Infrastructure/Persistence/DatabaseMigrator.cs
@@ -0,0 +1,31 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace FluentCMS.Infrastructure.Persistence;
+
+// Applies pending migrations at startup (OPS-04). With no migrations defined yet this only confirms the database opens.
+public sealed class DatabaseMigrator(IServiceScopeFactory scopeFactory, ILogger logger) : IHostedService
+{
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var database = scope.ServiceProvider.GetRequiredService().Database;
+
+ var pending = (await database.GetPendingMigrationsAsync(cancellationToken)).ToList();
+ if (pending.Count == 0)
+ {
+ logger.LogInformation("Database is up to date.");
+ return;
+ }
+
+ logger.LogInformation("Applying {Count} pending migration(s).", pending.Count);
+ await database.MigrateAsync(cancellationToken);
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/FluentCMS.Infrastructure/Persistence/DatabaseOptions.cs b/src/FluentCMS.Infrastructure/Persistence/DatabaseOptions.cs
new file mode 100644
index 000000000..0334ddbc5
--- /dev/null
+++ b/src/FluentCMS.Infrastructure/Persistence/DatabaseOptions.cs
@@ -0,0 +1,29 @@
+namespace FluentCMS.Infrastructure.Persistence;
+
+public sealed class DatabaseOptions
+{
+ public const string SectionName = "Database";
+
+ // "sqlite" or "postgresql". Left empty, the provider is inferred from the connection string.
+ public string? Provider { get; set; }
+
+ public string ConnectionString { get; set; } = "Data Source=fluentcms.db";
+
+ public string ResolveProvider()
+ {
+ if (!string.IsNullOrWhiteSpace(Provider))
+ {
+ return Provider.Trim().ToLowerInvariant();
+ }
+
+ return ConnectionString.Contains("Host=", StringComparison.OrdinalIgnoreCase)
+ ? DatabaseProviders.PostgreSql
+ : DatabaseProviders.Sqlite;
+ }
+}
+
+public static class DatabaseProviders
+{
+ public const string Sqlite = "sqlite";
+ public const string PostgreSql = "postgresql";
+}
diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs
new file mode 100644
index 000000000..bb13a01b7
--- /dev/null
+++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs
@@ -0,0 +1,5 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace FluentCMS.Infrastructure.Persistence;
+
+public sealed class FluentCmsDbContext(DbContextOptions options) : DbContext(options);
diff --git a/src/FluentCMS.Mcp/FluentCMS.Mcp.csproj b/src/FluentCMS.Mcp/FluentCMS.Mcp.csproj
new file mode 100644
index 000000000..460b63b29
--- /dev/null
+++ b/src/FluentCMS.Mcp/FluentCMS.Mcp.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs b/src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs
new file mode 100644
index 000000000..1fe4a5888
--- /dev/null
+++ b/src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.Mcp;
+
+public static class McpServiceCollectionExtensions
+{
+ // The MCP transport and tools arrive with F-MCP; until then this registers nothing.
+ public static IServiceCollection AddFluentCmsMcp(this IServiceCollection services)
+ {
+ return services;
+ }
+}
diff --git a/tests/FluentCMS.IntegrationTests/DatabaseTests.cs b/tests/FluentCMS.IntegrationTests/DatabaseTests.cs
new file mode 100644
index 000000000..21cc5069b
--- /dev/null
+++ b/tests/FluentCMS.IntegrationTests/DatabaseTests.cs
@@ -0,0 +1,69 @@
+using FluentCMS.Infrastructure;
+using FluentCMS.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.IntegrationTests;
+
+public sealed class DatabaseTests
+{
+ [Fact]
+ public async Task DbContext_opens_on_sqlite_by_default()
+ {
+ using var database = new TemporarySqliteDatabase();
+ await using var services = BuildServices(database.Configuration);
+ await using var scope = services.CreateAsyncScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ Assert.Equal("Microsoft.EntityFrameworkCore.Sqlite", context.Database.ProviderName);
+
+ // SQLite creates the file on first open; CanConnect only reports whether the file exists.
+ await context.Database.OpenConnectionAsync();
+ await context.Database.CloseConnectionAsync();
+
+ Assert.True(await context.Database.CanConnectAsync());
+ }
+
+ [PostgresFact]
+ public async Task DbContext_opens_on_postgresql_when_the_connection_string_says_so()
+ {
+ var connectionString = Environment.GetEnvironmentVariable(PostgresFactAttribute.VariableName)!;
+ await using var services = BuildServices(new Dictionary
+ {
+ ["Database:ConnectionString"] = connectionString,
+ });
+ await using var scope = services.CreateAsyncScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ Assert.Equal("Npgsql.EntityFrameworkCore.PostgreSQL", context.Database.ProviderName);
+ Assert.True(await context.Database.CanConnectAsync());
+ }
+
+ [Fact]
+ public void Unknown_provider_is_rejected()
+ {
+ using var services = BuildServices(new Dictionary
+ {
+ ["Database:Provider"] = "oracle",
+ ["Database:ConnectionString"] = "Data Source=unused",
+ });
+ using var scope = services.CreateScope();
+
+ var exception = Assert.Throws(
+ () => scope.ServiceProvider.GetRequiredService());
+
+ Assert.Contains("oracle", exception.Message);
+ }
+
+ private static ServiceProvider BuildServices(IEnumerable> settings)
+ {
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
+ var services = new ServiceCollection();
+
+ services.AddLogging();
+ services.AddFluentCmsInfrastructure(configuration);
+
+ return services.BuildServiceProvider();
+ }
+}
diff --git a/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj
new file mode 100644
index 000000000..8eee8c0ad
--- /dev/null
+++ b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/FluentCMS.IntegrationTests/HostTests.cs b/tests/FluentCMS.IntegrationTests/HostTests.cs
new file mode 100644
index 000000000..4e1bbc127
--- /dev/null
+++ b/tests/FluentCMS.IntegrationTests/HostTests.cs
@@ -0,0 +1,58 @@
+using System.Net;
+using FluentCMS.Host;
+using FluentCMS.Host.Jobs;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace FluentCMS.IntegrationTests;
+
+public sealed class HostTests
+{
+ [Fact]
+ public async Task Web_host_answers_the_health_endpoint_and_runs_the_job_queue()
+ {
+ using var database = new TemporarySqliteDatabase();
+ var app = FluentCmsHost.BuildWebApplication([], builder =>
+ {
+ builder.WebHost.UseUrls("http://127.0.0.1:0");
+ builder.Configuration.AddInMemoryCollection(database.Configuration);
+ });
+
+ await using (app)
+ {
+ await app.StartAsync();
+
+ Assert.Contains(app.Services.GetServices(), service => service is JobQueueWorker);
+
+ using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) };
+ var response = await client.GetAsync("/health");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("Healthy", await response.Content.ReadAsStringAsync());
+
+ await app.StopAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Mcp_host_has_no_web_server_and_stops_when_standard_input_closes()
+ {
+ using var database = new TemporarySqliteDatabase();
+ using var host = FluentCmsHost.BuildMcpHost(
+ [],
+ standardInput: Stream.Null,
+ configure: builder => builder.Configuration.AddInMemoryCollection(database.Configuration));
+
+ Assert.Null(host.Services.GetService());
+ Assert.Contains(host.Services.GetServices(), service => service is JobQueueWorker);
+
+ var run = host.RunAsync();
+ var finished = await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(30)));
+
+ Assert.Same(run, finished);
+ await run;
+ }
+}
diff --git a/tests/FluentCMS.IntegrationTests/PostgresFactAttribute.cs b/tests/FluentCMS.IntegrationTests/PostgresFactAttribute.cs
new file mode 100644
index 000000000..10f19e4a9
--- /dev/null
+++ b/tests/FluentCMS.IntegrationTests/PostgresFactAttribute.cs
@@ -0,0 +1,15 @@
+namespace FluentCMS.IntegrationTests;
+
+// Runs only when the variable holds a PostgreSQL connection string; CI sets it from a service container.
+public sealed class PostgresFactAttribute : FactAttribute
+{
+ public const string VariableName = "FLUENTCMS_TEST_POSTGRES";
+
+ public PostgresFactAttribute()
+ {
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(VariableName)))
+ {
+ Skip = $"{VariableName} is not set.";
+ }
+ }
+}
diff --git a/tests/FluentCMS.IntegrationTests/TemporarySqliteDatabase.cs b/tests/FluentCMS.IntegrationTests/TemporarySqliteDatabase.cs
new file mode 100644
index 000000000..3fadff70d
--- /dev/null
+++ b/tests/FluentCMS.IntegrationTests/TemporarySqliteDatabase.cs
@@ -0,0 +1,20 @@
+using Microsoft.Data.Sqlite;
+
+namespace FluentCMS.IntegrationTests;
+
+internal sealed class TemporarySqliteDatabase : IDisposable
+{
+ private readonly string _path = Path.Combine(Path.GetTempPath(), $"fluentcms-test-{Guid.NewGuid():N}.db");
+
+ public Dictionary Configuration => new()
+ {
+ ["Database:ConnectionString"] = $"Data Source={_path}",
+ };
+
+ public void Dispose()
+ {
+ // Pooled connections keep the file locked on Windows until the pool is cleared.
+ SqliteConnection.ClearAllPools();
+ File.Delete(_path);
+ }
+}
diff --git a/tests/FluentCMS.Tests/FluentCMS.Tests.csproj b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj
new file mode 100644
index 000000000..6b046c087
--- /dev/null
+++ b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs b/tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs
new file mode 100644
index 000000000..15accb238
--- /dev/null
+++ b/tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs
@@ -0,0 +1,62 @@
+using System.Xml.Linq;
+
+namespace FluentCMS.Tests;
+
+// Enforces the dependency direction from ADR-0004 by reading the project files, so a forbidden reference fails the build's tests.
+public sealed class ProjectReferenceRulesTests
+{
+ private static readonly Dictionary AllowedReferences = new()
+ {
+ ["FluentCMS.Core"] = [],
+ ["FluentCMS.Infrastructure"] = ["FluentCMS.Core"],
+ ["FluentCMS.Mcp"] = ["FluentCMS.Core"],
+ ["FluentCMS.Api"] = ["FluentCMS.Core"],
+ ["FluentCMS.Admin"] = ["FluentCMS.Core"],
+ ["FluentCMS.Host"] = ["FluentCMS.Core", "FluentCMS.Infrastructure", "FluentCMS.Mcp", "FluentCMS.Api", "FluentCMS.Admin"],
+ };
+
+ [Fact]
+ public void Every_project_references_only_what_ADR_0004_permits()
+ {
+ var sourceDirectory = Path.Combine(FindRepositoryRoot(), "src");
+ var projectFiles = Directory.GetFiles(sourceDirectory, "*.csproj", SearchOption.AllDirectories);
+
+ Assert.Equal(
+ AllowedReferences.Keys.Order(),
+ projectFiles.Select(Path.GetFileNameWithoutExtension).Order());
+
+ var violations = new List();
+
+ foreach (var projectFile in projectFiles)
+ {
+ var project = Path.GetFileNameWithoutExtension(projectFile);
+ var references = XDocument.Load(projectFile)
+ .Descendants("ProjectReference")
+ .Select(reference => ProjectName((string)reference.Attribute("Include")!));
+
+ violations.AddRange(references
+ .Where(reference => !AllowedReferences[project].Contains(reference))
+ .Select(reference => $"{project} references {reference}"));
+ }
+
+ Assert.Empty(violations);
+ }
+
+ private static string ProjectName(string include)
+ {
+ return Path.GetFileNameWithoutExtension(include.Replace('\\', '/'));
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "FluentCMS.slnx")))
+ {
+ directory = directory.Parent;
+ }
+
+ return directory?.FullName
+ ?? throw new InvalidOperationException("FluentCMS.slnx was not found above the test directory.");
+ }
+}