From a0deef110865486775e64da269dc23979a6265f0 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sat, 17 Oct 2026 15:42:31 -0400 Subject: [PATCH] Create the v2 solution skeleton Solution, the six projects from ADR-0004 with their reference rules, both Host entry modes, the empty DbContext with provider selection, the job queue stub, tests, and the build-and-test CI job. Refs #2433 --- .github/workflows/ci.yml | 42 ++++++++++ .gitignore | 5 ++ CONTRIBUTING.md | 11 ++- Directory.Build.props | 13 +++ Directory.Packages.props | 23 ++++++ FluentCMS.slnx | 14 ++++ nuget.config | 7 ++ .../AdminServiceCollectionExtensions.cs | 12 +++ src/FluentCMS.Admin/FluentCMS.Admin.csproj | 11 +++ .../ApiServiceCollectionExtensions.cs | 12 +++ src/FluentCMS.Api/FluentCMS.Api.csproj | 11 +++ .../CoreServiceCollectionExtensions.cs | 12 +++ src/FluentCMS.Core/FluentCMS.Core.csproj | 7 ++ src/FluentCMS.Host/FluentCMS.Host.csproj | 16 ++++ src/FluentCMS.Host/FluentCmsHost.cs | 80 +++++++++++++++++++ src/FluentCMS.Host/Jobs/JobQueueOptions.cs | 10 +++ src/FluentCMS.Host/Jobs/JobQueueWorker.cs | 36 +++++++++ src/FluentCMS.Host/Program.cs | 5 ++ src/FluentCMS.Host/StandardInputWatcher.cs | 53 ++++++++++++ src/FluentCMS.Host/appsettings.json | 15 ++++ .../FluentCMS.Infrastructure.csproj | 14 ++++ ...frastructureServiceCollectionExtensions.cs | 37 +++++++++ .../Persistence/DatabaseMigrator.cs | 31 +++++++ .../Persistence/DatabaseOptions.cs | 29 +++++++ .../Persistence/FluentCmsDbContext.cs | 5 ++ src/FluentCMS.Mcp/FluentCMS.Mcp.csproj | 11 +++ .../McpServiceCollectionExtensions.cs | 12 +++ .../DatabaseTests.cs | 69 ++++++++++++++++ .../FluentCMS.IntegrationTests.csproj | 19 +++++ tests/FluentCMS.IntegrationTests/HostTests.cs | 58 ++++++++++++++ .../PostgresFactAttribute.cs | 15 ++++ .../TemporarySqliteDatabase.cs | 20 +++++ tests/FluentCMS.Tests/FluentCMS.Tests.csproj | 18 +++++ .../ProjectReferenceRulesTests.cs | 62 ++++++++++++++ 34 files changed, 794 insertions(+), 1 deletion(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 FluentCMS.slnx create mode 100644 nuget.config create mode 100644 src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs create mode 100644 src/FluentCMS.Admin/FluentCMS.Admin.csproj create mode 100644 src/FluentCMS.Api/ApiServiceCollectionExtensions.cs create mode 100644 src/FluentCMS.Api/FluentCMS.Api.csproj create mode 100644 src/FluentCMS.Core/CoreServiceCollectionExtensions.cs create mode 100644 src/FluentCMS.Core/FluentCMS.Core.csproj create mode 100644 src/FluentCMS.Host/FluentCMS.Host.csproj create mode 100644 src/FluentCMS.Host/FluentCmsHost.cs create mode 100644 src/FluentCMS.Host/Jobs/JobQueueOptions.cs create mode 100644 src/FluentCMS.Host/Jobs/JobQueueWorker.cs create mode 100644 src/FluentCMS.Host/Program.cs create mode 100644 src/FluentCMS.Host/StandardInputWatcher.cs create mode 100644 src/FluentCMS.Host/appsettings.json create mode 100644 src/FluentCMS.Infrastructure/FluentCMS.Infrastructure.csproj create mode 100644 src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/DatabaseMigrator.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/DatabaseOptions.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs create mode 100644 src/FluentCMS.Mcp/FluentCMS.Mcp.csproj create mode 100644 src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs create mode 100644 tests/FluentCMS.IntegrationTests/DatabaseTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj create mode 100644 tests/FluentCMS.IntegrationTests/HostTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/PostgresFactAttribute.cs create mode 100644 tests/FluentCMS.IntegrationTests/TemporarySqliteDatabase.cs create mode 100644 tests/FluentCMS.Tests/FluentCMS.Tests.csproj create mode 100644 tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs 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."); + } +}