From 26108c95ccb5528ca8cf8a350b4ef7339fe981ca Mon Sep 17 00:00:00 2001 From: peterkneale Date: Fri, 30 Dec 2022 18:11:30 +1100 Subject: [PATCH] x --- Demo.sln | 7 ++ readme.md | 26 +++++-- .../Domain/SettingsAggregate/Settings.cs | 4 +- .../Infrastructure/SettingsRepository.cs | 18 ++--- .../Api/TenantsApi.proto | 6 +- .../Infrastructure/WidgetRepository.cs | 8 +- .../Infrastructure/Database/Migration1.cs | 4 +- ...d.Modules.Settings.IntegrationTests.csproj | 32 ++++++++ .../Fixtures/ContainerCollectionFixture.cs | 6 ++ .../Fixtures/ContainerFixture.cs | 33 +++++++++ .../Fixtures/ProviderExtensions.cs | 35 +++++++++ .../GlobalUsings.cs | 9 +++ .../UseCases/ThemeTests.cs | 73 +++++++++++++++++++ 13 files changed, 227 insertions(+), 34 deletions(-) create mode 100644 tests/Backend.Modules.Settings.IntegrationTests/Backend.Modules.Settings.IntegrationTests.csproj create mode 100644 tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerCollectionFixture.cs create mode 100644 tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerFixture.cs create mode 100644 tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ProviderExtensions.cs create mode 100644 tests/Backend.Modules.Settings.IntegrationTests/GlobalUsings.cs create mode 100644 tests/Backend.Modules.Settings.IntegrationTests/UseCases/ThemeTests.cs diff --git a/Demo.sln b/Demo.sln index e7eed58..d6a9e23 100644 --- a/Demo.sln +++ b/Demo.sln @@ -52,6 +52,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.Modules.Tenants.Int EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{7EB35F3D-8510-4899-8A61-C4D2362DA822}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.Modules.Settings.IntegrationTests", "tests\Backend.Modules.Settings.IntegrationTests\Backend.Modules.Settings.IntegrationTests.csproj", "{CD307B35-5A4F-4B94-BC25-2A5961B860D0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -118,6 +120,10 @@ Global {983CF288-DDD8-43F6-ADBD-88961D89F72D}.Debug|Any CPU.Build.0 = Debug|Any CPU {983CF288-DDD8-43F6-ADBD-88961D89F72D}.Release|Any CPU.ActiveCfg = Release|Any CPU {983CF288-DDD8-43F6-ADBD-88961D89F72D}.Release|Any CPU.Build.0 = Release|Any CPU + {CD307B35-5A4F-4B94-BC25-2A5961B860D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD307B35-5A4F-4B94-BC25-2A5961B860D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD307B35-5A4F-4B94-BC25-2A5961B860D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD307B35-5A4F-4B94-BC25-2A5961B860D0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {A10CE326-0725-459B-864D-83BBD8E13034} = {73B2D212-C184-4F69-B7D2-296834C288D1} @@ -137,5 +143,6 @@ Global {983CF288-DDD8-43F6-ADBD-88961D89F72D} = {7EB35F3D-8510-4899-8A61-C4D2362DA822} {AE4A616F-EE8F-4112-8D46-7B89DA71684D} = {7EB35F3D-8510-4899-8A61-C4D2362DA822} {5686EB1A-2C59-4512-B8F2-EB5E9A30E0F8} = {7EB35F3D-8510-4899-8A61-C4D2362DA822} + {CD307B35-5A4F-4B94-BC25-2A5961B860D0} = {7EB35F3D-8510-4899-8A61-C4D2362DA822} EndGlobalSection EndGlobal diff --git a/readme.md b/readme.md index ff61e7c..0209c29 100644 --- a/readme.md +++ b/readme.md @@ -5,13 +5,21 @@ - todo -# Backend +# Backend - Modular Monolith -### Admin API +### Tenants API - Executes use cases in the context of an administrator on the platform - The security policy defined below allows read-only access to all tenant data -### Tenant API +### Settings API +- Executes use cases in the context of a specific tenant on the platform +- The security policy defined below allows full access to the specified tenants data + +### Statistics API +- Executes use cases in the context of a specific tenant on the platform +- The security policy defined below allows full access to the specified tenants data + +### Widgets API - Executes use cases in the context of a specific tenant on the platform - The security policy defined below allows full access to the specified tenants data @@ -42,7 +50,7 @@ ## Database schema Create a table for use by multiple tenants ```cs -Create.Table("cars") +Create.Table("widgets") .WithColumn("id").AsGuid().NotNullable().PrimaryKey() .WithColumn("tenant").AsString().NotNullable() // This column indicates which tenant a row belongs to .WithColumn("registration").AsString().Nullable().Unique() @@ -84,11 +92,13 @@ Execute.Sql($"CREATE POLICY {Policy} ON {Table} FOR ALL TO {Username} USING ({Co ## Build and Deploy ```shell -docker build -f Frontend/Dockerfile . -t peterkneale/frontend -docker build -f Admin/Dockerfile . -t peterkneale/admin -docker build -f Registration/Dockerfile . -t peterkneale/registration +docker build -f src/Admin/Dockerfile . -t peterkneale/admin +docker build -f src/Backend/Dockerfile . -t peterkneale/backend +docker build -f src/Frontend/Dockerfile . -t peterkneale/frontend +docker build -f src/Registration/Dockerfile . -t peterkneale/registration -docker push peterkneale/frontend docker push peterkneale/admin +docker push peterkneale/backend +docker push peterkneale/frontend docker push peterkneale/registration ``` diff --git a/src/Backend.Modules.Settings/Domain/SettingsAggregate/Settings.cs b/src/Backend.Modules.Settings/Domain/SettingsAggregate/Settings.cs index 02d563a..8abb00e 100644 --- a/src/Backend.Modules.Settings/Domain/SettingsAggregate/Settings.cs +++ b/src/Backend.Modules.Settings/Domain/SettingsAggregate/Settings.cs @@ -19,8 +19,10 @@ public void ResetTheme() public string? Theme { get; private set; } - public string GetTheme() => Theme ?? "Default"; + public string GetTheme() => Theme ?? DefaultThemeName; public static Settings Create() => new(); + + public const string DefaultThemeName = "Default"; } \ No newline at end of file diff --git a/src/Backend.Modules.Settings/Infrastructure/SettingsRepository.cs b/src/Backend.Modules.Settings/Infrastructure/SettingsRepository.cs index 5b1862e..8f47316 100644 --- a/src/Backend.Modules.Settings/Infrastructure/SettingsRepository.cs +++ b/src/Backend.Modules.Settings/Infrastructure/SettingsRepository.cs @@ -1,37 +1,32 @@ using Backend.Modules.Infrastructure.Repositories.Serialisation; -using Backend.Modules.Infrastructure.Tenancy; namespace Backend.Modules.Settings.Infrastructure; using static Backend.Modules.Infrastructure.Database.Constants; internal class SettingsRepository : ISettingsRepository { - private readonly IGetTenantContext _context; private readonly IDbConnection _connection; - public SettingsRepository(ITenantConnectionFactory factory, IGetTenantContext context) + public SettingsRepository(ITenantConnectionFactory factory) { - _context = context; _connection = factory.GetDbConnectionForTenant(); } public async Task Insert(Domain.SettingsAggregate.Settings settings, CancellationToken cancellationToken) { - const string sql = $"insert into {TableSettings} ({ColumnTenantId}, {ColumnData}) values (@id, @data::jsonb)"; + const string sql = $"insert into {TableSettings} ({ColumnData}) values (@data::jsonb)"; var json = JsonHelper.ToJson(settings); await _connection.ExecuteAsync(sql, new { - id = _context.CurrentTenant, data = json }); } public async Task Update(Domain.SettingsAggregate.Settings settings, CancellationToken cancellationToken) { - const string sql = $"update {TableSettings} set {ColumnData} = @data::jsonb where {ColumnTenantId} = @id"; + const string sql = $"update {TableSettings} set {ColumnData} = @data::jsonb"; var result = await _connection.ExecuteAsync(sql, new { - id = _context.CurrentTenant, data = JsonHelper.ToJson(settings) }); if (result != 1) @@ -42,11 +37,8 @@ public async Task Update(Domain.SettingsAggregate.Settings settings, Cancellatio public async Task Get(CancellationToken cancellationToken) { - const string sql = $"select {ColumnData} from {TableSettings} where {ColumnTenantId} = @id"; - var result = await _connection.QuerySingleOrDefaultAsync(sql, new - { - id = _context.CurrentTenant - }); + const string sql = $"select {ColumnData} from {TableSettings}"; + var result = await _connection.QuerySingleOrDefaultAsync(sql); return JsonHelper.ToObject(result); } } \ No newline at end of file diff --git a/src/Backend.Modules.Tenants/Api/TenantsApi.proto b/src/Backend.Modules.Tenants/Api/TenantsApi.proto index 06ec817..7afd37b 100644 --- a/src/Backend.Modules.Tenants/Api/TenantsApi.proto +++ b/src/Backend.Modules.Tenants/Api/TenantsApi.proto @@ -4,8 +4,7 @@ package Backend; option csharp_namespace = "Backend.Api"; -service TenantsApi { - +service TenantsApi { // commands rpc AddTenant(AddTenantRequest) returns (EmptyResponse2); rpc Claim(ClaimRequest) returns (EmptyResponse2); @@ -14,8 +13,7 @@ service TenantsApi { // queries rpc GetTenant(GetTenantRequest) returns (GetTenantResponse); rpc GetTenantByIdentifier(GetTenantByIdentifierRequest) returns (GetTenantByIdentifierResponse); - rpc ListTenants(ListTenantsRequest) returns (ListTenantsResponse); - + rpc ListTenants(ListTenantsRequest) returns (ListTenantsResponse); } // commands diff --git a/src/Backend.Modules.Widgets/Infrastructure/WidgetRepository.cs b/src/Backend.Modules.Widgets/Infrastructure/WidgetRepository.cs index 49f8fe0..046c45a 100644 --- a/src/Backend.Modules.Widgets/Infrastructure/WidgetRepository.cs +++ b/src/Backend.Modules.Widgets/Infrastructure/WidgetRepository.cs @@ -7,12 +7,10 @@ namespace Backend.Modules.Widgets.Infrastructure; internal class WidgetRepository : IWidgetRepository { private readonly IDbConnection _connection; - private readonly IGetTenantContext _context; - public WidgetRepository(ITenantConnectionFactory factory, IGetTenantContext context) + public WidgetRepository(ITenantConnectionFactory factory) { _connection = factory.GetDbConnectionForTenant(); - _context = context; } public async Task Get(WidgetId widgetId, CancellationToken cancellationToken) @@ -36,12 +34,11 @@ public async Task> List(CancellationToken cancellationToken) public async Task Insert(Widget widget, CancellationToken cancellationToken) { - const string sql = $"insert into {TableWidgets} ({ColumnId}, {ColumnTenantId}, {ColumnData}) values (@id, @tenant_id, @data::jsonb)"; + const string sql = $"insert into {TableWidgets} ({ColumnId}, {ColumnData}) values (@id, @data::jsonb)"; var json = JsonHelper.ToJson(widget); await _connection.ExecuteAsync(sql, new { id = widget.Id.Id, - tenant_id = _context.CurrentTenant, data = json }); } @@ -59,5 +56,4 @@ public async Task Update(Widget widget, CancellationToken cancellationToken) throw new Exception("Record not updated"); } } - } \ No newline at end of file diff --git a/src/Backend.Modules/Infrastructure/Database/Migration1.cs b/src/Backend.Modules/Infrastructure/Database/Migration1.cs index 0b1d32e..8c5d191 100644 --- a/src/Backend.Modules/Infrastructure/Database/Migration1.cs +++ b/src/Backend.Modules/Infrastructure/Database/Migration1.cs @@ -19,12 +19,12 @@ public override void Up() .WithColumn(Constants.ColumnData).AsCustom("jsonb").NotNullable(); Create.Table(Constants.TableSettings) - .WithColumn(Constants.ColumnTenantId).AsGuid().NotNullable().PrimaryKey() + .WithColumn(Constants.ColumnTenantId).AsGuid().NotNullable().PrimaryKey().WithDefaultValue(RawSql.Insert("current_setting('app.tenant_id')::uuid")) .WithColumn(Constants.ColumnData).AsCustom("jsonb").NotNullable(); Create.Table(Constants.TableWidgets) .WithColumn(Constants.ColumnId).AsGuid().NotNullable().PrimaryKey() - .WithColumn(Constants.ColumnTenantId).AsGuid().NotNullable() + .WithColumn(Constants.ColumnTenantId).AsGuid().NotNullable().WithDefaultValue(RawSql.Insert("current_setting('app.tenant_id')::uuid")) .WithColumn(Constants.ColumnData).AsCustom("jsonb").NotNullable(); // This table should have row level security that ensure a tenant can only manage their own data diff --git a/tests/Backend.Modules.Settings.IntegrationTests/Backend.Modules.Settings.IntegrationTests.csproj b/tests/Backend.Modules.Settings.IntegrationTests/Backend.Modules.Settings.IntegrationTests.csproj new file mode 100644 index 0000000..133cd44 --- /dev/null +++ b/tests/Backend.Modules.Settings.IntegrationTests/Backend.Modules.Settings.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerCollectionFixture.cs b/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerCollectionFixture.cs new file mode 100644 index 0000000..b12bfd0 --- /dev/null +++ b/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerCollectionFixture.cs @@ -0,0 +1,6 @@ +namespace Backend.Modules.Settings.IntegrationTests.Fixtures; + +[CollectionDefinition(nameof(ContainerCollectionFixture))] +public class ContainerCollectionFixture : ICollectionFixture +{ +} \ No newline at end of file diff --git a/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerFixture.cs b/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerFixture.cs new file mode 100644 index 0000000..75944e7 --- /dev/null +++ b/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ContainerFixture.cs @@ -0,0 +1,33 @@ +using Backend.Modules.Infrastructure.Database; + +namespace Backend.Modules.Settings.IntegrationTests.Fixtures; + +public class ContainerFixture : IDisposable +{ + private readonly ServiceProvider _provider; + + public ContainerFixture() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection() + .AddEnvironmentVariables() + .Build(); + + var services = new ServiceCollection() + .AddModules(configuration) + .AddSettings(configuration); + + services + .AddSingleton(configuration); + + _provider = services.BuildServiceProvider(); + _provider.ExecuteDatabaseMigration(x => x.ResetDatabase()); + } + + public IServiceProvider Provider => _provider; + + public void Dispose() + { + _provider.Dispose(); + } +} \ No newline at end of file diff --git a/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ProviderExtensions.cs b/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ProviderExtensions.cs new file mode 100644 index 0000000..5951e46 --- /dev/null +++ b/tests/Backend.Modules.Settings.IntegrationTests/Fixtures/ProviderExtensions.cs @@ -0,0 +1,35 @@ +using Backend.Modules.Infrastructure.Tenancy; + +namespace Backend.Modules.Settings.IntegrationTests.Fixtures; + +public static class ProviderExtensions +{ + public static async Task ExecuteCommand(this IServiceProvider provider, IRequest command, Guid? tenant = null) + { + using var scope = provider.CreateScope(); + if (tenant != null) + { + SetTenantContext(tenant.Value, scope); + } + var mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(command); + } + + public static async Task ExecuteQuery(this IServiceProvider provider, IRequest query, Guid? tenant = null) + { + using var scope = provider.CreateScope(); + if (tenant != null) + { + SetTenantContext(tenant.Value, scope); + } + var mediator = scope.ServiceProvider.GetRequiredService(); + return await mediator.Send(query); + } + + private static void SetTenantContext(Guid tenant, IServiceScope scope) + { + // resolve the tenant context so that it can be set for this use case + var setter = scope.ServiceProvider.GetRequiredService(); + setter.SetCurrentTenant(tenant); + } +} \ No newline at end of file diff --git a/tests/Backend.Modules.Settings.IntegrationTests/GlobalUsings.cs b/tests/Backend.Modules.Settings.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..a9fe0ba --- /dev/null +++ b/tests/Backend.Modules.Settings.IntegrationTests/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Global using directives +global using System; +global using System.Collections.Generic; +global using System.Threading.Tasks; +global using FluentAssertions; +global using MediatR; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Xunit; \ No newline at end of file diff --git a/tests/Backend.Modules.Settings.IntegrationTests/UseCases/ThemeTests.cs b/tests/Backend.Modules.Settings.IntegrationTests/UseCases/ThemeTests.cs new file mode 100644 index 0000000..4df1f19 --- /dev/null +++ b/tests/Backend.Modules.Settings.IntegrationTests/UseCases/ThemeTests.cs @@ -0,0 +1,73 @@ +using Backend.Modules.Settings.Application.Commands; +using Backend.Modules.Settings.Application.Queries; +using Backend.Modules.Settings.IntegrationTests.Fixtures; + +namespace Backend.Modules.Settings.IntegrationTests.UseCases; + +[Collection(nameof(ContainerCollectionFixture))] +public class ThemeTests +{ + private readonly IServiceProvider _provider; + + public ThemeTests(ContainerFixture container) + { + _provider = container.Provider; + } + + [Fact] + public async Task Can_get_initial_default_theme() + { + // arrange + var id = Guid.NewGuid(); + + // act + await AssertThemeIsDefault(id); + } + + [Fact] + public async Task Can_change_themes() + { + // arrange + var id = Guid.NewGuid(); + var theme1 = Guid.NewGuid().ToString(); + var theme2 = Guid.NewGuid().ToString(); + var theme3 = Guid.NewGuid().ToString(); + + // act && assert + await _provider.ExecuteCommand(new SetTheme.Command(theme1), id); + await AssertThemeIs(id, theme1); + + await _provider.ExecuteCommand(new SetTheme.Command(theme2), id); + await AssertThemeIs(id, theme2); + + await _provider.ExecuteCommand(new SetTheme.Command(theme3), id); + await AssertThemeIs(id, theme3); + } + + private async Task AssertThemeIs(Guid id, string theme1) + { + var result1 = await _provider.ExecuteQuery(new GetTheme.Query(), id); + result1.Should().Be(theme1); + } + + [Fact] + public async Task Can_reset_themes() + { + // arrange + var id = Guid.NewGuid(); + var theme = Guid.NewGuid().ToString(); + + // act + await _provider.ExecuteCommand(new SetTheme.Command(theme), id); + await _provider.ExecuteCommand(new ResetTheme.Command(), id); + + // assert + await AssertThemeIsDefault(id); + } + + private async Task AssertThemeIsDefault(Guid id) + { + var result = await _provider.ExecuteQuery(new GetTheme.Query(), id); + result.Should().Be(Domain.SettingsAggregate.Settings.DefaultThemeName); + } +} \ No newline at end of file