diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c2c05d12..31dec870f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,3 +92,18 @@ jobs: - name: Run view-sync working-directory: src/view-sync run: docker compose -f docker-compose.yml up --build --exit-code-from view-sync + + extern-sync: + runs-on: ubuntu-latest + name: Run extern-sync + + steps: + - uses: actions/checkout@v4 + + - name: Setup environment + working-directory: src/extern-sync + run: docker compose -f docker-compose.services.yml up --build --wait + + - name: Run extern-sync + working-directory: src/extern-sync + run: docker compose -f docker-compose.yml up --build --exit-code-from extern-sync diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 9095bbad1..57d9702ba 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -86,6 +86,21 @@ jobs: type=edge type=semver,pattern=v{{version}},value=${{ env.VERSION }} + - name: Collect Docker image metadata (extern-sync) + id: meta-extern-sync + uses: docker/metadata-action@v5 + with: + images: ${{ env.BASE_IMAGE_NAME }}-extern-sync + labels: | + org.opencontainers.image.created=${{ env.COMMITED_AT }} + org.opencontainers.image.version=v${{ env.VERSION }} + org.opencontainers.image.maintainer=GeoWerkstatt GmbH + flavor: | + latest=false + tags: | + type=edge + type=semver,pattern=v{{version}},value=${{ env.VERSION }} + - name: Log in to the GitHub container registry uses: docker/login-action@v3 with: @@ -109,7 +124,7 @@ jobs: - name: Build and push Docker image (api) uses: docker/build-push-action@v5 with: - context: ./src/api + context: ./src push: true build-args: | VERSION=${{ env.VERSION }} @@ -146,6 +161,19 @@ jobs: cache-from: type=registry,ref=${{ env.BASE_IMAGE_NAME }}-view-sync:edge cache-to: type=inline + - name: Build and push Docker image (extern-sync) + uses: docker/build-push-action@v5 + with: + context: ./src/extern-sync + push: true + build-args: | + VERSION=${{ env.VERSION }} + REVISION=${{ env.REVISION }} + tags: ${{ steps.meta-extern-sync.outputs.tags }} + labels: ${{ steps.meta-extern-sync.outputs.labels }} + cache-from: type=registry,ref=${{ env.BASE_IMAGE_NAME }}-extern-sync:edge + cache-to: type=inline + - name: Create GitHub pre-release run: | gh api \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1186638d5..9ea5a551a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,18 @@ jobs: tags: | type=semver,pattern=v{{version}} + - name: Collect Docker image metadata (extern-sync) + id: meta-extern-sync + uses: docker/metadata-action@v5 + with: + images: ${{ env.BASE_IMAGE_NAME }}-extern-sync + labels: | + org.opencontainers.image.created=${{ env.COMMITED_AT }} + org.opencontainers.image.version=v${{ env.VERSION }} + org.opencontainers.image.maintainer=GeoWerkstatt GmbH + tags: | + type=semver,pattern=v{{version}} + - name: Log in to the GitHub container registry uses: docker/login-action@v3 with: @@ -138,6 +150,19 @@ jobs: cache-from: type=registry,ref=${{ env.BASE_IMAGE_NAME }}-view-sync:edge cache-to: type=inline + - name: Build and push Docker image (extern-sync) + uses: docker/build-push-action@v5 + with: + context: ./src/extern-sync + push: true + build-args: | + VERSION=${{ env.VERSION }} + REVISION=${{ env.REVISION }} + tags: ${{ steps.meta-extern-sync.outputs.tags }} + labels: ${{ steps.meta-extern-sync.outputs.labels }} + cache-from: type=registry,ref=${{ env.BASE_IMAGE_NAME }}-extern-sync:edge + cache-to: type=inline + patch-changelog: runs-on: ubuntu-latest name: Patch CHANGELOG.md and update GitHub release notes diff --git a/BDMS.sln b/BDMS.sln index 6814ec6dd..6957d471a 100644 --- a/BDMS.sln +++ b/BDMS.sln @@ -17,6 +17,10 @@ Project("{54A90642-561A-4BB1-A94E-469ADEE60C69}") = "BDMS.Client", "src\client\B EndProject Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{2B4BC48D-B932-4CB4-B9D6-1336A9F64D79}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BDMS.ExternSync", "src\extern-sync\BDMS.ExternSync.csproj", "{951E6B71-6561-4FA7-9709-5D7F4E1D0DCA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BDMS.ExternSync.Test", "tests\extern-sync\BDMS.ExternSync.Test.csproj", "{7874F0AF-6311-4A8A-897A-C4DD82A97E6F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +45,14 @@ Global {2B4BC48D-B932-4CB4-B9D6-1336A9F64D79}.Debug|Any CPU.Build.0 = Debug|Any CPU {2B4BC48D-B932-4CB4-B9D6-1336A9F64D79}.Release|Any CPU.ActiveCfg = Release|Any CPU {2B4BC48D-B932-4CB4-B9D6-1336A9F64D79}.Release|Any CPU.Build.0 = Release|Any CPU + {951E6B71-6561-4FA7-9709-5D7F4E1D0DCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {951E6B71-6561-4FA7-9709-5D7F4E1D0DCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {951E6B71-6561-4FA7-9709-5D7F4E1D0DCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {951E6B71-6561-4FA7-9709-5D7F4E1D0DCA}.Release|Any CPU.Build.0 = Release|Any CPU + {7874F0AF-6311-4A8A-897A-C4DD82A97E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7874F0AF-6311-4A8A-897A-C4DD82A97E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7874F0AF-6311-4A8A-897A-C4DD82A97E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7874F0AF-6311-4A8A-897A-C4DD82A97E6F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docker-compose.yml b/docker-compose.yml index a232575c5..5ab5526d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,12 +93,14 @@ services: DB_PORT: 5432 api: build: - context: ./src/api + context: ./src + dockerfile: api/Dockerfile target: development ports: - 5000:5000 volumes: - ./src/api:/src + - ./src/Directory.Build.props:/Directory.Build.props - /src/bin - /src/obj depends_on: diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 000000000..1a80e118f --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + 8.0-all + true + GeoWerkstatt GmbH + GeoWerkstatt GmbH + $(MSBuildThisFileDirectory)/artifacts + https://github.com/swisstopo/swissgeol-boreholes-suite + https://github.com/swisstopo/swissgeol-boreholes-suite.git + git + true + + + + + all + runtime; build; native; contentfiles; analyzers + + + <_Parameter1>$(AssemblyName).Test + + + + diff --git a/src/api/BDMS.csproj b/src/api/BDMS.csproj index fba774a22..c17c54603 100644 --- a/src/api/BDMS.csproj +++ b/src/api/BDMS.csproj @@ -1,19 +1,7 @@ - net8.0 - enable - enable - true - 8.0-all CS1591,CS8618,CS8620 BDMS - GeoWerkstatt GmbH - GeoWerkstatt GmbH - $(MSBuildThisFileDirectory)/artifacts - https://github.com/swisstopo/swissgeol-boreholes-suite - https://github.com/swisstopo/swissgeol-boreholes-suite.git - git - true Linux ..\.. @@ -32,13 +20,6 @@ - - all - runtime; build; native; contentfiles; analyzers - - - <_Parameter1>$(AssemblyName).Test - diff --git a/src/api/BdmsContext.cs b/src/api/BdmsContext.cs index 3509d7edb..33ecb3aea 100644 --- a/src/api/BdmsContext.cs +++ b/src/api/BdmsContext.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using System.Security.Claims; +using static BDMS.BdmsContextConstants; namespace BDMS; @@ -96,7 +97,7 @@ public async Task UpdateChangeInformationAndSaveChangesAsync(HttpContext ht /// protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.HasDefaultSchema("bdms"); + modelBuilder.HasDefaultSchema(BoreholesDatabaseSchemaName); modelBuilder.Entity().HasKey(k => new { k.UserId, k.WorkgroupId, k.Role }); modelBuilder.Entity().HasKey(k => new { k.UserId, k.TermId }); diff --git a/src/api/BdmsContextConstants.cs b/src/api/BdmsContextConstants.cs new file mode 100644 index 000000000..7045ed0c1 --- /dev/null +++ b/src/api/BdmsContextConstants.cs @@ -0,0 +1,17 @@ +namespace BDMS; + +/// +/// constants. +/// +public static class BdmsContextConstants +{ + /// + /// The name of the boreholes database. + /// + public const string BoreholesDatabaseName = "bdms"; + + /// + /// The name of the boreholes database schema. + /// + public const string BoreholesDatabaseSchemaName = "bdms"; +} diff --git a/src/api/BdmsContextExtensions.cs b/src/api/BdmsContextExtensions.cs index 22e497fa0..783ac425c 100644 --- a/src/api/BdmsContextExtensions.cs +++ b/src/api/BdmsContextExtensions.cs @@ -4,17 +4,29 @@ using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; using System.Collections.ObjectModel; using System.Globalization; +using static BDMS.BdmsContextConstants; namespace BDMS; #pragma warning disable CA1505 /// -/// Contains extensions methods for the BDMS db context. +/// Contains extensions methods for the . /// public static class BdmsContextExtensions { + /// + /// Sets the default options for the boreholes database context. + /// + public static void SetDbContextOptions(this NpgsqlDbContextOptionsBuilder options) + { + options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + options.UseNetTopologySuite(); + options.MigrationsHistoryTable("__EFMigrationsHistory", BoreholesDatabaseName); + } + /// /// Seed test data but only if the database is not yet seeded. /// diff --git a/src/api/Dockerfile b/src/api/Dockerfile index ca855eace..ff99779c5 100644 --- a/src/api/Dockerfile +++ b/src/api/Dockerfile @@ -3,13 +3,16 @@ ENV ASPNETCORE_ENVIRONMENT=Development WORKDIR /src # Install missing packages -RUN apt-get -y update -RUN apt-get -y install git vim curl htop -RUN dotnet tool install --global dotnet-ef --version 8.0.0 +RUN \ + apt-get -y update && \ + apt-get -y install curl git htop vim && \ + rm -rf /var/lib/apt/lists/* && \ + dotnet tool install --global dotnet-ef --version 8.0.0 + ENV PATH=$PATH:/root/.dotnet/tools # Restore dependencies and tools -COPY BDMS.csproj . +COPY api/BDMS.csproj Directory.Build.props ./ RUN dotnet restore ENTRYPOINT ["dotnet", "watch", "run", "--no-launch-profile"] @@ -20,11 +23,11 @@ ARG REVISION WORKDIR /src # Restore dependencies and tools -COPY BDMS.csproj . +COPY api/BDMS.csproj Directory.Build.props ./ RUN dotnet restore # Create optimized production build -COPY . . +COPY api/ Directory.Build.props ./ RUN dotnet publish \ -c Release \ -p:UseAppHost=false \ diff --git a/src/api/Program.cs b/src/api/Program.cs index 43abdddd8..e8c1f083b 100644 --- a/src/api/Program.cs +++ b/src/api/Program.cs @@ -52,12 +52,7 @@ builder.Services.AddHttpClient(); var connectionString = builder.Configuration.GetConnectionString(nameof(BdmsContext)); -builder.Services.AddNpgsql(connectionString, options => -{ - options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); - options.UseNetTopologySuite(); - options.MigrationsHistoryTable("__EFMigrationsHistory", "bdms"); -}); +builder.Services.AddNpgsql(connectionString, BdmsContextExtensions.SetDbContextOptions); builder.Services.Configure(options => options.LowercaseUrls = true); builder.Services.AddSwaggerGen(options => diff --git a/src/extern-sync/BDMS.ExternSync.csproj b/src/extern-sync/BDMS.ExternSync.csproj new file mode 100644 index 000000000..324d7ef86 --- /dev/null +++ b/src/extern-sync/BDMS.ExternSync.csproj @@ -0,0 +1,28 @@ + + + + true + BDMS.ExternSync + Exe + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/extern-sync/Dockerfile b/src/extern-sync/Dockerfile new file mode 100644 index 000000000..e98ebcd96 --- /dev/null +++ b/src/extern-sync/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7-labs + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG VERSION +ARG REVISION +WORKDIR /src + +# Restore dependencies and tools +COPY --parents "extern-sync/BDMS.ExternSync.csproj" "api/BDMS.csproj" "Directory.Build.props" ./ +RUN dotnet restore "./extern-sync/BDMS.ExternSync.csproj" + +# Create optimized production build +COPY --parents "extern-sync/" "api/" ./ +RUN dotnet publish "./extern-sync/BDMS.ExternSync.csproj" \ + -c Release \ + -p:UseAppHost=false \ + -p:VersionPrefix=${VERSION} \ + -p:SourceRevisionId=${REVISION} \ + -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS deploy +ENV ASPNETCORE_ENVIRONMENT=Production +WORKDIR /app + +# Set default locale +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +COPY --from=build /app/publish . + +# Switch to the non-root user 'app' defined in the base image +USER $APP_UID +ENTRYPOINT ["dotnet", "BDMS.ExternSync.dll"] diff --git a/src/extern-sync/ISyncContext.cs b/src/extern-sync/ISyncContext.cs new file mode 100644 index 000000000..e157a09dd --- /dev/null +++ b/src/extern-sync/ISyncContext.cs @@ -0,0 +1,18 @@ +namespace BDMS.ExternSync; + +/// +/// Represents a boreholes sync context containing a source and +/// a target database . +/// +public interface ISyncContext +{ + /// + /// The source database context. + /// + BdmsContext Source { get; } + + /// + /// The target database context. + /// + BdmsContext Target { get; } +} diff --git a/src/extern-sync/ISyncTask.cs b/src/extern-sync/ISyncTask.cs new file mode 100644 index 000000000..13da6f558 --- /dev/null +++ b/src/extern-sync/ISyncTask.cs @@ -0,0 +1,12 @@ +namespace BDMS.ExternSync; + +/// +/// Represents a sync task that can be executed and validated. +/// +public interface ISyncTask +{ + /// + /// Executes and validates the sync task. + /// + Task ExecuteAndValidateAsync(CancellationToken cancellationToken); +} diff --git a/src/extern-sync/Program.cs b/src/extern-sync/Program.cs new file mode 100644 index 000000000..b5bdc262a --- /dev/null +++ b/src/extern-sync/Program.cs @@ -0,0 +1,36 @@ +using BDMS.ExternSync; +using BDMS.ExternSync.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using static BDMS.ExternSync.SyncContextConstants; + +using var app = Host.CreateDefaultBuilder(args).ConfigureServices((context, services) => +{ + // Register source and target database contexts + string GetConnectionString(string name) => + context.Configuration.GetConnectionString(name) ?? throw new InvalidOperationException($"Connection string <{name}> not found."); + + services.AddNpgsqlDataSource(GetConnectionString(SourceBdmsContextName), serviceKey: SourceBdmsContextName); + services.AddNpgsqlDataSource(GetConnectionString(TargetBdmsContextName), serviceKey: TargetBdmsContextName); + services.AddTransient(); + + // Register tasks. The order specified here is the order in which they will be executed. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Register task manager + services.AddScoped(); +}) +.Build(); + +// Execute tasks +using var scope = app.Services.CreateScope(); +using var cancellationTokenSource = new CancellationTokenSource(); + +await scope.ServiceProvider.GetRequiredService() + .ExecuteTasksAsync(cancellationTokenSource.Token) + .ConfigureAwait(false); diff --git a/src/extern-sync/README.md b/src/extern-sync/README.md new file mode 100644 index 000000000..d18accf1e --- /dev/null +++ b/src/extern-sync/README.md @@ -0,0 +1,30 @@ +# Extern-Sync + +## 🎯 Purpose + +*Extern-Sync* is a .NET application to sync selected drilling data between a source and a target database. It consists of a couple of *sync tasks* that are executed in sequence. + +The application is packed into a Docker container and can be run in a containerized environment. The container exits with a non-zero exit code if any of the *sync tasks* fails. + +## 🛠️ Configuration + +The application is configured using environment variables. The following environment variables are mandatory: + +- `CONNECTIONSTRINGS__SourceBdmsContext`: The connection string to the source database. +- `CONNECTIONSTRINGS__TargetBdmsContext`: The connection string to the target database. + +## 🧪 Unit Tests + +According to your needs, *sync tasks* can either be executed in a *in-memory* or a real PostgreSQL database using [Testcontainers](https://testcontainers.com/modules/postgresql/). + +## 🚀 Usage + +A full integration test can be run by executing the following commands: + +```bash +# Setup environment (source and target databases) +docker compose -f docker-compose.services.yml up --wait + +# Start the extern-sync container +docker compose down && docker compose up --build +``` diff --git a/src/extern-sync/SyncContext.cs b/src/extern-sync/SyncContext.cs new file mode 100644 index 000000000..abd8b3510 --- /dev/null +++ b/src/extern-sync/SyncContext.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Data.Common; +using static BDMS.ExternSync.SyncContextConstants; +using static BDMS.ExternSync.SyncContextExtensions; + +namespace BDMS.ExternSync; + +/// +/// Represents a boreholes sync context containing a source and +/// a target database . +/// +public class SyncContext( + [FromKeyedServices(SourceBdmsContextName)] DbConnection sourceDbConnection, + [FromKeyedServices(TargetBdmsContextName)] DbConnection targetDbConnection) + : ISyncContext, IDisposable +{ + private bool disposedValue; + + /// + public BdmsContext Source { get; } = new BdmsContext(GetDbContextOptions(sourceDbConnection)); + + /// + public BdmsContext Target { get; } = new BdmsContext(GetDbContextOptions(targetDbConnection)); + + /// + /// Disposes the and database contexts. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue && disposing) + { + Source.Dispose(); + Target.Dispose(); + + disposedValue = true; + } + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/extern-sync/SyncContextConstants.cs b/src/extern-sync/SyncContextConstants.cs new file mode 100644 index 000000000..0b90701a9 --- /dev/null +++ b/src/extern-sync/SyncContextConstants.cs @@ -0,0 +1,27 @@ +using Npgsql; + +namespace BDMS.ExternSync; + +/// +/// constants. +/// +public static class SyncContextConstants +{ + /// + /// The name of the source . + /// + /// + /// This identifier is used to register multiple + /// services with different connection strings. + /// + public const string SourceBdmsContextName = "SourceBdmsContext"; + + /// + /// The name of the target . + /// + /// + /// This identifier is used to register multiple + /// services with different connection strings. + /// + public const string TargetBdmsContextName = "TargetBdmsContext"; +} diff --git a/src/extern-sync/SyncContextExtensions.cs b/src/extern-sync/SyncContextExtensions.cs new file mode 100644 index 000000000..24e41047d --- /dev/null +++ b/src/extern-sync/SyncContextExtensions.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using System.Data; +using System.Data.Common; + +namespace BDMS.ExternSync; + +/// +/// extension methods. +/// +public static class SyncContextExtensions +{ + /// + /// Gets and opens the for the specified . + /// + public static async Task GetAndOpenDbConnectionAsync(this BdmsContext context, CancellationToken cancellationToken = default) + { + var dbConnection = (NpgsqlConnection)context.Database.GetDbConnection(); + if (dbConnection.State != ConnectionState.Open) + { + await dbConnection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + return dbConnection; + } + + /// + /// Gets the database schema version for the specified . + /// + public static async Task GetDbSchemaVersionAsync(this BdmsContext context, CancellationToken cancellationToken = default) + { + var migrations = await context.Database.GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false); + return migrations.LastOrDefault(); + } + + /// + /// Cleans up superfluous data in the boreholes database. After applying database migrations to a new/empty database, + /// the database contains data that is not meant to be present in the production environment. This method removes this data. + /// + public static async Task CleanUpSuperfluousDataAsync(this BdmsContext context, CancellationToken cancellationToken = default) + { + var usersToRemove = await context.Users + .Where(u => u.SubjectId.StartsWith("sub_")) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + context.Users.RemoveRange(usersToRemove); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the for the specified . + /// + public static DbContextOptions GetDbContextOptions(DbConnection dbConnection) => + new DbContextOptionsBuilder().UseNpgsql(dbConnection, options => BdmsContextExtensions.SetDbContextOptions(options)).Options; + + /// + /// Gets the for the specified . + /// + public static DbContextOptions GetDbContextOptions(string connectionString) => + new DbContextOptionsBuilder().UseNpgsql(connectionString, BdmsContextExtensions.SetDbContextOptions).Options; +} diff --git a/src/extern-sync/SyncTask.cs b/src/extern-sync/SyncTask.cs new file mode 100644 index 000000000..400e60c7f --- /dev/null +++ b/src/extern-sync/SyncTask.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Logging; + +namespace BDMS.ExternSync; + +/// +/// Represents a sync task containing source and target database contexts +/// that can be executed and validated. +/// +/// The sync context. +/// The logger for this instance. +public abstract class SyncTask(ISyncContext syncContext, ILogger logger) + : IDisposable, ISyncTask +{ + private bool disposedValue; + + /// + /// The source database context. + /// + protected BdmsContext Source { get; } = syncContext.Source; + + /// + /// The target database context. + /// + protected BdmsContext Target { get; } = syncContext.Target; + + /// + /// The logger for the . + /// + protected ILogger Logger { get; } = logger; + + /// + public async Task ExecuteAndValidateAsync(CancellationToken cancellationToken) + { + await RunTaskAsync(cancellationToken).ConfigureAwait(false); + await ValidateTaskAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs the . + /// + protected abstract Task RunTaskAsync(CancellationToken cancellationToken); + + /// + /// Validates the result after the has been executed. + /// + protected abstract Task ValidateTaskAsync(CancellationToken cancellationToken); + + /// + /// Disposes the and database contexts. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue && disposing) + { + Source.Dispose(); + Target.Dispose(); + + disposedValue = true; + } + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/extern-sync/SyncTaskManager.cs b/src/extern-sync/SyncTaskManager.cs new file mode 100644 index 000000000..a02928e71 --- /dev/null +++ b/src/extern-sync/SyncTaskManager.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Logging; + +namespace BDMS.ExternSync; + +/// +/// Represents a task manager that executes and validates a collection of in sequence. +/// +/// A collection of s to execute in sequence. +/// The logger for this instance. +public class SyncTaskManager(IEnumerable tasks, ILogger logger) +{ + /// + /// Executes the s in sequence. + /// + public async Task ExecuteTasksAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Queued tasks: {TaskNames}", string.Join(", ", tasks.Select(t => t.GetType().Name))); + + foreach (var task in tasks) + { + logger.LogInformation("Executing task {TaskName}...", task.GetType().Name); + await task.ExecuteAndValidateAsync(cancellationToken).ConfigureAwait(false); + } + + logger.LogInformation("All tasks have been executed successfully."); + } +} diff --git a/src/extern-sync/Tasks/CollectInformationTask.cs b/src/extern-sync/Tasks/CollectInformationTask.cs new file mode 100644 index 000000000..147359760 --- /dev/null +++ b/src/extern-sync/Tasks/CollectInformationTask.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; + +namespace BDMS.ExternSync.Tasks; + +/// +/// Collects some information about the source and target databases +/// and checks if they are not the same. +/// +public class CollectInformationTask(ISyncContext syncContext, ILogger logger) : SyncTask(syncContext, logger) +{ + /// + protected override async Task RunTaskAsync(CancellationToken cancellationToken) + { + // Log the source and target database connection information + var source = await Source.GetAndOpenDbConnectionAsync(cancellationToken).ConfigureAwait(false); + var target = await Target.GetAndOpenDbConnectionAsync(cancellationToken).ConfigureAwait(false); + + Logger.LogInformation( + "Source database: {SourceDatabase}\nTarget database: {TargetDatabase}", + $"{source.Database}@{source.Host}:{source.Port}", + $"{target.Database}@{target.Host}:{target.Port}"); + } + + /// + protected override async Task ValidateTaskAsync(CancellationToken cancellationToken) + { + // Check if the source and target databases are the same + var source = await Source.GetAndOpenDbConnectionAsync(cancellationToken).ConfigureAwait(false); + var target = await Target.GetAndOpenDbConnectionAsync(cancellationToken).ConfigureAwait(false); + + if (source.Database == target.Database && source.Host == target.Host && source.Port == target.Port) + { + throw new InvalidOperationException("Source and target databases cannot be the same."); + } + } +} diff --git a/src/extern-sync/Tasks/SetupDatabaseTask.cs b/src/extern-sync/Tasks/SetupDatabaseTask.cs new file mode 100644 index 000000000..c620a0050 --- /dev/null +++ b/src/extern-sync/Tasks/SetupDatabaseTask.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace BDMS.ExternSync.Tasks; + +/// +/// Setups the target database by migrating the schema and checks whether the source +/// and target databases have the same schema version. +/// +/// +/// IMPORTANT! This class does not yet implement the actual behavior. It only +/// contains sample code to verify the testing and integration concepts. +/// +public class SetupDatabaseTask(ISyncContext syncContext, ILogger logger) : SyncTask(syncContext, logger) +{ + /// + protected override async Task RunTaskAsync(CancellationToken cancellationToken) + { + // Check the target database schema version and migrate if necessary + var targetDbSchemaVersion = await Target.GetDbSchemaVersionAsync(cancellationToken).ConfigureAwait(false); + if (targetDbSchemaVersion.IsNullOrEmpty()) + { + Logger.LogInformation("The target database hat not been migrated yet.\nInitializing migration..."); + await Target.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + + Logger.LogInformation("Clean-up superfluous data..."); + await Target.CleanUpSuperfluousDataAsync(cancellationToken).ConfigureAwait(false); + } + + // Log the source and target database schema versions + Logger.LogInformation( + "Source database schema version: {SourceDatabase}\nTarget database schema version: {TargetDatabase}", + await Source.GetDbSchemaVersionAsync(cancellationToken).ConfigureAwait(false), + await Target.GetDbSchemaVersionAsync(cancellationToken).ConfigureAwait(false)); + } + + /// + protected override async Task ValidateTaskAsync(CancellationToken cancellationToken) + { + var sourceDbSchemaVersion = await Source.GetDbSchemaVersionAsync(cancellationToken).ConfigureAwait(false); + var targetDbSchemaVersion = await Target.GetDbSchemaVersionAsync(cancellationToken).ConfigureAwait(false); + if (sourceDbSchemaVersion != targetDbSchemaVersion) + { + throw new InvalidOperationException( + $"Source and target databases have different schema versions\n" + + $"Source: <{sourceDbSchemaVersion}>, Target: <{targetDbSchemaVersion}>"); + } + } +} diff --git a/src/extern-sync/Tasks/SynchronizeUsersTask.cs b/src/extern-sync/Tasks/SynchronizeUsersTask.cs new file mode 100644 index 000000000..b7f967231 --- /dev/null +++ b/src/extern-sync/Tasks/SynchronizeUsersTask.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace BDMS.ExternSync.Tasks; + +/// +/// Synchronizes users from the source database to the target database. +/// +/// +/// IMPORTANT! This class does not yet implement the actual behavior. It only +/// contains sample code to verify the testing and integration concepts. +/// +public class SynchronizeUsersTask(ISyncContext syncContext, ILogger logger) : SyncTask(syncContext, logger) +{ + /// + protected override async Task RunTaskAsync(CancellationToken cancellationToken) + { + var sourceUsers = await Source.Users.ToListAsync(cancellationToken).ConfigureAwait(false); + var targetUsers = await Target.Users.ToListAsync(cancellationToken).ConfigureAwait(false); + + var usersToInsert = sourceUsers + .Where(sourceUser => targetUsers.TrueForAll(targetUser => targetUser.Id != sourceUser.Id)) + .ToList(); + + Target.Users.AddRange(usersToInsert); + + await Target.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + protected override async Task ValidateTaskAsync(CancellationToken cancellationToken) + { + // Ensure that the number of users in the source and target databases is the same + var sourceUserCount = await Source.Users.CountAsync(cancellationToken).ConfigureAwait(false); + var targetUserCount = await Target.Users.CountAsync(cancellationToken).ConfigureAwait(false); + + if (sourceUserCount != targetUserCount) + { + throw new InvalidOperationException( + $"The number of users in the source and target databases is different\n" + + $"Source: {sourceUserCount}, Target: {targetUserCount}"); + } + } +} diff --git a/src/extern-sync/Tasks/UpdateSequencesTask.cs b/src/extern-sync/Tasks/UpdateSequencesTask.cs new file mode 100644 index 000000000..f24b30ff6 --- /dev/null +++ b/src/extern-sync/Tasks/UpdateSequencesTask.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Logging; +using Npgsql; +using static BDMS.BdmsContextConstants; + +namespace BDMS.ExternSync.Tasks; + +/// +/// Sets the sequences in the target database. +/// +/// +/// IMPORTANT! This class does not yet implement the actual behavior. It only +/// contains sample code to verify the testing and integration concepts. +/// +public class UpdateSequencesTask(ISyncContext syncContext, ILogger logger) : SyncTask(syncContext, logger) +{ + private const int MinValue = 20000; + private const string SequenceName = "users_id_usr_seq"; + private const string GetSequenceLastValueSql = $"SELECT last_value FROM {BoreholesDatabaseSchemaName}.{SequenceName};"; + private const string SetSequenceValueQuery = $"SELECT setval('{BoreholesDatabaseSchemaName}.{SequenceName}', ($1));"; + + /// + protected override async Task RunTaskAsync(CancellationToken cancellationToken) + { + var targetDbConnection = await Target.GetAndOpenDbConnectionAsync(cancellationToken).ConfigureAwait(false); + using var selectCommand = new NpgsqlCommand(GetSequenceLastValueSql, targetDbConnection); + + var lastValue = await selectCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as long? ?? -1; + Logger.LogInformation("{SchemaName}.{SequenceName} last_value: <{LastValue}>", BoreholesDatabaseSchemaName, SequenceName, lastValue); + + if (lastValue == -1) + { + Logger.LogError("Error while reading sequence {SchemaName}.{SequenceName}.", BoreholesDatabaseSchemaName, SequenceName); + } + else if (lastValue < MinValue) + { + using var alterCommand = new NpgsqlCommand(SetSequenceValueQuery, targetDbConnection); + alterCommand.Parameters.AddWithValue(MinValue); + + await alterCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + + Logger.LogInformation("Sequence {SchemaName}.{SequenceName} has been set to {MinValue}.", BoreholesDatabaseSchemaName, SequenceName, MinValue); + } + else + { + Logger.LogInformation("Sequence for {SchemaName}.{SequenceName} has already been set.", BoreholesDatabaseSchemaName, SequenceName); + } + } + + /// + protected override async Task ValidateTaskAsync(CancellationToken cancellationToken) => + await Task.FromResult(true).ConfigureAwait(false); +} diff --git a/src/extern-sync/config/pgadmin4-servers.json b/src/extern-sync/config/pgadmin4-servers.json new file mode 100644 index 000000000..c7304f64b --- /dev/null +++ b/src/extern-sync/config/pgadmin4-servers.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "source-db", + "Group": "Servers", + "Host": "db", + "Port": 5432, + "MaintenanceDB": "bdms", + "Username": "SPAWNPLOW", + "PassFile": "/tmp/.pgpass" + }, + "2": { + "Name": "target-db", + "Group": "Servers", + "Host": "target-db", + "Port": 5432, + "MaintenanceDB": "WAXDIONYSUS", + "Username": "CHOCOLATESLAW", + "PassFile": "/tmp/.pgpass" + } + } + } diff --git a/src/extern-sync/docker-compose.services.yml b/src/extern-sync/docker-compose.services.yml new file mode 100644 index 000000000..33937ab54 --- /dev/null +++ b/src/extern-sync/docker-compose.services.yml @@ -0,0 +1,54 @@ +services: + db: + extends: + file: ../../docker-compose.yml + service: db + # override the default configuration + volumes: + - /var/lib/postgresql/data + target-db: + image: postgis/postgis:15-3.4-alpine + restart: unless-stopped + ports: + - 5433:5432 + volumes: + - ../../db:/docker-entrypoint-initdb.d + environment: + POSTGRES_USER: CHOCOLATESLAW + POSTGRES_PASSWORD: FRUGALCLUSTER + POSTGRES_DB: WAXDIONYSUS + pgadmin: + image: dpage/pgadmin4 + restart: unless-stopped + ports: + - 3050:80 + environment: + PGADMIN_DEFAULT_EMAIL: pgadmin@example.com + PGADMIN_DEFAULT_PASSWORD: ROADBOUNCE + PGADMIN_CONFIG_SERVER_MODE: "False" + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" + volumes: + - ./config/pgadmin4-servers.json:/pgadmin4/servers.json + entrypoint: + - /bin/sh + - -c + - | + /bin/echo 'db:5432:bdms:SPAWNPLOW:YELLOWSPATULA' > /tmp/.pgpass + /bin/echo 'target-db:5432:WAXDIONYSUS:CHOCOLATESLAW:FRUGALCLUSTER' >> /tmp/.pgpass + chmod 0600 /tmp/.pgpass + /entrypoint.sh + api: + extends: + file: ../../docker-compose.yml + service: api + build: + args: + - VERSION=0.0.99 + - REVISION=test + environment: + # use development environment to enable seeding the database + - ASPNETCORE_ENVIRONMENT=Development + minio: + extends: + file: ../../docker-compose.yml + service: minio diff --git a/src/extern-sync/docker-compose.yml b/src/extern-sync/docker-compose.yml new file mode 100644 index 000000000..89274793b --- /dev/null +++ b/src/extern-sync/docker-compose.yml @@ -0,0 +1,13 @@ +services: + extern-sync: + build: + context: ../ + dockerfile: extern-sync/Dockerfile + args: + - VERSION=0.0.99 + - REVISION=dev + environment: + - CONNECTIONSTRINGS__SourceBdmsContext=Host=host.docker.internal;Port=5432;Database=bdms;Username=SPAWNPLOW;Password=YELLOWSPATULA + - CONNECTIONSTRINGS__TargetBdmsContext=Host=host.docker.internal;Port=5433;Database=WAXDIONYSUS;Username=CHOCOLATESLAW;Password=FRUGALCLUSTER + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 000000000..39730ba2f --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,12 @@ +# boreholes Test Rules +# Description: Ignores rules that are not useful in test code because we want to test these edge cases + +[*.cs] +dotnet_diagnostic.CA1001.severity = none +dotnet_diagnostic.CA2000.severity = none +dotnet_diagnostic.CS1591.severity = none +dotnet_diagnostic.CS8604.severity = none +dotnet_diagnostic.CS8618.severity = none +dotnet_diagnostic.CS8620.severity = none +dotnet_diagnostic.CS8625.severity = none +dotnet_diagnostic.CS8629.severity = none diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 000000000..4a6c2507e --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + $(MSBuildProjectName.Replace(" ", "_").Replace(".Test", "")) + true + false + true + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/tests/api/BDMS.Test.csproj b/tests/api/BDMS.Test.csproj index 7b0ad1167..68c1d325a 100644 --- a/tests/api/BDMS.Test.csproj +++ b/tests/api/BDMS.Test.csproj @@ -1,26 +1,8 @@ - - true - net8.0 - enable - enable - false - BDMS - true - CS1591,CS8604,CS8618,CS8620,CS8629,CA1001,CA1014,CS8625,CA2000,CA2007 - + - - - - - - - all - runtime; build; native; contentfiles; analyzers - diff --git a/tests/api/ContextFactory.cs b/tests/api/ContextFactory.cs index 415d7e5c8..aac3281ba 100644 --- a/tests/api/ContextFactory.cs +++ b/tests/api/ContextFactory.cs @@ -10,18 +10,8 @@ internal static class ContextFactory /// Creates an instance of . /// /// The initialized . - public static BdmsContext CreateContext() - { - return new BdmsContext( - new DbContextOptionsBuilder().UseNpgsql( - ConnectionString, - options => - { - options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); - options.UseNetTopologySuite(); - options.MigrationsHistoryTable("__EFMigrationsHistory", "bdms"); - }).Options); - } + public static BdmsContext CreateContext() => + new(new DbContextOptionsBuilder().UseNpgsql(ConnectionString, BdmsContextExtensions.SetDbContextOptions).Options); /// /// Creates a new DbContext and starts a transaction. diff --git a/tests/extern-sync/BDMS.ExternSync.Test.csproj b/tests/extern-sync/BDMS.ExternSync.Test.csproj new file mode 100644 index 000000000..c11a03fad --- /dev/null +++ b/tests/extern-sync/BDMS.ExternSync.Test.csproj @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + initdb.d\01-schema.sql + PreserveNewest + + + initdb.d\02-geolcodes.sql + PreserveNewest + + + initdb.d\03-data.sql + PreserveNewest + + + + diff --git a/tests/extern-sync/SyncTaskManagerTest.cs b/tests/extern-sync/SyncTaskManagerTest.cs new file mode 100644 index 000000000..d3179139f --- /dev/null +++ b/tests/extern-sync/SyncTaskManagerTest.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; +using Moq; + +namespace BDMS.ExternSync; + +[TestClass] +public class SyncTaskManagerTest +{ + private List> syncTasks; + + [TestInitialize] + public void Initialize() + { + syncTasks = + [ + new Mock(MockBehavior.Strict), + new Mock(MockBehavior.Strict), + new Mock(MockBehavior.Strict) + ]; + } + + [TestCleanup] + public void Cleanup() => syncTasks.ForEach(t => t.VerifyAll()); + + [TestMethod] + public async Task ExecutesTasksInSequence() + { + var mockSequence = new MockSequence(); + syncTasks[0].InSequence(mockSequence).Setup(t => t.ExecuteAndValidateAsync(It.IsAny())).Returns(Task.CompletedTask); + syncTasks[1].InSequence(mockSequence).Setup(t => t.ExecuteAndValidateAsync(It.IsAny())).Returns(Task.CompletedTask); + syncTasks[2].InSequence(mockSequence).Setup(t => t.ExecuteAndValidateAsync(It.IsAny())).Returns(Task.CompletedTask); + + var syncTaskManager = new SyncTaskManager(syncTasks.Select(t => t.Object).ToList(), Mock.Of>()); + await syncTaskManager.ExecuteTasksAsync(Mock.Of().Token); + + syncTasks[0].Verify(t => t.ExecuteAndValidateAsync(It.IsAny()), Times.Once); + syncTasks[1].Verify(t => t.ExecuteAndValidateAsync(It.IsAny()), Times.Once); + syncTasks[2].Verify(t => t.ExecuteAndValidateAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task StopsExecutingOnFailure() + { + var mockSequence = new MockSequence(); + syncTasks[0].InSequence(mockSequence).Setup(t => t.ExecuteAndValidateAsync(It.IsAny())).Returns(Task.CompletedTask); + syncTasks[1].InSequence(mockSequence).Setup(t => t.ExecuteAndValidateAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("BLACKTOLL SQUEAKY.")); + syncTasks[2].InSequence(mockSequence).Setup(t => t.ExecuteAndValidateAsync(It.IsAny())).Returns(Task.CompletedTask); + + var syncTaskManager = new SyncTaskManager(syncTasks.Select(t => t.Object).ToList(), Mock.Of>()); + + var exception = await Assert.ThrowsExceptionAsync(() => + syncTaskManager.ExecuteTasksAsync(Mock.Of().Token)); + + Assert.AreEqual("BLACKTOLL SQUEAKY.", exception.Message); + + syncTasks[0].Verify(t => t.ExecuteAndValidateAsync(It.IsAny()), Times.Once); + syncTasks[1].Verify(t => t.ExecuteAndValidateAsync(It.IsAny()), Times.Once); + syncTasks[2].Verify(t => t.ExecuteAndValidateAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/extern-sync/Tasks/CollectInformationTaskTest.cs b/tests/extern-sync/Tasks/CollectInformationTaskTest.cs new file mode 100644 index 000000000..28978db78 --- /dev/null +++ b/tests/extern-sync/Tasks/CollectInformationTaskTest.cs @@ -0,0 +1,18 @@ +using BDMS.ExternSync.Tasks; +using Microsoft.Extensions.Logging; +using Moq; + +namespace BDMS.ExternSync; + +[TestClass] +public class CollectInformationTaskTest +{ + [TestMethod] + public async Task CollectInformation() + { + using var syncContext = await TestSyncContext.BuildAsync().ConfigureAwait(false); + using var syncTask = new CollectInformationTask(syncContext, new Mock>().Object); + + await syncTask.ExecuteAndValidateAsync(Mock.Of().Token); + } +} diff --git a/tests/extern-sync/Tasks/MigrateDatabaseTaskTest.cs b/tests/extern-sync/Tasks/MigrateDatabaseTaskTest.cs new file mode 100644 index 000000000..7b563e03d --- /dev/null +++ b/tests/extern-sync/Tasks/MigrateDatabaseTaskTest.cs @@ -0,0 +1,18 @@ +using BDMS.ExternSync.Tasks; +using Microsoft.Extensions.Logging; +using Moq; + +namespace BDMS.ExternSync; + +[TestClass] +public class MigrateDatabaseTaskTest +{ + [TestMethod] + public async Task MigrateDatabase() + { + using var syncContext = await TestSyncContext.BuildAsync().ConfigureAwait(false); + using var syncTask = new SetupDatabaseTask(syncContext, new Mock>().Object); + + await syncTask.ExecuteAndValidateAsync(Mock.Of().Token); + } +} diff --git a/tests/extern-sync/Tasks/SynchronizeUsersTaskTest.cs b/tests/extern-sync/Tasks/SynchronizeUsersTaskTest.cs new file mode 100644 index 000000000..031b7d803 --- /dev/null +++ b/tests/extern-sync/Tasks/SynchronizeUsersTaskTest.cs @@ -0,0 +1,30 @@ +using BDMS.ExternSync.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; + +namespace BDMS.ExternSync; + +[TestClass] +public class SynchronizeUsersTaskTest +{ + [TestMethod] + public async Task SynchronizeUsers() + { + using var syncContext = await TestSyncContext.BuildAsync(useInMemory: true).ConfigureAwait(false); + using var syncTask = new SynchronizeUsersTask(syncContext, Mock.Of>()); + + await syncContext.SeedUserTestDataAsync().ConfigureAwait(false); + var (source, target) = (syncContext.Source, syncContext.Target); + + Assert.AreNotEqual(source.Users.CountAsync().Result, target.Users.CountAsync().Result); + Assert.AreEqual(4, await source.Users.CountAsync()); + Assert.AreEqual(1, await target.Users.CountAsync()); + + await syncTask.ExecuteAndValidateAsync(Mock.Of().Token); + + Assert.AreEqual(source.Users.CountAsync().Result, target.Users.CountAsync().Result); + Assert.AreEqual(4, await source.Users.CountAsync()); + Assert.AreEqual(4, await target.Users.CountAsync()); + } +} diff --git a/tests/extern-sync/Tasks/UpdateSequencesTaskTest.cs b/tests/extern-sync/Tasks/UpdateSequencesTaskTest.cs new file mode 100644 index 000000000..9c04afd6d --- /dev/null +++ b/tests/extern-sync/Tasks/UpdateSequencesTaskTest.cs @@ -0,0 +1,26 @@ +using BDMS.ExternSync.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Npgsql; + +namespace BDMS.ExternSync; + +[TestClass] +public class UpdateSequencesTaskTest +{ + [TestMethod] + public async Task UpdateSequences() + { + using var syncContext = await TestSyncContext.BuildAsync().ConfigureAwait(false); + using var syncTask = new UpdateSequencesTask(syncContext, new Mock>().Object); + + var targetDbConnection = await syncContext.Target.GetAndOpenDbConnectionAsync().ConfigureAwait(false); + using var selectCommand = new NpgsqlCommand($"SELECT last_value FROM bdms.users_id_usr_seq;", targetDbConnection); + + Assert.AreEqual(8L, await selectCommand.ExecuteScalarAsync()); + + await syncTask.ExecuteAndValidateAsync(Mock.Of().Token); + + Assert.AreEqual(20000L, await selectCommand.ExecuteScalarAsync()); + } +} diff --git a/tests/extern-sync/TestSyncContext.cs b/tests/extern-sync/TestSyncContext.cs new file mode 100644 index 000000000..036afa65d --- /dev/null +++ b/tests/extern-sync/TestSyncContext.cs @@ -0,0 +1,64 @@ +using static BDMS.ExternSync.TestSyncContextExtensions; + +namespace BDMS.ExternSync; + +/// +/// Represents a containing a source and target +/// for testing purposes. The +/// can either use a real PostgreSQL database or an in-memory context. +/// +internal class TestSyncContext : ISyncContext, IDisposable +{ + private bool disposedValue; + + /// + public BdmsContext Source { get; } + + /// + public BdmsContext Target { get; } + + /// + /// Initializes a new instance of the class. + /// + private TestSyncContext(BdmsContext source, BdmsContext target) + { + Source = source; + Target = target; + } + + /// + /// Builds a new instance of the class. + /// + /// By default a real PostgreSQL database is used + /// when source and target database contexts are created. This allows to execute + /// raw SQL queries but comes with a performance penalty. When set to true + /// an in-memory database is used instead. + public static async Task BuildAsync(bool useInMemory = false) + { + var source = CreateDbContextAsync(useInMemory); + var target = CreateDbContextAsync(useInMemory); + await Task.WhenAll(source, target).ConfigureAwait(false); + return new TestSyncContext(source.Result, target.Result); + } + + /// + /// Disposes the and database contexts. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue && disposing) + { + Source.Dispose(); + Target.Dispose(); + + disposedValue = true; + } + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/extern-sync/TestSyncContextExtensions.cs b/tests/extern-sync/TestSyncContextExtensions.cs new file mode 100644 index 000000000..ba638732b --- /dev/null +++ b/tests/extern-sync/TestSyncContextExtensions.cs @@ -0,0 +1,60 @@ +using BDMS.Models; +using DotNet.Testcontainers.Builders; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; +using static BDMS.BdmsContextConstants; +using static BDMS.ExternSync.SyncContextExtensions; + +namespace BDMS.ExternSync; + +/// +/// extension methods. +/// +internal static class TestSyncContextExtensions +{ + internal static async Task SeedUserTestDataAsync(this TestSyncContext syncContext) + { + var (source, target) = (syncContext.Source, syncContext.Target); + + source.Users.Add(new User { Id = 1, FirstName = "John", LastName = "Doe", Name = "John Doe", SubjectId = "doe123" }); + source.Users.Add(new User { Id = 2, FirstName = "Jane", LastName = "Doe", Name = "Jane Doe", SubjectId = "doe456" }); + source.Users.Add(new User { Id = 3, FirstName = "Alice", LastName = "Smith", Name = "Alice Smith", SubjectId = "smith789" }); + source.Users.Add(new User { Id = 4, FirstName = "Bob", LastName = "Smith", Name = "Bob Smith", SubjectId = "smith101" }); + await source.SaveChangesAsync().ConfigureAwait(false); + + target.Users.Add(new User { Id = 1, FirstName = "Charlie", LastName = "Brown", Name = "Charlie Brown", SubjectId = "brown123" }); + await target.SaveChangesAsync().ConfigureAwait(false); + } + + /// + /// Creates a new for testing purposes. Use to specify + /// whether to use a real PostgreSQL database or an in-memory context. + /// + internal static async Task CreateDbContextAsync(bool useInMemory) => + useInMemory ? CreateInMemoryDbContext() : await CreatePostgreSqlDbContextAsync().ConfigureAwait(false); + + private static BdmsContext CreateInMemoryDbContext() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + + private static async Task CreatePostgreSqlDbContextAsync() + { + var postgreSqlContainer = await CreatePostgreSqlContainerAsync().ConfigureAwait(false); + var context = new BdmsContext(GetDbContextOptions(postgreSqlContainer.GetConnectionString())); + await context.Database.MigrateAsync().ConfigureAwait(false); + await context.CleanUpSuperfluousDataAsync().ConfigureAwait(false); + return context; + } + + private static async Task CreatePostgreSqlContainerAsync() + { + var initDbDirectoryPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "initdb.d")); + var postgreSqlContainer = new PostgreSqlBuilder() + .WithImage("postgis/postgis:15-3.4-alpine") + .WithDatabase(BoreholesDatabaseName) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)) + .WithResourceMapping(initDbDirectoryPath, "/docker-entrypoint-initdb.d") + .Build(); + await postgreSqlContainer.StartAsync(); + return postgreSqlContainer; + } +}