diff --git a/CLAUDE.md b/CLAUDE.md index 0166e3f..ebf653d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,12 +35,11 @@ dotnet ef database update --project src/backend/YesChef.Api Note: `Program.cs` calls `db.Database.MigrateAsync()` on startup, so running the API auto-applies pending migrations against the configured `ConnectionStrings:DefaultConnection`. -Backend tests (TUnit on Microsoft.Testing.Platform; integration tests use Testcontainers + Postgres). The `src/backend/global.json` opts `dotnet test` into MTP mode, so use `--solution` / `--project` flags: +Backend tests (TUnit on Microsoft.Testing.Platform; integration tests use Testcontainers + Postgres). The `src/backend/global.json` opts `dotnet test` into MTP mode, which means project args must be passed via `--project` (positional project paths are rejected) and `--solution` is NOT a supported switch on the .NET 10 SDK. Run from `src/backend` so `global.json` is in scope — use `Push-Location` to avoid the blocked `cd`: ```powershell -dotnet test --solution src/backend/YesChef.slnx # all tests -dotnet test --project src/backend/YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj -dotnet test --project src/backend/YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj +Push-Location src/backend; try { dotnet test --project YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj } finally { Pop-Location } +Push-Location src/backend; try { dotnet test --project YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj } finally { Pop-Location } ``` Integration tests start a single Postgres 17 container per test session, create a migrated `yeschef_template` database, then clone a fresh DB per test via `CREATE DATABASE … TEMPLATE …`. Tests run in parallel (capped by `IntegrationTestParallelLimit`) and require Docker (Rancher Desktop or Docker Desktop). diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 2af2214..b6cdf4c 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -5,6 +5,7 @@ namespace YesChef.Api.Data; public class YesChefDb(DbContextOptions options) : DbContext(options) { + public DbSet Families => Set(); public DbSet Users => Set(); public DbSet Stores => Set(); public DbSet ShoppingLists => Set(); @@ -14,6 +15,13 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(e => + { + e.HasIndex(f => f.InviteCode).IsUnique(); + e.Property(f => f.Name).HasMaxLength(100); + e.Property(f => f.InviteCode).HasMaxLength(100); + }); + modelBuilder.Entity(e => { e.HasIndex(u => u.Name).IsUnique(); diff --git a/src/backend/YesChef.Api/Entities/Family.cs b/src/backend/YesChef.Api/Entities/Family.cs new file mode 100644 index 0000000..b253d8c --- /dev/null +++ b/src/backend/YesChef.Api/Entities/Family.cs @@ -0,0 +1,9 @@ +namespace YesChef.Api.Entities; + +public class Family +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string InviteCode { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Migrations/20260508033526_AddFamily.Designer.cs b/src/backend/YesChef.Api/Migrations/20260508033526_AddFamily.Designer.cs new file mode 100644 index 0000000..9558ccc --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260508033526_AddFamily.Designer.cs @@ -0,0 +1,340 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260508033526_AddFamily")] + partial class AddFamily + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Recipe"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260508033526_AddFamily.cs b/src/backend/YesChef.Api/Migrations/20260508033526_AddFamily.cs new file mode 100644 index 0000000..dfeb606 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260508033526_AddFamily.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddFamily : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Families", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + InviteCode = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Families", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Families_InviteCode", + table: "Families", + column: "InviteCode", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Families"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 77a545b..cbb9396 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -22,6 +22,35 @@ namespace YesChef.Api.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => { b.Property("Id") diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index 4d1ad74..a195503 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using YesChef.Api.Auth; using YesChef.Api.Data; +using YesChef.Api.Entities; using YesChef.Api.Features.Recipes; using YesChef.Api.Features.ShoppingLists; using YesChef.Api.Features.Stores; @@ -49,6 +50,14 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); + + if (!await db.Families.AnyAsync()) + { + var inviteCode = builder.Configuration["FamilyCode"] + ?? throw new InvalidOperationException("FamilyCode configuration is required to bootstrap the default family."); + db.Families.Add(new Family { Name = "Family", InviteCode = inviteCode }); + await db.SaveChangesAsync(); + } } app.UseAuthentication();