diff --git a/backend/Api/Api.csproj b/backend/Api/Api.csproj index 1db3e689..aa25375f 100644 --- a/backend/Api/Api.csproj +++ b/backend/Api/Api.csproj @@ -38,5 +38,9 @@ <_ContentIncludedByDefault Remove="Routes\obj\project.packagespec.json"/> + + + + diff --git a/backend/Api/AppExtensions/DatabaseExtensions.cs b/backend/Api/AppExtensions/DatabaseExtensions.cs deleted file mode 100644 index 12c07b46..00000000 --- a/backend/Api/AppExtensions/DatabaseExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Infrastructure.DatabaseContext; -using Microsoft.EntityFrameworkCore; - -namespace Api.AppExtensions; - -public static class DatabaseExtensions -{ - public static WebApplication ApplyMigrations(this WebApplication app) - { - using var scope = app.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - return app; - } -} \ No newline at end of file diff --git a/backend/Api/Program.cs b/backend/Api/Program.cs index 0f95775d..0cf280b7 100644 --- a/backend/Api/Program.cs +++ b/backend/Api/Program.cs @@ -1,24 +1,19 @@ using System.Text.Json.Serialization; using Api.AppExtensions; using Api.Options; -using Infrastructure.DatabaseContext; +using Infrastructure; using Infrastructure.Repositories; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.EntityFrameworkCore; using Microsoft.Identity.Web; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); -var connection = builder.Configuration.GetConnectionString("VibesDb"); - -if (string.IsNullOrEmpty(connection)) - throw new InvalidOperationException("No connection string found"); +builder.AddInfrastructure(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApi(builder.Configuration); builder.Services.AddAuthorization(opt => { opt.FallbackPolicy = opt.DefaultPolicy; }); -builder.Services.AddDbContext(options => options.UseSqlServer(connection)); builder.Services.AddMemoryCache(); builder.AddRepositories(); @@ -26,7 +21,6 @@ .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); builder.Services.AddEndpointsApiExplorer(); - var adOptions = builder.Configuration.GetSection("AzureAd").Get(); if (adOptions == null) throw new InvalidOperationException("Required AzureAd options are missing"); @@ -40,8 +34,6 @@ var app = builder.Build(); -app.ApplyMigrations(); - app.UsePathBase("/v0"); app.MapControllers(); @@ -61,4 +53,12 @@ app.UseAuthorization(); -await app.RunAsync(); \ No newline at end of file +await app.RunAsync(); + +namespace Api +{ + // ReSharper disable once PartialTypeWithSinglePart + // ReSharper disable once UnusedType.Global + // ReSharper disable once ClassNeverInstantiated.Global + public partial class Program; +} \ No newline at end of file diff --git a/backend/Api/appsettings.Local.json b/backend/Api/appsettings.Local.json new file mode 100644 index 00000000..e9e7bd75 --- /dev/null +++ b/backend/Api/appsettings.Local.json @@ -0,0 +1,20 @@ +{ + "AzureAd": { + "DisableAuthAd": false, + "Instance": "https://login.microsoftonline.com/", + "ClientId": "7ef3a24e-7093-41dc-9163-9618415137fe", + "TenantId": "0f16d077-bd82-4a6c-b498-52741239205f", + "ApiScope": "api://7ef3a24e-7093-41dc-9163-9618415137fe/Vibes.ReadWrite" + }, + "ConnectionStrings": { + "VibesDb": "Server=localhost;Database=AzureVibesDb;User Id=sa;Password=yourStrong(!)Password;;TrustServerCertificate=true" + }, + "InfrastructureConfig": { + "EnableSensitiveDataLogging": true + }, + "TestContainersConfig": { + "Enabled": true, + "RunMigrations": true, + "SeedDatabase": false + } +} \ No newline at end of file diff --git a/backend/Api/appsettings.Test.json b/backend/Api/appsettings.Test.json new file mode 100644 index 00000000..fc4d38af --- /dev/null +++ b/backend/Api/appsettings.Test.json @@ -0,0 +1,7 @@ +{ + "TestContainersConfig": { + "Enabled": true, + "RunMigrations": true, + "SeedDatabase": false + } +} \ No newline at end of file diff --git a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs index 471d174e..d79bbf72 100644 --- a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs +++ b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs @@ -10,10 +10,11 @@ using Core.Weeks; using Infrastructure.ValueConverters; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Infrastructure.DatabaseContext; -public class ApplicationContext(DbContextOptions options) : DbContext(options) +public class ApplicationContext(IOptions config) : DbContext { public DbSet Consultant { get; init; } = null!; public DbSet Competence { get; init; } = null!; @@ -28,6 +29,12 @@ public class ApplicationContext(DbContextOptions options) : DbContext(options) public DbSet Staffing { get; init; } = null!; public DbSet Agreements { get; init; } = null!; + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer(config.Value.ConnectionString) + .EnableSensitiveDataLogging(config.Value + .EnableSensitiveDataLogging); + } protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..7a9f75f1 --- /dev/null +++ b/backend/Infrastructure/DependencyInjection.cs @@ -0,0 +1,23 @@ +using Infrastructure.DatabaseContext; +using Infrastructure.TestContainers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Infrastructure; + +public static class DependencyInjection +{ + public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBuilder builder) + { + builder + .AddConfig(out _) + .AddConfig(out var currentTestContainersConfig); + + if (currentTestContainersConfig.Enabled) + builder.AddTestContainers(); + + builder.Services.AddDbContext(); + + return builder; + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Infrastructure.csproj b/backend/Infrastructure/Infrastructure.csproj index 257af2bb..b04187e2 100644 --- a/backend/Infrastructure/Infrastructure.csproj +++ b/backend/Infrastructure/Infrastructure.csproj @@ -16,7 +16,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/backend/Infrastructure/InfrastructureConfig.cs b/backend/Infrastructure/InfrastructureConfig.cs new file mode 100644 index 00000000..95e23061 --- /dev/null +++ b/backend/Infrastructure/InfrastructureConfig.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Infrastructure; + +public sealed record InfrastructureConfig +{ + public required string ConnectionString { get; set; } + public required bool EnableSensitiveDataLogging { get; set; } +} + +internal static class ConfigExtensions +{ + internal static IHostApplicationBuilder AddConfig(this IHostApplicationBuilder builder, out T currentConfig) + where T : class + { + var name = typeof(T); + var configSection = builder.Configuration.GetSection(typeof(T).Name); + builder.Services.Configure(configSection); + currentConfig = configSection.Get()!; + return builder; + } +} \ No newline at end of file diff --git a/backend/Infrastructure/TestContainers/TestContainersConfig.cs b/backend/Infrastructure/TestContainers/TestContainersConfig.cs new file mode 100644 index 00000000..c122f2b9 --- /dev/null +++ b/backend/Infrastructure/TestContainers/TestContainersConfig.cs @@ -0,0 +1,8 @@ +namespace Infrastructure.TestContainers; + +public record TestContainersConfig +{ + public required bool Enabled { get; set; } + public required bool RunMigrations { get; set; } + public required bool SeedDatabase { get; set; } +} \ No newline at end of file diff --git a/backend/Infrastructure/TestContainers/TestContainersFactory.cs b/backend/Infrastructure/TestContainers/TestContainersFactory.cs new file mode 100644 index 00000000..325be490 --- /dev/null +++ b/backend/Infrastructure/TestContainers/TestContainersFactory.cs @@ -0,0 +1,82 @@ +using Infrastructure.DatabaseContext; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Testcontainers.SqlEdge; + +namespace Infrastructure.TestContainers; + +public class TestContainersFactory(TestContainersConfig config, ILogger logger) +{ + private const string DbPassword = "test123!"; + private const int DbHostPort = 14333; + + public static readonly string DefaultConnectionString = + $"Server=127.0.0.1,{DbHostPort};Database=master;User Id=sa;Password={DbPassword};TrustServerCertificate=True"; + + private SqlEdgeContainer? _sqlEdgeContainer; + + public string? CurrentConnectionString => _sqlEdgeContainer?.GetConnectionString(); + + public async Task Start(CancellationToken cancellationToken = default, Overrides? overrides = null) + { + try + { + if (!config.Enabled) return; + + var dbHostPort = overrides?.DbHostPortOverride ?? DbHostPort; + var dbContainerName = overrides?.DbContainerNameOverride ?? "testcontainers-api-db"; + + logger.LogInformation("Starting TestContainers"); + + _sqlEdgeContainer = new SqlEdgeBuilder() + .WithName(dbContainerName) + .WithReuse(true) + .WithPassword(DbPassword) + .WithPortBinding(dbHostPort, 1433) + .Build(); + + await _sqlEdgeContainer.StartAsync(cancellationToken); + + var options = Options.Create(new InfrastructureConfig + { + ConnectionString = _sqlEdgeContainer.GetConnectionString(), + EnableSensitiveDataLogging = true + }); + + await using var context = new ApplicationContext(options); + + if (config.RunMigrations) + await MigrateDatabase(context, cancellationToken); + + if (config.SeedDatabase) logger.LogInformation("Seeding database with test data"); + // TODO: DataBuilder + } + catch (Exception e) + { + logger.LogError(e, "Error while starting TestContainers"); + } + } + + public Task Stop(CancellationToken cancellationToken = default) + { + var stopTask = _sqlEdgeContainer?.StopAsync(cancellationToken) ?? Task.CompletedTask; + return stopTask; + } + + public record Overrides(string? DbContainerNameOverride, int? DbHostPortOverride); + + private async Task MigrateDatabase(DbContext context, CancellationToken cancellationToken) + { + var retries = 0; + while (!await context.Database.CanConnectAsync(cancellationToken) && retries < 10) + { + retries++; + logger.LogInformation("Attempt #{AttemptNumber} to connect to DB failed, retrying in 1 second.", retries); + await Task.Delay(1000, cancellationToken); + } + + logger.LogInformation("Running database migrations"); + await context.Database.MigrateAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/TestContainers/TestContainersHostBuilder.cs b/backend/Infrastructure/TestContainers/TestContainersHostBuilder.cs new file mode 100644 index 00000000..71f02189 --- /dev/null +++ b/backend/Infrastructure/TestContainers/TestContainersHostBuilder.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Infrastructure.TestContainers; + +public static class TestContainersHostBuilder +{ + public static void AddTestContainers(this IHostApplicationBuilder builder) + { + if (!builder.Environment.IsValidTestContainersEnvironment()) + throw new InvalidOperationException( + $"{nameof(AddTestContainers)} should only be called in dev environments. Current environment: {builder.Environment.EnvironmentName}"); + + builder.Services.AddHostedService(); + + builder.Services.Configure(opts => + { + opts.ConnectionString = TestContainersFactory.DefaultConnectionString; + }); + } + + private static bool IsValidTestContainersEnvironment(this IHostEnvironment environment) + { + return environment.IsDevelopment() || environment.EnvironmentName == "Local"; + } +} \ No newline at end of file diff --git a/backend/Infrastructure/TestContainers/TestContainersService.cs b/backend/Infrastructure/TestContainers/TestContainersService.cs new file mode 100644 index 00000000..69c0b82f --- /dev/null +++ b/backend/Infrastructure/TestContainers/TestContainersService.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Infrastructure.TestContainers; + +public class TestContainersService(IServiceProvider serviceProvider) : IHostedService +{ + private TestContainersFactory? _testContainersFactory; + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + try + { + logger.LogInformation("Running TestContainersService"); + + var config = scope.ServiceProvider.GetRequiredService>().Value; + if (config.Enabled) + { + var testContainersLogger = scope.ServiceProvider.GetRequiredService>(); + _testContainersFactory = new TestContainersFactory(config, testContainersLogger); + + await _testContainersFactory.Start(cancellationToken); + } + } + catch (Exception e) + { + logger.LogError(e, "Failed to start test containers service"); + throw; + } + + logger.LogInformation("Finished running TestContainersService"); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return _testContainersFactory?.Stop(cancellationToken) ?? Task.CompletedTask; + } +} \ No newline at end of file diff --git a/backend/Tests/Api.E2E/ExampleTests.cs b/backend/Tests/Api.E2E/ExampleTests.cs new file mode 100644 index 00000000..e214e19a --- /dev/null +++ b/backend/Tests/Api.E2E/ExampleTests.cs @@ -0,0 +1,51 @@ +using Bogus; +using Bogus.DataSets; +using Core.Customers; +using Core.Organizations; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Tests.Api.E2E.Shared; + +namespace Tests.Api.E2E; + +[Collection(ApiTestCollection.CollectionName)] +public class ExampleTests(ApiFactory apiFactory) : TestsBase(apiFactory) +{ + + private static Faker CompanyFaker = new(); + + [Fact] + public async Task Can_Make_Database_Calls() + { + var faker = new Faker(); + var orgName = faker.Company.CompanyName(); + + var org = CompanyFaker.Generate(); + + var organization = new Organization + { + Id = faker.Random.Guid().ToString(), + Name = orgName, + UrlKey = orgName.ToLower().Replace(" ", ""), + Country = faker.Address.Country(), + NumberOfVacationDaysInYear = faker.Random.Int(20, 30), + HasVacationInChristmas = faker.Random.Bool(), + HoursPerWorkday = faker.Random.Int(6,9), + Departments = [], + Customers = [], + AbsenceTypes = [] + }; + + DatabaseContext.Organization.Add(organization); + await DatabaseContext.SaveChangesAsync(); + DatabaseContext.ChangeTracker.Clear(); + + var fetchedOrganization = await DatabaseContext.Organization + .Include(x => x.Departments) + .Include(x => x.Customers) + .Include(x => x.AbsenceTypes) + .FirstOrDefaultAsync(); + + fetchedOrganization.Should().BeEquivalentTo(organization); + } +} \ No newline at end of file diff --git a/backend/Tests/Api.E2E/Shared/ApiFactory.cs b/backend/Tests/Api.E2E/Shared/ApiFactory.cs new file mode 100644 index 00000000..7f579ca0 --- /dev/null +++ b/backend/Tests/Api.E2E/Shared/ApiFactory.cs @@ -0,0 +1,97 @@ +using System.Data.Common; +using System.Diagnostics; +using Api; +using Infrastructure; +using Infrastructure.DatabaseContext; +using Infrastructure.TestContainers; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Respawn; +using Respawn.Graph; + +namespace Tests.Api.E2E.Shared; + +public class ApiFactory : WebApplicationFactory, IAsyncLifetime, ICollectionFixture +{ + public TestContainersFactory TestContainersFactory { get; private set; } = default!; + public ApplicationContext DbContext { get; private set; } = default!; + public HttpClient HttpClient { get; private set; } = default!; + private DbConnection _dbConnection = default!; + private Respawner _respawner = default!; + + public async Task InitializeAsync() + { + var testContainersConfig = new TestContainersConfig + { + Enabled = true, + RunMigrations = true, + SeedDatabase = false + }; + TestContainersFactory = + new TestContainersFactory(testContainersConfig, NullLogger.Instance); + + await TestContainersFactory.Start( + overrides: new + TestContainersFactory.Overrides( + "testcontainers-api-e2e-db", + 14433 + )); + + DbContext = new ApplicationContext(Options.Create(new InfrastructureConfig + { + ConnectionString = TestContainersFactory.CurrentConnectionString ?? throw new UnreachableException(), + EnableSensitiveDataLogging = true + })); + + HttpClient = CreateClient(); + + _dbConnection = new SqlConnection(TestContainersFactory.CurrentConnectionString); + await _dbConnection.OpenAsync(); + + _respawner = await Respawner.CreateAsync(_dbConnection, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer, + SchemasToInclude = ["dbo"], + TablesToIgnore = [new Table("dbo", "__EFMigrationsHistory")] + }); + + } + + public new Task DisposeAsync() + { + HttpClient.Dispose(); + return TestContainersFactory.Stop(); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureHostConfiguration(config => config.AddJsonFile("appsettings.Test.json", optional: true)); + return base.CreateHost(builder); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(typeof(TestContainersService)); + services.Configure(opts => + { + opts.ConnectionString = + TestContainersFactory.CurrentConnectionString ?? throw new UnreachableException(); + }); + }); + } + + public async Task ResetDatabaseAsync() + { + await _respawner.ResetAsync(_dbConnection); + } +} \ No newline at end of file diff --git a/backend/Tests/Api.E2E/Shared/ApiTestCollection.cs b/backend/Tests/Api.E2E/Shared/ApiTestCollection.cs new file mode 100644 index 00000000..386900cd --- /dev/null +++ b/backend/Tests/Api.E2E/Shared/ApiTestCollection.cs @@ -0,0 +1,7 @@ +namespace Tests.Api.E2E.Shared; + +[CollectionDefinition(CollectionName)] +public class ApiTestCollection : ICollectionFixture +{ + public const string CollectionName = nameof(ApiTestCollection); +} \ No newline at end of file diff --git a/backend/Tests/Api.E2E/TestsBase.cs b/backend/Tests/Api.E2E/TestsBase.cs new file mode 100644 index 00000000..c08ce12c --- /dev/null +++ b/backend/Tests/Api.E2E/TestsBase.cs @@ -0,0 +1,31 @@ +using Infrastructure.DatabaseContext; +using Tests.Api.E2E.Shared; + +namespace Tests.Api.E2E; + +[Collection(ApiTestCollection.CollectionName)] +public class TestsBase : IAsyncLifetime +{ + private readonly Func _resetDatabase; + + protected readonly HttpClient Client; + protected readonly ApplicationContext DatabaseContext; + + protected TestsBase(ApiFactory apiFactory) + { + Client = apiFactory.HttpClient; + _resetDatabase = apiFactory.ResetDatabaseAsync; + DatabaseContext = apiFactory.DbContext; + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + DatabaseContext.ChangeTracker.Clear(); + return _resetDatabase(); + } +} \ No newline at end of file diff --git a/backend/Tests/CleanArchitectureLayerTests.cs b/backend/Tests/CleanArchitectureLayerTests.cs deleted file mode 100644 index 9388c2f2..00000000 --- a/backend/Tests/CleanArchitectureLayerTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using ArchUnitNET.Domain; -using ArchUnitNET.Fluent; -using ArchUnitNET.Loader; -using ArchUnitNET.NUnit; -using static ArchUnitNET.Fluent.ArchRuleDefinition; -using Assembly = System.Reflection.Assembly; - -namespace Tests; - -public class CleanArchitectureLayerTests -{ - // TIP: load your architecture once at the start to maximize performance of your tests - private static readonly Architecture Architecture = new ArchLoader().LoadAssemblies( - Assembly.Load("Core"), - Assembly.Load("Infrastructure"), - Assembly.Load("Api") - ).Build(); - - private readonly IObjectProvider ApiLayer = - Types().That().ResideInNamespace("Api").As("Api Layer"); - - private readonly IObjectProvider CoreLayer = - Types().That().ResideInAssembly("ApplicationCore").As("Application Core Layer"); - - private readonly IObjectProvider DatabaseLayer = - Types().That().ResideInNamespace("Infrastructure").As("Infrastructure Layer"); - - [Test] - public void CoreLayerShouldNotAccessApiLayer() - { - IArchRule applicationCoreLayerShouldNotAccessApiLayer = Types().That().Are(CoreLayer).Should() - .NotDependOnAny(ApiLayer).Because("The ApplicationCore project should not depend on the Api project."); - applicationCoreLayerShouldNotAccessApiLayer.Check(Architecture); - } - - [Test] - public void CoreLayerShouldNotAccessDatabaseLayer() - { - IArchRule applicationCoreLayerShouldNotAccessApiLayer = Types().That().Are(CoreLayer).Should() - .NotDependOnAny(DatabaseLayer).Because("The ApplicationCore project should not depend on the Api project."); - applicationCoreLayerShouldNotAccessApiLayer.Check(Architecture); - } - - [Test] - public void DatabaseLayerShouldNotAccessApiLayer() - { - IArchRule infrastructureLayerShouldNotAccessApiLayer = Types().That().Are(DatabaseLayer).Should() - .NotDependOnAny(ApiLayer).Because("The Infrastructure project should not depend on the Api project."); - infrastructureLayerShouldNotAccessApiLayer.Check(Architecture); - } -} \ No newline at end of file diff --git a/backend/Tests/AbsenceTest.cs b/backend/Tests/Core.Tests/AbsenceTest.cs similarity index 84% rename from backend/Tests/AbsenceTest.cs rename to backend/Tests/Core.Tests/AbsenceTest.cs index baa315cb..72f36f0e 100644 --- a/backend/Tests/AbsenceTest.cs +++ b/backend/Tests/Core.Tests/AbsenceTest.cs @@ -8,25 +8,26 @@ using Core.Staffings; using Core.Vacations; using Core.Weeks; +using FluentAssertions; using NSubstitute; -namespace Tests; +namespace Tests.Core.Tests; -public class Tests +public class AbsenceTests { - [TestCase(2, 15, 0, 0, 7.5)] - [TestCase(0, 7.5, 0, 0, 30)] - [TestCase(5, 37.5, 0, 0, 0)] - [TestCase(0, 0, 0, 0, 37.5)] - [TestCase(5, 30, 0, 0, 0)] - [TestCase(5, 0, 0, 0, 0)] - [TestCase(5, 37.5, 0, 0, 0)] - [TestCase(0, 0, 1, 0, 30)] - [TestCase(0, 0, 2, 0, 22.5)] - [TestCase(0, 0, 5, 0, 0)] - [TestCase(0, 0, 0, 37.5, 0)] - [TestCase(0, 0, 0, 30, 7.5)] - [TestCase(0, 7.5, 0, 22.5, 7.5)] + [Theory] + [InlineData(2, 15, 0, 0, 7.5)] + [InlineData(0, 7.5, 0, 0, 30)] + [InlineData(5, 37.5, 0, 0, 0)] + [InlineData(0, 0, 0, 0, 37.5)] + [InlineData(5, 30, 0, 0, 0)] + [InlineData(5, 0, 0, 0, 0)] + [InlineData(0, 0, 1, 0, 30)] + [InlineData(0, 0, 2, 0, 22.5)] + [InlineData(0, 0, 5, 0, 0)] + [InlineData(0, 0, 0, 37.5, 0)] + [InlineData(0, 0, 0, 30, 7.5)] + [InlineData(0, 7.5, 0, 22.5, 7.5)] public void AvailabilityCalculation( int vacationDays, double plannedAbsenceHours, @@ -119,16 +120,14 @@ public void AvailabilityCalculation( var bookingModel = ReadModelFactory.MapToReadModelList(consultant, [week]).Bookings[0] .BookingModel; - Assert.Multiple(() => - { - Assert.That(bookingModel.TotalBillable, Is.EqualTo(staffedHours)); - Assert.That(bookingModel.TotalPlannedAbsences, Is.EqualTo(plannedAbsenceHours)); - Assert.That(bookingModel.TotalHolidayHours, Is.EqualTo(numberOfHolidays * 7.5)); - Assert.That(bookingModel.TotalSellableTime, Is.EqualTo(expectedSellableHours)); - }); + + bookingModel.TotalBillable.Should().Be(staffedHours); + bookingModel.TotalPlannedAbsences.Should().Be(plannedAbsenceHours); + bookingModel.TotalHolidayHours.Should().Be(numberOfHolidays * 7.5); + bookingModel.TotalSellableTime.Should().Be(expectedSellableHours); } - [Test] + [Fact] public void MultiplePlannedAbsences() { var org = new Organization @@ -191,6 +190,6 @@ public void MultiplePlannedAbsences() var bookedHours = ReadModelFactory.MapToReadModelList(consultant, [week]).Bookings[0] .BookingModel; - Assert.That(bookedHours.TotalPlannedAbsences, Is.EqualTo(30)); + bookedHours.TotalPlannedAbsences.Should().Be(30); } } \ No newline at end of file diff --git a/backend/Tests/Core.Tests/ArchitectureTests.cs b/backend/Tests/Core.Tests/ArchitectureTests.cs new file mode 100644 index 00000000..81c7c387 --- /dev/null +++ b/backend/Tests/Core.Tests/ArchitectureTests.cs @@ -0,0 +1,73 @@ +using ArchUnitNET.Domain; +using ArchUnitNET.Loader; +using ArchUnitNET.xUnit; +using static ArchUnitNET.Fluent.ArchRuleDefinition; +using Assembly = System.Reflection.Assembly; + +namespace Tests.Core.Tests; + +public class ArchitectureTests +{ + private static readonly Architecture Architecture = new ArchLoader().LoadAssemblies( + Assembly.Load("Core"), + Assembly.Load("Infrastructure"), + Assembly.Load("Api") + ).Build(); + + private readonly IObjectProvider _apiLayer = + Types() + .That() + .ResideInNamespace("Api") + .As("Api Layer"); + + private readonly IObjectProvider _coreLayer = + Types() + .That() + .ResideInAssembly("ApplicationCore") + .As("Application Core Layer"); + + private readonly IObjectProvider _databaseLayer = + Types() + .That() + .ResideInNamespace("Infrastructure") + .As("Infrastructure Layer"); + + [Fact] + public void CoreLayer_Should_Not_Access_ApiLayer() + { + Types() + .That() + .Are(_coreLayer) + .Should() + .NotDependOnAny(_apiLayer) + .Because("The ApplicationCore project should not depend on the Api project.") + .WithoutRequiringPositiveResults() + .Check(Architecture); + } + + [Fact] + public void CoreLayer_Should_Not_Access_DatabaseLayer() + { + Types() + .That() + .Are(_coreLayer) + .Should() + .NotDependOnAny(_databaseLayer) + .Because("The ApplicationCore project should not depend on the Api project.") + .WithoutRequiringPositiveResults() + .Check(Architecture); + } + + [Fact] + public void DatabaseLayer_Should_Not_Access_ApiLayer() + { + Types() + .That() + .Are(_databaseLayer) + .Should() + .NotDependOnAny(_apiLayer) + .Because("The Infrastructure project should not depend on the Api project.") + .WithoutRequiringPositiveResults() + .Check(Architecture); + } +} \ No newline at end of file diff --git a/backend/Tests/GlobalUsings.cs b/backend/Tests/GlobalUsings.cs index cefced49..8c927eb7 100644 --- a/backend/Tests/GlobalUsings.cs +++ b/backend/Tests/GlobalUsings.cs @@ -1 +1 @@ -global using NUnit.Framework; \ No newline at end of file +global using Xunit; \ No newline at end of file diff --git a/backend/Tests/Tests.csproj b/backend/Tests/Tests.csproj index b8a7f49a..a1b7dffa 100644 --- a/backend/Tests/Tests.csproj +++ b/backend/Tests/Tests.csproj @@ -11,14 +11,20 @@ - - - - - - - - + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/backend/backend.sln.DotSettings b/backend/backend.sln.DotSettings index 97106184..6a2c9e52 100644 --- a/backend/backend.sln.DotSettings +++ b/backend/backend.sln.DotSettings @@ -1,4 +1,5 @@  + True True True True @@ -6,8 +7,10 @@ True True True + True True True True True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/frontend/src/components/FilteredConsultantsList.tsx b/frontend/src/components/FilteredConsultantsList.tsx index 1ea0a01d..47a890ed 100644 --- a/frontend/src/components/FilteredConsultantsList.tsx +++ b/frontend/src/components/FilteredConsultantsList.tsx @@ -34,7 +34,7 @@ export default function StaffingTable() { {filteredConsultants .at(0) - ?.bookings.map((_, index) => )} + ?.bookings.map((x) => )}