From 087bf377df46156ece5dc2ce66ef533e96c5aeda Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 19:50:39 +0100 Subject: [PATCH 1/8] Add MenuUser entity, EF Core configuration, and migration - Add MenuUserEntity in MenuDB/Data with Id, AuthSubject, DisplayName, Email (nullable), AvatarUrl (nullable), CreatedAtUtc, LastSeenAtUtc - Add MenuUserId Vogen value object (wraps int) - Configure MenuUser in MenuDbContext.OnModelCreating: - Table: identity.MenuUser - nvarchar column types per spec - Unique index UX_MenuUser_AuthSubject on AuthSubject - Add EF Core migration AddMenuUser - Add unit tests verifying table name, schema, column types, and unique index Closes #1109 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MenuUserEntityConfigurationTests.cs | 96 +++++ backend/MenuApi/ValueObjects/MenuUser.cs | 6 + backend/MenuDB/Data/MenuUserEntity.cs | 18 + backend/MenuDB/MenuDbContext.cs | 16 + .../20260528185016_AddMenuUser.Designer.cs | 330 ++++++++++++++++++ .../Migrations/20260528185016_AddMenuUser.cs | 52 +++ .../Migrations/MenuDbContextModelSnapshot.cs | 40 ++- 7 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 backend/MenuApi.Tests/Repositories/MenuUserEntityConfigurationTests.cs create mode 100644 backend/MenuApi/ValueObjects/MenuUser.cs create mode 100644 backend/MenuDB/Data/MenuUserEntity.cs create mode 100644 backend/MenuDB/Migrations/20260528185016_AddMenuUser.Designer.cs create mode 100644 backend/MenuDB/Migrations/20260528185016_AddMenuUser.cs diff --git a/backend/MenuApi.Tests/Repositories/MenuUserEntityConfigurationTests.cs b/backend/MenuApi.Tests/Repositories/MenuUserEntityConfigurationTests.cs new file mode 100644 index 00000000..fc1ef184 --- /dev/null +++ b/backend/MenuApi.Tests/Repositories/MenuUserEntityConfigurationTests.cs @@ -0,0 +1,96 @@ +using AwesomeAssertions; +using MenuDB; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Xunit; + +namespace MenuApi.Tests.Repositories; + +public class MenuUserEntityConfigurationTests +{ + [Fact] + public void MenuUser_Table_Has_Correct_Name_And_Schema() + { + using var db = CreateDbContext(); + + var entityType = db.Model.FindEntityType(typeof(MenuDB.Data.MenuUserEntity)); + + entityType.Should().NotBeNull(); + entityType!.GetTableName().Should().Be("MenuUser"); + entityType.GetSchema().Should().Be("identity"); + } + + [Fact] + public void MenuUser_AuthSubject_Is_Nvarchar256_Required() + { + using var db = CreateDbContext(); + + var entityType = db.Model.FindEntityType(typeof(MenuDB.Data.MenuUserEntity))!; + var property = entityType.FindProperty(nameof(MenuDB.Data.MenuUserEntity.AuthSubject))!; + + GetConfiguredColumnType(property).Should().Be("nvarchar(256)"); + property.IsNullable.Should().BeFalse(); + } + + [Fact] + public void MenuUser_DisplayName_Is_Nvarchar100_Required() + { + using var db = CreateDbContext(); + + var entityType = db.Model.FindEntityType(typeof(MenuDB.Data.MenuUserEntity))!; + var property = entityType.FindProperty(nameof(MenuDB.Data.MenuUserEntity.DisplayName))!; + + GetConfiguredColumnType(property).Should().Be("nvarchar(100)"); + property.IsNullable.Should().BeFalse(); + } + + [Fact] + public void MenuUser_Email_Is_Nvarchar256_Nullable() + { + using var db = CreateDbContext(); + + var entityType = db.Model.FindEntityType(typeof(MenuDB.Data.MenuUserEntity))!; + var property = entityType.FindProperty(nameof(MenuDB.Data.MenuUserEntity.Email))!; + + GetConfiguredColumnType(property).Should().Be("nvarchar(256)"); + property.IsNullable.Should().BeTrue(); + } + + [Fact] + public void MenuUser_AvatarUrl_Is_Nvarchar512_Nullable() + { + using var db = CreateDbContext(); + + var entityType = db.Model.FindEntityType(typeof(MenuDB.Data.MenuUserEntity))!; + var property = entityType.FindProperty(nameof(MenuDB.Data.MenuUserEntity.AvatarUrl))!; + + GetConfiguredColumnType(property).Should().Be("nvarchar(512)"); + property.IsNullable.Should().BeTrue(); + } + + [Fact] + public void MenuUser_Has_Unique_Index_On_AuthSubject() + { + using var db = CreateDbContext(); + + var entityType = db.Model.FindEntityType(typeof(MenuDB.Data.MenuUserEntity))!; + var index = entityType.GetIndexes() + .SingleOrDefault(i => i.GetDatabaseName() == "UX_MenuUser_AuthSubject"); + + index.Should().NotBeNull(); + index!.IsUnique.Should().BeTrue(); + index.Properties.Should().ContainSingle(p => p.Name == nameof(MenuDB.Data.MenuUserEntity.AuthSubject)); + } + + private static string GetConfiguredColumnType(IReadOnlyAnnotatable property) => + property.FindAnnotation("Relational:ColumnType")?.Value?.ToString() ?? string.Empty; + + private static MenuDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new MenuDbContext(options); + } +} diff --git a/backend/MenuApi/ValueObjects/MenuUser.cs b/backend/MenuApi/ValueObjects/MenuUser.cs new file mode 100644 index 00000000..66946cd1 --- /dev/null +++ b/backend/MenuApi/ValueObjects/MenuUser.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace MenuApi.ValueObjects; + +[ValueObject] +public readonly partial struct MenuUserId { } diff --git a/backend/MenuDB/Data/MenuUserEntity.cs b/backend/MenuDB/Data/MenuUserEntity.cs new file mode 100644 index 00000000..569fc404 --- /dev/null +++ b/backend/MenuDB/Data/MenuUserEntity.cs @@ -0,0 +1,18 @@ +namespace MenuDB.Data; + +public class MenuUserEntity +{ + public int Id { get; set; } + + public required string AuthSubject { get; set; } + + public required string DisplayName { get; set; } + + public string? Email { get; set; } + + public string? AvatarUrl { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public DateTime LastSeenAtUtc { get; set; } +} diff --git a/backend/MenuDB/MenuDbContext.cs b/backend/MenuDB/MenuDbContext.cs index a2863024..bfb9dee2 100644 --- a/backend/MenuDB/MenuDbContext.cs +++ b/backend/MenuDB/MenuDbContext.cs @@ -5,6 +5,8 @@ namespace MenuDB; public class MenuDbContext(DbContextOptions options) : DbContext(options) { + public DbSet MenuUsers { get; set; } + public DbSet Recipes { get; set; } public DbSet Ingredients { get; set; } public DbSet UnitTypes { get; set; } @@ -14,6 +16,20 @@ public class MenuDbContext(DbContextOptions options) : DbContext( protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(e => + { + e.ToTable("MenuUser", "identity"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).UseIdentityColumn(); + e.Property(x => x.AuthSubject).HasColumnType("nvarchar(256)").IsRequired(); + e.Property(x => x.DisplayName).HasColumnType("nvarchar(100)").IsRequired(); + e.Property(x => x.Email).HasColumnType("nvarchar(256)"); + e.Property(x => x.AvatarUrl).HasColumnType("nvarchar(512)"); + e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2").IsRequired(); + e.Property(x => x.LastSeenAtUtc).HasColumnType("datetime2").IsRequired(); + e.HasIndex(x => x.AuthSubject).IsUnique().HasDatabaseName("UX_MenuUser_AuthSubject"); + }); + modelBuilder.Entity(e => { e.ToTable("Recipe"); diff --git a/backend/MenuDB/Migrations/20260528185016_AddMenuUser.Designer.cs b/backend/MenuDB/Migrations/20260528185016_AddMenuUser.Designer.cs new file mode 100644 index 00000000..93864f9e --- /dev/null +++ b/backend/MenuDB/Migrations/20260528185016_AddMenuUser.Designer.cs @@ -0,0 +1,330 @@ +// +using System; +using MenuDB; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MenuDB.Migrations +{ + [DbContext(typeof(MenuDbContext))] + [Migration("20260528185016_AddMenuUser")] + partial class AddMenuUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MenuDB.Data.IngredientEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Ingredient_Name"); + + b.ToTable("Ingredient", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.IngredientUnitEntity", b => + { + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("IngredientId", "UnitId"); + + b.HasIndex("UnitId"); + + b.ToTable("IngredientUnit", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.MenuUserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthSubject") + .IsRequired() + .HasColumnType("nvarchar(256)"); + + b.Property("AvatarUrl") + .HasColumnType("nvarchar(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(100)"); + + b.Property("Email") + .HasColumnType("nvarchar(256)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AuthSubject") + .IsUnique() + .HasDatabaseName("UX_MenuUser_AuthSubject"); + + b.ToTable("MenuUser", "identity"); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Recipe_Name"); + + b.ToTable("Recipe", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeIngredientEntity", b => + { + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(10,4)"); + + b.HasKey("RecipeId", "IngredientId", "UnitId"); + + b.HasIndex("IngredientId"); + + b.HasIndex("UnitId"); + + b.ToTable("RecipeIngredient", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.UnitEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Abbreviation") + .HasColumnType("varchar(5)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("UnitTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Abbreviation") + .IsUnique() + .HasDatabaseName("UX_Unit_Abbreviation") + .HasFilter("[Abbreviation] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Unit_Name"); + + b.HasIndex("UnitTypeId"); + + b.ToTable("Unit", (string)null); + + b.HasData( + new + { + Id = 1, + Abbreviation = "ml", + Name = "Millilitres", + UnitTypeId = 1 + }, + new + { + Id = 2, + Abbreviation = "l", + Name = "Litres", + UnitTypeId = 1 + }, + new + { + Id = 3, + Name = "Quantity", + UnitTypeId = 2 + }, + new + { + Id = 4, + Abbreviation = "g", + Name = "Grams", + UnitTypeId = 3 + }, + new + { + Id = 5, + Abbreviation = "kg", + Name = "Kilograms", + UnitTypeId = 3 + }); + }); + + modelBuilder.Entity("MenuDB.Data.UnitTypeEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_UnitType_Name"); + + b.ToTable("UnitType", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Volume" + }, + new + { + Id = 2, + Name = "Quantity" + }, + new + { + Id = 3, + Name = "Weight" + }); + }); + + modelBuilder.Entity("MenuDB.Data.IngredientUnitEntity", b => + { + b.HasOne("MenuDB.Data.IngredientEntity", "Ingredient") + .WithMany("IngredientUnits") + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_IngredientUnit_ToIngredient"); + + b.HasOne("MenuDB.Data.UnitEntity", "Unit") + .WithMany("IngredientUnits") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_IngredientUnit_ToUnit"); + + b.Navigation("Ingredient"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeIngredientEntity", b => + { + b.HasOne("MenuDB.Data.IngredientEntity", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MenuDB.Data.RecipeEntity", "Recipe") + .WithMany("RecipeIngredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_RecipeIngredient_ToRecipe"); + + b.HasOne("MenuDB.Data.UnitEntity", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("Recipe"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("MenuDB.Data.UnitEntity", b => + { + b.HasOne("MenuDB.Data.UnitTypeEntity", "UnitType") + .WithMany("Units") + .HasForeignKey("UnitTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_Unit_ToUnitType"); + + b.Navigation("UnitType"); + }); + + modelBuilder.Entity("MenuDB.Data.IngredientEntity", b => + { + b.Navigation("IngredientUnits"); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeEntity", b => + { + b.Navigation("RecipeIngredients"); + }); + + modelBuilder.Entity("MenuDB.Data.UnitEntity", b => + { + b.Navigation("IngredientUnits"); + }); + + modelBuilder.Entity("MenuDB.Data.UnitTypeEntity", b => + { + b.Navigation("Units"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/MenuDB/Migrations/20260528185016_AddMenuUser.cs b/backend/MenuDB/Migrations/20260528185016_AddMenuUser.cs new file mode 100644 index 00000000..69fbe63b --- /dev/null +++ b/backend/MenuDB/Migrations/20260528185016_AddMenuUser.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MenuDB.Migrations +{ + /// + public partial class AddMenuUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "MenuUser", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AuthSubject = table.Column(type: "nvarchar(256)", nullable: false), + DisplayName = table.Column(type: "nvarchar(100)", nullable: false), + Email = table.Column(type: "nvarchar(256)", nullable: true), + AvatarUrl = table.Column(type: "nvarchar(512)", nullable: true), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + LastSeenAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MenuUser", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "UX_MenuUser_AuthSubject", + schema: "identity", + table: "MenuUser", + column: "AuthSubject", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MenuUser", + schema: "identity"); + } + } +} diff --git a/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs b/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs index 6e6d45c6..e8e6b216 100644 --- a/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs +++ b/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using MenuDB; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -16,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -57,6 +58,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("IngredientUnit", (string)null); }); + modelBuilder.Entity("MenuDB.Data.MenuUserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthSubject") + .IsRequired() + .HasColumnType("nvarchar(256)"); + + b.Property("AvatarUrl") + .HasColumnType("nvarchar(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(100)"); + + b.Property("Email") + .HasColumnType("nvarchar(256)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AuthSubject") + .IsUnique() + .HasDatabaseName("UX_MenuUser_AuthSubject"); + + b.ToTable("MenuUser", "identity"); + }); + modelBuilder.Entity("MenuDB.Data.RecipeEntity", b => { b.Property("Id") From 8cc595771f7c0d6f030e22d3d5dcd074c1ed777c Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 19:57:19 +0100 Subject: [PATCH 2/8] Rename MenuUser.cs to MenuUserId.cs to match struct name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MenuApi.Integration.Tests.csproj | 1 + backend/MenuApi.Tests/MenuApi.Tests.csproj | 12 +----------- .../ValueObjects/{MenuUser.cs => MenuUserId.cs} | 0 3 files changed, 2 insertions(+), 11 deletions(-) rename backend/MenuApi/ValueObjects/{MenuUser.cs => MenuUserId.cs} (100%) diff --git a/backend/MenuApi.Integration.Tests/MenuApi.Integration.Tests.csproj b/backend/MenuApi.Integration.Tests/MenuApi.Integration.Tests.csproj index e4c4e915..4c17ef8c 100644 --- a/backend/MenuApi.Integration.Tests/MenuApi.Integration.Tests.csproj +++ b/backend/MenuApi.Integration.Tests/MenuApi.Integration.Tests.csproj @@ -9,6 +9,7 @@ ea3aa4c7-9b32-4485-a5b2-fb1cb0def863 enable + enable xUnit1051 diff --git a/backend/MenuApi.Tests/MenuApi.Tests.csproj b/backend/MenuApi.Tests/MenuApi.Tests.csproj index 37603a56..a69467f3 100644 --- a/backend/MenuApi.Tests/MenuApi.Tests.csproj +++ b/backend/MenuApi.Tests/MenuApi.Tests.csproj @@ -8,18 +8,8 @@ false enable + enable - - - True - - - - True - - - - diff --git a/backend/MenuApi/ValueObjects/MenuUser.cs b/backend/MenuApi/ValueObjects/MenuUserId.cs similarity index 100% rename from backend/MenuApi/ValueObjects/MenuUser.cs rename to backend/MenuApi/ValueObjects/MenuUserId.cs From c248028055f1ed335d710b1089c3714f9831a377 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 20:50:44 +0100 Subject: [PATCH 3/8] Fix nullable reference type errors in test projects Add = null! initializers and null-forgiving operators to test-local DTO classes and factory helpers so both test projects build cleanly with enable and TreatWarningsAsErrors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Factory/ApiAuthentication.cs | 18 +++++++++--------- .../Factory/ApiTestFixture.cs | 8 ++++---- .../Factory/TestParameters.cs | 8 ++++---- .../IngredientIntegrationTests.cs | 14 +++++++------- .../RecipeIntegrationTests.cs | 10 +++++----- .../RecipeWithIngredientsIntegrationTests.cs | 10 +++++----- .../ValidationIntegrationTests.cs | 8 ++++---- backend/MenuApi.Tests/CustomGenerator.cs | 2 +- backend/MenuApi.Tests/MenuApi.Tests.csproj | 11 +++++++++++ .../Services/RecipeServiceTests.cs | 4 ++-- 10 files changed, 52 insertions(+), 41 deletions(-) diff --git a/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs b/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs index ec46c425..6c576265 100644 --- a/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs +++ b/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs @@ -8,7 +8,7 @@ namespace MenuApi.Integration.Tests.Factory; internal class ApiAuthentication { - private readonly TestParameters config; + private readonly TestParameters config = null!; public ApiAuthentication() @@ -20,24 +20,24 @@ public ApiAuthentication() .AddUserSecrets() // User secrets for local development. .Build(); - config = Configuration.GetRequiredSection("Parameters").Get(); + config = Configuration.GetRequiredSection("Parameters").Get()!; } public class Auth0AuthenticationRequest { - public string client_id { get; set; } - public string client_secret { get; set; } - public string audience { get; set; } - public string grant_type { get; set; } + public string client_id { get; set; } = null!; + public string client_secret { get; set; } = null!; + public string audience { get; set; } = null!; + public string grant_type { get; set; } = null!; } public class Auth0AuthenticationResponse { - public string access_token { get; set; } - public string token_type { get; set; } + public string access_token { get; set; } = null!; + public string token_type { get; set; } = null!; } @@ -58,6 +58,6 @@ public async Task GetAuthenticationHeaderValue() authResponse.EnsureSuccessStatusCode(); var responseBody = await authResponse.Content.ReadFromJsonAsync(); - return new AuthenticationHeaderValue(responseBody.token_type, responseBody.access_token); + return new AuthenticationHeaderValue(responseBody!.token_type, responseBody.access_token); } } diff --git a/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs b/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs index 16ec3613..00138e96 100644 --- a/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs +++ b/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs @@ -11,9 +11,9 @@ namespace MenuApi.Integration.Tests.Factory; public class ApiTestFixture : IAsyncLifetime { - public DistributedApplication app { get; private set; } - private IDistributedApplicationTestingBuilder appHost; - private AuthenticationHeaderValue cachedAuthHeader; + public DistributedApplication app { get; private set; } = null!; + private IDistributedApplicationTestingBuilder appHost = null!; + private AuthenticationHeaderValue cachedAuthHeader = null!; public async Task GetHttpClient() { @@ -48,7 +48,7 @@ async ValueTask IAsyncLifetime.InitializeAsync() // Retry app startup to handle Docker daemon health checks in CI environments const int maxRetries = 3; const int delayMs = 2000; - Exception lastException = null; + Exception? lastException = null; for (int attempt = 1; attempt <= maxRetries; attempt++) { try diff --git a/backend/MenuApi.Integration.Tests/Factory/TestParameters.cs b/backend/MenuApi.Integration.Tests/Factory/TestParameters.cs index 184a534d..b31fcb1b 100644 --- a/backend/MenuApi.Integration.Tests/Factory/TestParameters.cs +++ b/backend/MenuApi.Integration.Tests/Factory/TestParameters.cs @@ -2,9 +2,9 @@ { internal class TestParameters { - public string Auth0TestClientId { get; set; } - public string Auth0TestClientSecret { get; set; } - public string Auth0Audience { get; set; } - public string Auth0Domain { get; set; } + public string Auth0TestClientId { get; set; } = null!; + public string Auth0TestClientSecret { get; set; } = null!; + public string Auth0Audience { get; set; } = null!; + public string Auth0Domain { get; set; } = null!; } } diff --git a/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs b/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs index 5e9761fa..e6348d35 100644 --- a/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs @@ -162,24 +162,24 @@ private class Ingredient { #pragma warning disable S1144 // Unused private types or members should be removed public int Id { get; set; } - public string Name { get; set; } - public List Units { get; set; } + public string Name { get; set; } = null!; + public List Units { get; set; } = null!; #pragma warning restore S1144 // Unused private types or members should be removed } public class IngredientUnit { #pragma warning disable S1144 // Unused private types or members should be removed - public string Name { get; set; } - public string Abbreviation { get; set; } - public string Type { get; set; } + public string Name { get; set; } = null!; + public string Abbreviation { get; set; } = null!; + public string Type { get; set; } = null!; #pragma warning restore S1144 // Unused private types or members should be removed } public class NewIngredient { - public string Name { get; set; } - public List UnitIds { get; set; } + public string Name { get; set; } = null!; + public List UnitIds { get; set; } = null!; } } diff --git a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs index 46cdcbae..4ee5e3a8 100644 --- a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs @@ -218,7 +218,7 @@ private static (int Id, string Name) GetRecipeFromJson(JsonDocument doc) var rootElement = doc.RootElement; return ( rootElement.GetProperty("id").GetInt32(), - rootElement.GetProperty("name").GetString() + rootElement.GetProperty("name").GetString()! ); } @@ -227,7 +227,7 @@ private class Recipe #pragma warning disable S1144 // Unused private types or members should be removed public int Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = null!; #pragma warning restore S1144 // Unused private types or members should be removed } @@ -235,14 +235,14 @@ public class NewRecipe { public List Ingredients { get; set; } = []; - public string Name { get; set; } + public string Name { get; set; } = null!; } public class RecipeIngredient { - public string Name { get; set; } + public string Name { get; set; } = null!; - public string Unit { get; set; } + public string Unit { get; set; } = null!; public decimal Amount { get; set; } } diff --git a/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs b/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs index 1656e9c5..ce0813a9 100644 --- a/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs @@ -351,15 +351,15 @@ private async Task PostIngredientAsync(HttpClient client, string name, List private class NewIngredient { #pragma warning disable S1144 // Unused private types or members should be removed - public string Name { get; set; } - public List UnitIds { get; set; } + public string Name { get; set; } = null!; + public List UnitIds { get; set; } = null!; #pragma warning restore S1144 // Unused private types or members should be removed } public class NewRecipe { #pragma warning disable S1144 // Unused private types or members should be removed - public string Name { get; set; } + public string Name { get; set; } = null!; public List Ingredients { get; set; } = []; #pragma warning restore S1144 // Unused private types or members should be removed } @@ -367,8 +367,8 @@ public class NewRecipe public class RecipeIngredient { #pragma warning disable S1144 // Unused private types or members should be removed - public string Name { get; set; } - public string Unit { get; set; } + public string Name { get; set; } = null!; + public string Unit { get; set; } = null!; public decimal Amount { get; set; } #pragma warning restore S1144 // Unused private types or members should be removed } diff --git a/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs b/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs index 0a4c611c..21f6d99c 100644 --- a/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs @@ -166,19 +166,19 @@ private async Task PostIngredientAsync(HttpClient client, string name) public class NewRecipe { public List Ingredients { get; set; } = []; - public string Name { get; set; } + public string Name { get; set; } = null!; } public class RecipeIngredient { - public string Name { get; set; } - public string Unit { get; set; } + public string Name { get; set; } = null!; + public string Unit { get; set; } = null!; public decimal Amount { get; set; } } public class NewIngredient { - public string Name { get; set; } + public string Name { get; set; } = null!; public List UnitIds { get; set; } = []; } } diff --git a/backend/MenuApi.Tests/CustomGenerator.cs b/backend/MenuApi.Tests/CustomGenerator.cs index 2efdc452..f4f2b82c 100644 --- a/backend/MenuApi.Tests/CustomGenerator.cs +++ b/backend/MenuApi.Tests/CustomGenerator.cs @@ -12,7 +12,7 @@ public object Create(object request, ISpecimenContext context) { var value = context.Resolve(GetUnderlyingType(type)); - return type.GetMethod("From").Invoke(null, [value]); + return type.GetMethod("From")!.Invoke(null, [value])!; } return new NoSpecimen(); diff --git a/backend/MenuApi.Tests/MenuApi.Tests.csproj b/backend/MenuApi.Tests/MenuApi.Tests.csproj index a69467f3..a18a7a31 100644 --- a/backend/MenuApi.Tests/MenuApi.Tests.csproj +++ b/backend/MenuApi.Tests/MenuApi.Tests.csproj @@ -10,6 +10,17 @@ enable enable + + + True + + + + True + + + + diff --git a/backend/MenuApi.Tests/Services/RecipeServiceTests.cs b/backend/MenuApi.Tests/Services/RecipeServiceTests.cs index e5b43021..1583c8bf 100644 --- a/backend/MenuApi.Tests/Services/RecipeServiceTests.cs +++ b/backend/MenuApi.Tests/Services/RecipeServiceTests.cs @@ -45,7 +45,7 @@ public async Task GetRecipeSuccess(DBModel.Recipe recipe, IEnumerable fun = () => sut.UpdateRecipeAsync(recipeId, null); + Func fun = () => sut.UpdateRecipeAsync(recipeId, null!); var result = await fun.Should().ThrowAsync(); result.And.ParamName.Should().Be("newRecipe"); From 015e0a72508cf481c9de1af997108381e61ebe8b Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 21:34:49 +0100 Subject: [PATCH 4/8] Extract EF config into IEntityTypeConfiguration classes; add MenuDB.Tests project - Create MenuDB/Configuration/ with one IEntityTypeConfiguration class per entity (MenuUser, Recipe, Ingredient, UnitType, Unit, IngredientUnit, RecipeIngredient) - Simplify MenuDbContext.OnModelCreating to a single ApplyConfigurationsFromAssembly call - Create MenuDB.Tests project with xunit/AwesomeAssertions and add to solution - Move MenuUserEntityConfigurationTests to MenuDB.Tests (correct home for EF model configuration tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/MenuApi.sln | 14 +++ backend/MenuDB.Tests/MenuDB.Tests.csproj | 38 ++++++++ .../MenuUserEntityConfigurationTests.cs | 2 +- .../IngredientEntityConfiguration.cs | 17 ++++ .../IngredientUnitEntityConfiguration.cs | 22 +++++ .../MenuUserEntityConfiguration.cs | 22 +++++ .../RecipeEntityConfiguration.cs | 17 ++++ .../RecipeIngredientEntityConfiguration.cs | 25 +++++ .../Configuration/UnitEntityConfiguration.cs | 29 ++++++ .../UnitTypeEntityConfiguration.cs | 21 ++++ backend/MenuDB/MenuDbContext.cs | 97 +------------------ 11 files changed, 207 insertions(+), 97 deletions(-) create mode 100644 backend/MenuDB.Tests/MenuDB.Tests.csproj rename backend/{MenuApi.Tests/Repositories => MenuDB.Tests}/MenuUserEntityConfigurationTests.cs (98%) create mode 100644 backend/MenuDB/Configuration/IngredientEntityConfiguration.cs create mode 100644 backend/MenuDB/Configuration/IngredientUnitEntityConfiguration.cs create mode 100644 backend/MenuDB/Configuration/MenuUserEntityConfiguration.cs create mode 100644 backend/MenuDB/Configuration/RecipeEntityConfiguration.cs create mode 100644 backend/MenuDB/Configuration/RecipeIngredientEntityConfiguration.cs create mode 100644 backend/MenuDB/Configuration/UnitEntityConfiguration.cs create mode 100644 backend/MenuDB/Configuration/UnitTypeEntityConfiguration.cs diff --git a/backend/MenuApi.sln b/backend/MenuApi.sln index 28b4f6fe..b1886ed2 100644 --- a/backend/MenuApi.sln +++ b/backend/MenuApi.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MenuDB", "MenuDB\MenuDB.csp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Menu.MigrationService", "Menu.MigrationService\Menu.MigrationService.csproj", "{678A54EA-9237-49D6-B4DC-ACEF67F6385E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MenuDB.Tests", "MenuDB.Tests\MenuDB.Tests.csproj", "{E780A213-994B-4759-9FC6-DB589E8C0DE2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -125,6 +127,18 @@ Global {678A54EA-9237-49D6-B4DC-ACEF67F6385E}.Release|x64.Build.0 = Release|Any CPU {678A54EA-9237-49D6-B4DC-ACEF67F6385E}.Release|x86.ActiveCfg = Release|Any CPU {678A54EA-9237-49D6-B4DC-ACEF67F6385E}.Release|x86.Build.0 = Release|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Debug|x64.ActiveCfg = Debug|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Debug|x64.Build.0 = Debug|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Debug|x86.ActiveCfg = Debug|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Debug|x86.Build.0 = Debug|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Release|Any CPU.Build.0 = Release|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Release|x64.ActiveCfg = Release|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Release|x64.Build.0 = Release|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Release|x86.ActiveCfg = Release|Any CPU + {E780A213-994B-4759-9FC6-DB589E8C0DE2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/backend/MenuDB.Tests/MenuDB.Tests.csproj b/backend/MenuDB.Tests/MenuDB.Tests.csproj new file mode 100644 index 00000000..06e0d709 --- /dev/null +++ b/backend/MenuDB.Tests/MenuDB.Tests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + Exe + enable + enable + + + + True + + + + True + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/backend/MenuApi.Tests/Repositories/MenuUserEntityConfigurationTests.cs b/backend/MenuDB.Tests/MenuUserEntityConfigurationTests.cs similarity index 98% rename from backend/MenuApi.Tests/Repositories/MenuUserEntityConfigurationTests.cs rename to backend/MenuDB.Tests/MenuUserEntityConfigurationTests.cs index fc1ef184..86092fd3 100644 --- a/backend/MenuApi.Tests/Repositories/MenuUserEntityConfigurationTests.cs +++ b/backend/MenuDB.Tests/MenuUserEntityConfigurationTests.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Xunit; -namespace MenuApi.Tests.Repositories; +namespace MenuDB.Tests; public class MenuUserEntityConfigurationTests { diff --git a/backend/MenuDB/Configuration/IngredientEntityConfiguration.cs b/backend/MenuDB/Configuration/IngredientEntityConfiguration.cs new file mode 100644 index 00000000..31bcda66 --- /dev/null +++ b/backend/MenuDB/Configuration/IngredientEntityConfiguration.cs @@ -0,0 +1,17 @@ +using MenuDB.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MenuDB.Configuration; + +public class IngredientEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Ingredient"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).UseIdentityColumn(); + builder.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); + builder.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Ingredient_Name"); + } +} diff --git a/backend/MenuDB/Configuration/IngredientUnitEntityConfiguration.cs b/backend/MenuDB/Configuration/IngredientUnitEntityConfiguration.cs new file mode 100644 index 00000000..403bf8ed --- /dev/null +++ b/backend/MenuDB/Configuration/IngredientUnitEntityConfiguration.cs @@ -0,0 +1,22 @@ +using MenuDB.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MenuDB.Configuration; + +public class IngredientUnitEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("IngredientUnit"); + builder.HasKey(x => new { x.IngredientId, x.UnitId }); + builder.HasOne(x => x.Ingredient) + .WithMany(x => x.IngredientUnits) + .HasForeignKey(x => x.IngredientId) + .HasConstraintName("FK_IngredientUnit_ToIngredient"); + builder.HasOne(x => x.Unit) + .WithMany(x => x.IngredientUnits) + .HasForeignKey(x => x.UnitId) + .HasConstraintName("FK_IngredientUnit_ToUnit"); + } +} diff --git a/backend/MenuDB/Configuration/MenuUserEntityConfiguration.cs b/backend/MenuDB/Configuration/MenuUserEntityConfiguration.cs new file mode 100644 index 00000000..772c54ad --- /dev/null +++ b/backend/MenuDB/Configuration/MenuUserEntityConfiguration.cs @@ -0,0 +1,22 @@ +using MenuDB.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MenuDB.Configuration; + +public class MenuUserEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("MenuUser", "identity"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).UseIdentityColumn(); + builder.Property(x => x.AuthSubject).HasColumnType("nvarchar(256)").IsRequired(); + builder.Property(x => x.DisplayName).HasColumnType("nvarchar(100)").IsRequired(); + builder.Property(x => x.Email).HasColumnType("nvarchar(256)"); + builder.Property(x => x.AvatarUrl).HasColumnType("nvarchar(512)"); + builder.Property(x => x.CreatedAtUtc).HasColumnType("datetime2").IsRequired(); + builder.Property(x => x.LastSeenAtUtc).HasColumnType("datetime2").IsRequired(); + builder.HasIndex(x => x.AuthSubject).IsUnique().HasDatabaseName("UX_MenuUser_AuthSubject"); + } +} diff --git a/backend/MenuDB/Configuration/RecipeEntityConfiguration.cs b/backend/MenuDB/Configuration/RecipeEntityConfiguration.cs new file mode 100644 index 00000000..235eb342 --- /dev/null +++ b/backend/MenuDB/Configuration/RecipeEntityConfiguration.cs @@ -0,0 +1,17 @@ +using MenuDB.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MenuDB.Configuration; + +public class RecipeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Recipe"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).UseIdentityColumn(); + builder.Property(x => x.Name).HasColumnType("varchar(500)").IsRequired(); + builder.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Recipe_Name"); + } +} diff --git a/backend/MenuDB/Configuration/RecipeIngredientEntityConfiguration.cs b/backend/MenuDB/Configuration/RecipeIngredientEntityConfiguration.cs new file mode 100644 index 00000000..254265fc --- /dev/null +++ b/backend/MenuDB/Configuration/RecipeIngredientEntityConfiguration.cs @@ -0,0 +1,25 @@ +using MenuDB.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MenuDB.Configuration; + +public class RecipeIngredientEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RecipeIngredient"); + builder.HasKey(x => new { x.RecipeId, x.IngredientId, x.UnitId }); + builder.Property(x => x.Amount).HasColumnType("decimal(10,4)").IsRequired(); + builder.HasOne(x => x.Recipe) + .WithMany(x => x.RecipeIngredients) + .HasForeignKey(x => x.RecipeId) + .HasConstraintName("FK_RecipeIngredient_ToRecipe"); + builder.HasOne(x => x.Ingredient) + .WithMany() + .HasForeignKey(x => x.IngredientId); + builder.HasOne(x => x.Unit) + .WithMany() + .HasForeignKey(x => x.UnitId); + } +} diff --git a/backend/MenuDB/Configuration/UnitEntityConfiguration.cs b/backend/MenuDB/Configuration/UnitEntityConfiguration.cs new file mode 100644 index 00000000..8b541472 --- /dev/null +++ b/backend/MenuDB/Configuration/UnitEntityConfiguration.cs @@ -0,0 +1,29 @@ +using MenuDB.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MenuDB.Configuration; + +public class UnitEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Unit"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); + builder.Property(x => x.Abbreviation).HasColumnType("varchar(5)"); + builder.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Unit_Name"); + builder.HasIndex(x => x.Abbreviation).IsUnique().HasDatabaseName("UX_Unit_Abbreviation").HasFilter("[Abbreviation] IS NOT NULL"); + builder.HasOne(x => x.UnitType) + .WithMany(x => x.Units) + .HasForeignKey(x => x.UnitTypeId) + .HasConstraintName("FK_Unit_ToUnitType"); + builder.HasData( + new UnitEntity { Id = 1, Name = "Millilitres", Abbreviation = "ml", UnitTypeId = 1 }, + new UnitEntity { Id = 2, Name = "Litres", Abbreviation = "l", UnitTypeId = 1 }, + new UnitEntity { Id = 3, Name = "Quantity", Abbreviation = null, UnitTypeId = 2 }, + new UnitEntity { Id = 4, Name = "Grams", Abbreviation = "g", UnitTypeId = 3 }, + new UnitEntity { Id = 5, Name = "Kilograms", Abbreviation = "kg", UnitTypeId = 3 }); + } +} diff --git a/backend/MenuDB/Configuration/UnitTypeEntityConfiguration.cs b/backend/MenuDB/Configuration/UnitTypeEntityConfiguration.cs new file mode 100644 index 00000000..552b7b13 --- /dev/null +++ b/backend/MenuDB/Configuration/UnitTypeEntityConfiguration.cs @@ -0,0 +1,21 @@ +using MenuDB.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MenuDB.Configuration; + +public class UnitTypeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("UnitType"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); + builder.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_UnitType_Name"); + builder.HasData( + new UnitTypeEntity { Id = 1, Name = "Volume" }, + new UnitTypeEntity { Id = 2, Name = "Quantity" }, + new UnitTypeEntity { Id = 3, Name = "Weight" }); + } +} diff --git a/backend/MenuDB/MenuDbContext.cs b/backend/MenuDB/MenuDbContext.cs index bfb9dee2..bb929a6c 100644 --- a/backend/MenuDB/MenuDbContext.cs +++ b/backend/MenuDB/MenuDbContext.cs @@ -16,101 +16,6 @@ public class MenuDbContext(DbContextOptions options) : DbContext( protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(e => - { - e.ToTable("MenuUser", "identity"); - e.HasKey(x => x.Id); - e.Property(x => x.Id).UseIdentityColumn(); - e.Property(x => x.AuthSubject).HasColumnType("nvarchar(256)").IsRequired(); - e.Property(x => x.DisplayName).HasColumnType("nvarchar(100)").IsRequired(); - e.Property(x => x.Email).HasColumnType("nvarchar(256)"); - e.Property(x => x.AvatarUrl).HasColumnType("nvarchar(512)"); - e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2").IsRequired(); - e.Property(x => x.LastSeenAtUtc).HasColumnType("datetime2").IsRequired(); - e.HasIndex(x => x.AuthSubject).IsUnique().HasDatabaseName("UX_MenuUser_AuthSubject"); - }); - - modelBuilder.Entity(e => - { - e.ToTable("Recipe"); - e.HasKey(x => x.Id); - e.Property(x => x.Id).UseIdentityColumn(); - e.Property(x => x.Name).HasColumnType("varchar(500)").IsRequired(); - e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Recipe_Name"); - }); - - modelBuilder.Entity(e => - { - e.ToTable("Ingredient"); - e.HasKey(x => x.Id); - e.Property(x => x.Id).UseIdentityColumn(); - e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); - e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Ingredient_Name"); - }); - - modelBuilder.Entity(e => - { - e.ToTable("UnitType"); - e.HasKey(x => x.Id); - e.Property(x => x.Id).ValueGeneratedNever(); - e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); - e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_UnitType_Name"); - e.HasData( - new UnitTypeEntity { Id = 1, Name = "Volume" }, - new UnitTypeEntity { Id = 2, Name = "Quantity" }, - new UnitTypeEntity { Id = 3, Name = "Weight" }); - }); - - modelBuilder.Entity(e => - { - e.ToTable("Unit"); - e.HasKey(x => x.Id); - e.Property(x => x.Id).ValueGeneratedNever(); - e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); - e.Property(x => x.Abbreviation).HasColumnType("varchar(5)"); - e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Unit_Name"); - e.HasIndex(x => x.Abbreviation).IsUnique().HasDatabaseName("UX_Unit_Abbreviation").HasFilter("[Abbreviation] IS NOT NULL"); - e.HasOne(x => x.UnitType) - .WithMany(x => x.Units) - .HasForeignKey(x => x.UnitTypeId) - .HasConstraintName("FK_Unit_ToUnitType"); - e.HasData( - new UnitEntity { Id = 1, Name = "Millilitres", Abbreviation = "ml", UnitTypeId = 1 }, - new UnitEntity { Id = 2, Name = "Litres", Abbreviation = "l", UnitTypeId = 1 }, - new UnitEntity { Id = 3, Name = "Quantity", Abbreviation = null, UnitTypeId = 2 }, - new UnitEntity { Id = 4, Name = "Grams", Abbreviation = "g", UnitTypeId = 3 }, - new UnitEntity { Id = 5, Name = "Kilograms", Abbreviation = "kg", UnitTypeId = 3 }); - }); - - modelBuilder.Entity(e => - { - e.ToTable("IngredientUnit"); - e.HasKey(x => new { x.IngredientId, x.UnitId }); - e.HasOne(x => x.Ingredient) - .WithMany(x => x.IngredientUnits) - .HasForeignKey(x => x.IngredientId) - .HasConstraintName("FK_IngredientUnit_ToIngredient"); - e.HasOne(x => x.Unit) - .WithMany(x => x.IngredientUnits) - .HasForeignKey(x => x.UnitId) - .HasConstraintName("FK_IngredientUnit_ToUnit"); - }); - - modelBuilder.Entity(e => - { - e.ToTable("RecipeIngredient"); - e.HasKey(x => new { x.RecipeId, x.IngredientId, x.UnitId }); - e.Property(x => x.Amount).HasColumnType("decimal(10,4)").IsRequired(); - e.HasOne(x => x.Recipe) - .WithMany(x => x.RecipeIngredients) - .HasForeignKey(x => x.RecipeId) - .HasConstraintName("FK_RecipeIngredient_ToRecipe"); - e.HasOne(x => x.Ingredient) - .WithMany() - .HasForeignKey(x => x.IngredientId); - e.HasOne(x => x.Unit) - .WithMany() - .HasForeignKey(x => x.UnitId); - }); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(MenuDbContext).Assembly); } } From 4d377c398acab06a61bb5f76837ffb29a51e8d4e Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 21:51:30 +0100 Subject: [PATCH 5/8] Run MenuDB.Tests in CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32b8ddb8..d5205874 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -126,7 +126,9 @@ jobs: working-directory: ./backend - name: Test with the dotnet CLI - run: dotnet test --project MenuApi.Tests/MenuApi.Tests.csproj --configuration Release --no-build + run: | + dotnet test --project MenuApi.Tests/MenuApi.Tests.csproj --configuration Release --no-build + dotnet test --project MenuDB.Tests/MenuDB.Tests.csproj --configuration Release --no-build working-directory: ./backend From a1d7e8591dabcb56bb9f2cf2f9af3d634cac0322 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 21:53:25 +0100 Subject: [PATCH 6/8] Remove redundant config initializer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs b/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs index 6c576265..ccfb833e 100644 --- a/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs +++ b/backend/MenuApi.Integration.Tests/Factory/ApiAuthentication.cs @@ -8,7 +8,7 @@ namespace MenuApi.Integration.Tests.Factory; internal class ApiAuthentication { - private readonly TestParameters config = null!; + private readonly TestParameters config; public ApiAuthentication() From 688f8dfb9563fdfe24eee5b91e64a709b080bb23 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 21:54:11 +0100 Subject: [PATCH 7/8] Auto-discover unit test projects in CI Replace hardcoded project list with a glob over *.Tests.csproj (excluding Integration tests) so any new unit test projects are picked up automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d5205874..9674cb59 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -127,8 +127,10 @@ jobs: - name: Test with the dotnet CLI run: | - dotnet test --project MenuApi.Tests/MenuApi.Tests.csproj --configuration Release --no-build - dotnet test --project MenuDB.Tests/MenuDB.Tests.csproj --configuration Release --no-build + Get-ChildItem -Recurse -Filter "*.Tests.csproj" | Where-Object { $_.FullName -notlike "*Integration*" } | ForEach-Object { + dotnet test $_.FullName --configuration Release --no-build + } + shell: pwsh working-directory: ./backend From 6f54d314d1506a607e02f41c1c13d702483c1cc0 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Thu, 28 May 2026 21:58:43 +0100 Subject: [PATCH 8/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs b/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs index e6348d35..1b3a66ae 100644 --- a/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs @@ -171,7 +171,7 @@ public class IngredientUnit { #pragma warning disable S1144 // Unused private types or members should be removed public string Name { get; set; } = null!; - public string Abbreviation { get; set; } = null!; + public string? Abbreviation { get; set; } public string Type { get; set; } = null!; #pragma warning restore S1144 // Unused private types or members should be removed }