From f370e9d776757bb09bf457cf0e0a7e1714138f2c Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Sat, 11 Jul 2026 18:59:48 +0200 Subject: [PATCH 1/3] Move persistence to PostgreSQL; API tests on a native Postgres server --- Wend.Api/Program.cs | 8 ++++--- Wend.Core/Wend.Core.csproj | 2 +- Wend.Tests/DatabaseFixture.cs | 43 +++++++++++++++++++++++++++++++++++ Wend.Tests/Wend.Tests.csproj | 1 + Wend.Tests/WendApiFactory.cs | 42 ++++++++++++++++++++++++++-------- 5 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 Wend.Tests/DatabaseFixture.cs diff --git a/Wend.Api/Program.cs b/Wend.Api/Program.cs index 4e2d061..ca50e3c 100644 --- a/Wend.Api/Program.cs +++ b/Wend.Api/Program.cs @@ -4,11 +4,13 @@ var builder = WebApplication.CreateBuilder(args); -// Config seam — DB path and port are overridable by tests and manual runs. -var dbPath = builder.Configuration["Wend:DbPath"] ?? WendPaths.DefaultDbPath(); +// Config seam — connection string from user-secrets (dev) or environment (prod). +var connectionString = builder.Configuration.GetConnectionString("WendDb") + ?? throw new InvalidOperationException( + "ConnectionStrings:WendDb is not configured. Set it via user-secrets (dev) or environment (prod)."); var port = int.TryParse(builder.Configuration["Wend:Port"], out var p) ? p : 5174; -builder.Services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}")); +builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); builder.Services.AddScoped();builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Wend.Core/Wend.Core.csproj b/Wend.Core/Wend.Core.csproj index 72053da..992e4c0 100644 --- a/Wend.Core/Wend.Core.csproj +++ b/Wend.Core/Wend.Core.csproj @@ -11,7 +11,7 @@ - + diff --git a/Wend.Tests/DatabaseFixture.cs b/Wend.Tests/DatabaseFixture.cs new file mode 100644 index 0000000..8e322ea --- /dev/null +++ b/Wend.Tests/DatabaseFixture.cs @@ -0,0 +1,43 @@ +using Npgsql; + +namespace Wend.Tests; + +/// +/// Base connection to the local PostgreSQL *server* (its maintenance 'postgres' database). +/// Each API test creates its OWN throwaway database on this server (see WendApiFactory), so +/// tests stay isolated exactly as they were with per-test SQLite files. No container: a native +/// PostgreSQL service locally, a Postgres service container on CI. The base connection comes from +/// WEND_TEST_PG (set on CI); locally it defaults to the standard dev instance. Nothing secret is committed. +/// On start it also drops any wend_test_* databases orphaned by a previously crashed run. +/// +[SetUpFixture] +public sealed class DatabaseFixture +{ + public static string AdminConnectionString { get; private set; } = ""; + + [OneTimeSetUp] + public void Init() + { + AdminConnectionString = + Environment.GetEnvironmentVariable("WEND_TEST_PG") + ?? "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres"; + + // Clean slate: drop wend_test_* databases left over from a crashed run (a native + // server persists, unlike a thrown-away container). + using var admin = new NpgsqlConnection(AdminConnectionString); + admin.Open(); + var stale = new List(); + using (var find = admin.CreateCommand()) + { + find.CommandText = "SELECT datname FROM pg_database WHERE datname LIKE 'wend_test_%'"; + using var r = find.ExecuteReader(); + while (r.Read()) stale.Add(r.GetString(0)); + } + foreach (var db in stale) + { + using var drop = admin.CreateCommand(); + drop.CommandText = $"DROP DATABASE IF EXISTS \"{db}\" WITH (FORCE);"; + drop.ExecuteNonQuery(); + } + } +} diff --git a/Wend.Tests/Wend.Tests.csproj b/Wend.Tests/Wend.Tests.csproj index dd919ca..88f176d 100644 --- a/Wend.Tests/Wend.Tests.csproj +++ b/Wend.Tests/Wend.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/Wend.Tests/WendApiFactory.cs b/Wend.Tests/WendApiFactory.cs index 6dd7b9f..715a0e8 100644 --- a/Wend.Tests/WendApiFactory.cs +++ b/Wend.Tests/WendApiFactory.cs @@ -1,26 +1,48 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Npgsql; namespace Wend.Tests; /// -/// Boots the real app against a throwaway SQLite file via the Wend:DbPath seam, so tests -/// never touch the real %LOCALAPPDATA%\Wend\data.db. Each instance gets its own database. +/// Boots the real app against a throwaway PostgreSQL database on the local/CI server. Each factory +/// instance creates its OWN empty database and drops it on dispose, so tests stay isolated exactly +/// as they were with per-test SQLite files. The app builds the schema on startup (EnsureCreated now, +/// Migrate from Task 2). /// public sealed class WendApiFactory : WebApplicationFactory { - private readonly string _dbPath = - Path.Combine(Path.GetTempPath(), $"wend-test-{Guid.NewGuid():N}.db"); + private readonly string _dbName = $"wend_test_{Guid.NewGuid():N}"; - protected override void ConfigureWebHost(IWebHostBuilder builder) => - builder.UseSetting("Wend:DbPath", _dbPath); + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Create this instance's empty database on the shared server. + using (var admin = new NpgsqlConnection(DatabaseFixture.AdminConnectionString)) + { + admin.Open(); + using var cmd = admin.CreateCommand(); + cmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + cmd.ExecuteNonQuery(); + } + + // Point the app at it through the same seam Program.cs reads. + var perTest = new NpgsqlConnectionStringBuilder(DatabaseFixture.AdminConnectionString) + { + Database = _dbName, + }; + builder.UseSetting("ConnectionStrings:WendDb", perTest.ConnectionString); + } protected override void Dispose(bool disposing) { base.Dispose(disposing); - // Best-effort: the SQLite connection pool may still hold the file briefly on - // Windows. If the delete fails, leave it — it's in the OS temp folder. - try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } - catch (IOException) { } + // Native server persists (unlike a container) — drop this test's throwaway database. + // DROP ... WITH (FORCE) (PG13+) terminates the app's leftover pooled connections to this + // DB, so no global ClearAllPools() is needed (that would disrupt sibling tests' pools). + using var admin = new NpgsqlConnection(DatabaseFixture.AdminConnectionString); + admin.Open(); + using var cmd = admin.CreateCommand(); + cmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\" WITH (FORCE);"; + cmd.ExecuteNonQuery(); } } From dccb1481d687f98dba3c1f988b640b9a8b2b3e0a Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Sat, 11 Jul 2026 19:03:55 +0200 Subject: [PATCH 2/3] Adopt EF Core migrations; replace EnsureCreated with Migrate --- Wend.Api/Program.cs | 7 +- Wend.Api/Wend.Api.csproj | 10 +- .../20260711170130_InitialCreate.Designer.cs | 255 ++++++++++++++++++ .../20260711170130_InitialCreate.cs | 192 +++++++++++++ .../Migrations/WendDbContextModelSnapshot.cs | 252 +++++++++++++++++ 5 files changed, 711 insertions(+), 5 deletions(-) create mode 100644 Wend.Core/Migrations/20260711170130_InitialCreate.Designer.cs create mode 100644 Wend.Core/Migrations/20260711170130_InitialCreate.cs create mode 100644 Wend.Core/Migrations/WendDbContextModelSnapshot.cs diff --git a/Wend.Api/Program.cs b/Wend.Api/Program.cs index ca50e3c..f8c8dab 100644 --- a/Wend.Api/Program.cs +++ b/Wend.Api/Program.cs @@ -26,11 +26,10 @@ var app = builder.Build(); -// Create the SQLite schema on first run. NOTE: EnsureCreated does NOT migrate an existing -// database when later plans add tables — see "Schema & migrations" in the notes. Slice 1 -// adopts EF Core migrations at the Slice 1 -> 2 boundary. +// Apply pending EF Core migrations on startup (dev-simple; the deployment plan switches this to +// migration bundles / scripts, which Microsoft recommends for production). using (var scope = app.Services.CreateScope()) - scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + scope.ServiceProvider.GetRequiredService().Database.Migrate(); // Unhandled failures → bodyless 500 (no developer exception page over the wire). app.UseExceptionHandler(b => b.Run(ctx => { ctx.Response.StatusCode = 500; return Task.CompletedTask; })); diff --git a/Wend.Api/Wend.Api.csproj b/Wend.Api/Wend.Api.csproj index 5df7126..dddb49f 100644 --- a/Wend.Api/Wend.Api.csproj +++ b/Wend.Api/Wend.Api.csproj @@ -1,13 +1,21 @@ - + net10.0 enable enable + ce721b36-3c27-4a78-a047-720d9a894962 + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/Wend.Core/Migrations/20260711170130_InitialCreate.Designer.cs b/Wend.Core/Migrations/20260711170130_InitialCreate.Designer.cs new file mode 100644 index 0000000..f4f58fa --- /dev/null +++ b/Wend.Core/Migrations/20260711170130_InitialCreate.Designer.cs @@ -0,0 +1,255 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wend.Core; + +#nullable disable + +namespace Wend.Core.Migrations +{ + [DbContext(typeof(WendDbContext))] + [Migration("20260711170130_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Wend.Core.Board", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Boards"); + }); + + modelBuilder.Entity("Wend.Core.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("ListId") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ListId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("Wend.Core.CardLabel", b => + { + b.Property("CardId") + .HasColumnType("integer"); + + b.Property("LabelId") + .HasColumnType("integer"); + + b.HasKey("CardId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("CardLabels"); + }); + + modelBuilder.Entity("Wend.Core.ChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CardId") + .HasColumnType("integer"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.ToTable("ChecklistItems"); + }); + + modelBuilder.Entity("Wend.Core.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoardId") + .HasColumnType("integer"); + + b.Property("Colour") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Labels"); + }); + + modelBuilder.Entity("Wend.Core.List", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoardId") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Lists"); + }); + + modelBuilder.Entity("Wend.Core.Card", b => + { + b.HasOne("Wend.Core.List", null) + .WithMany("Cards") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.CardLabel", b => + { + b.HasOne("Wend.Core.Card", null) + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wend.Core.Label", null) + .WithMany() + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.ChecklistItem", b => + { + b.HasOne("Wend.Core.Card", null) + .WithMany("ChecklistItems") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.Label", b => + { + b.HasOne("Wend.Core.Board", null) + .WithMany("Labels") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.List", b => + { + b.HasOne("Wend.Core.Board", null) + .WithMany("Lists") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.Board", b => + { + b.Navigation("Labels"); + + b.Navigation("Lists"); + }); + + modelBuilder.Entity("Wend.Core.Card", b => + { + b.Navigation("ChecklistItems"); + }); + + modelBuilder.Entity("Wend.Core.List", b => + { + b.Navigation("Cards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Wend.Core/Migrations/20260711170130_InitialCreate.cs b/Wend.Core/Migrations/20260711170130_InitialCreate.cs new file mode 100644 index 0000000..dfd006a --- /dev/null +++ b/Wend.Core/Migrations/20260711170130_InitialCreate.cs @@ -0,0 +1,192 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Wend.Core.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Boards", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Boards", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Labels", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + BoardId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "text", nullable: false), + Colour = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Labels", x => x.Id); + table.ForeignKey( + name: "FK_Labels_Boards_BoardId", + column: x => x.BoardId, + principalTable: "Boards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Lists", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + BoardId = table.Column(type: "integer", nullable: false), + Title = table.Column(type: "text", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Lists", x => x.Id); + table.ForeignKey( + name: "FK_Lists_Boards_BoardId", + column: x => x.BoardId, + principalTable: "Boards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Cards", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ListId = table.Column(type: "integer", nullable: false), + Title = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + DueDate = table.Column(type: "date", nullable: true), + Position = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + ArchivedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Cards", x => x.Id); + table.ForeignKey( + name: "FK_Cards_Lists_ListId", + column: x => x.ListId, + principalTable: "Lists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CardLabels", + columns: table => new + { + CardId = table.Column(type: "integer", nullable: false), + LabelId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CardLabels", x => new { x.CardId, x.LabelId }); + table.ForeignKey( + name: "FK_CardLabels_Cards_CardId", + column: x => x.CardId, + principalTable: "Cards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CardLabels_Labels_LabelId", + column: x => x.LabelId, + principalTable: "Labels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChecklistItems", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CardId = table.Column(type: "integer", nullable: false), + Text = table.Column(type: "text", nullable: false), + Position = table.Column(type: "integer", nullable: false), + CheckedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ChecklistItems", x => x.Id); + table.ForeignKey( + name: "FK_ChecklistItems_Cards_CardId", + column: x => x.CardId, + principalTable: "Cards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CardLabels_LabelId", + table: "CardLabels", + column: "LabelId"); + + migrationBuilder.CreateIndex( + name: "IX_Cards_ListId", + table: "Cards", + column: "ListId"); + + migrationBuilder.CreateIndex( + name: "IX_ChecklistItems_CardId", + table: "ChecklistItems", + column: "CardId"); + + migrationBuilder.CreateIndex( + name: "IX_Labels_BoardId", + table: "Labels", + column: "BoardId"); + + migrationBuilder.CreateIndex( + name: "IX_Lists_BoardId", + table: "Lists", + column: "BoardId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CardLabels"); + + migrationBuilder.DropTable( + name: "ChecklistItems"); + + migrationBuilder.DropTable( + name: "Labels"); + + migrationBuilder.DropTable( + name: "Cards"); + + migrationBuilder.DropTable( + name: "Lists"); + + migrationBuilder.DropTable( + name: "Boards"); + } + } +} diff --git a/Wend.Core/Migrations/WendDbContextModelSnapshot.cs b/Wend.Core/Migrations/WendDbContextModelSnapshot.cs new file mode 100644 index 0000000..53b0f1c --- /dev/null +++ b/Wend.Core/Migrations/WendDbContextModelSnapshot.cs @@ -0,0 +1,252 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wend.Core; + +#nullable disable + +namespace Wend.Core.Migrations +{ + [DbContext(typeof(WendDbContext))] + partial class WendDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Wend.Core.Board", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Boards"); + }); + + modelBuilder.Entity("Wend.Core.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("ListId") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ListId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("Wend.Core.CardLabel", b => + { + b.Property("CardId") + .HasColumnType("integer"); + + b.Property("LabelId") + .HasColumnType("integer"); + + b.HasKey("CardId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("CardLabels"); + }); + + modelBuilder.Entity("Wend.Core.ChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CardId") + .HasColumnType("integer"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.ToTable("ChecklistItems"); + }); + + modelBuilder.Entity("Wend.Core.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoardId") + .HasColumnType("integer"); + + b.Property("Colour") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Labels"); + }); + + modelBuilder.Entity("Wend.Core.List", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoardId") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Lists"); + }); + + modelBuilder.Entity("Wend.Core.Card", b => + { + b.HasOne("Wend.Core.List", null) + .WithMany("Cards") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.CardLabel", b => + { + b.HasOne("Wend.Core.Card", null) + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wend.Core.Label", null) + .WithMany() + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.ChecklistItem", b => + { + b.HasOne("Wend.Core.Card", null) + .WithMany("ChecklistItems") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.Label", b => + { + b.HasOne("Wend.Core.Board", null) + .WithMany("Labels") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.List", b => + { + b.HasOne("Wend.Core.Board", null) + .WithMany("Lists") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wend.Core.Board", b => + { + b.Navigation("Labels"); + + b.Navigation("Lists"); + }); + + modelBuilder.Entity("Wend.Core.Card", b => + { + b.Navigation("ChecklistItems"); + }); + + modelBuilder.Entity("Wend.Core.List", b => + { + b.Navigation("Cards"); + }); +#pragma warning restore 612, 618 + } + } +} From a5dd9f66822b4b3f634e620c89f0945156c3acbb Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Sat, 11 Jul 2026 19:07:50 +0200 Subject: [PATCH 3/3] Run PostgreSQL integration tests on CI service container; document native dev setup --- .github/workflows/ci.yml | 14 ++++++++++++++ README.md | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f726621..5e7d84a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,20 @@ jobs: build-and-test: name: Build & test runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + WEND_TEST_PG: "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" steps: - uses: actions/checkout@v7 diff --git a/README.md b/README.md index 0fa6be3..b91247f 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ A free, open-source, accessible, dark-mode-first kanban board — a calm alterna **Slice 1 — local single-user board (complete).** -Boards, lists, and cards work end to end — create, rename, delete, and reorder lists inside a board, add cards to a list, move a card within its list or to another list, label them, delete a card with a one-click undo, open a card into a focused task view with an Edit mode, keep a per-card checklist (add, rename, reorder, check off into a collapsible Done strip, delete with undo) with progress shown on the board's card chips, and tune it all in a small settings screen — saved to SQLite, accessible and dark-mode-first. +Boards, lists, and cards work end to end — create, rename, delete, and reorder lists inside a board, add cards to a list, move a card within its list or to another list, label them, delete a card with a one-click undo, open a card into a focused task view with an Edit mode, keep a per-card checklist (add, rename, reorder, check off into a collapsible Done strip, delete with undo) with progress shown on the board's card chips, and tune it all in a small settings screen — saved to PostgreSQL, accessible and dark-mode-first. -- **Done:** the board, list, card, label, and checklist backend (JSON APIs behind `IBoardRepository`, `IListRepository`, `ICardRepository`, `ILabelRepository`, and `IChecklistItemRepository` seams, EF Core + SQLite, 147 NUnit tests, localhost-only) and the vanilla-JS MVC frontend (board-view navigation, accessible list reordering, card chips with a focused task view, accessible card moving with up/down buttons and a move-to-list dropdown, an inline label picker with soft-tint chips, a per-card checklist with a Done strip and chip progress bars, an undo-first delete for cards and checklist items with a transient "Deleted · Undo" toast, a task-view Edit mode, a localStorage settings screen gating the card Done checkboxes and the Delete card button, screen-reader announcements, keyboard focus management, per-list Done strips, and a mobile single-list switcher). +- **Done:** the board, list, card, label, and checklist backend (JSON APIs behind `IBoardRepository`, `IListRepository`, `ICardRepository`, `ILabelRepository`, and `IChecklistItemRepository` seams, EF Core + PostgreSQL with migrations, 147 NUnit tests, localhost-only) and the vanilla-JS MVC frontend (board-view navigation, accessible list reordering, card chips with a focused task view, accessible card moving with up/down buttons and a move-to-list dropdown, an inline label picker with soft-tint chips, a per-card checklist with a Done strip and chip progress bars, an undo-first delete for cards and checklist items with a transient "Deleted · Undo" toast, a task-view Edit mode, a localStorage settings screen gating the card Done checkboxes and the Delete card button, screen-reader announcements, keyboard focus management, per-list Done strips, and a mobile single-list switcher). - **Next:** Slice 2 — sharing and multi-user accounts. Design specs: [`docs/2026-06-15-wend-slice1-design.md`](docs/2026-06-15-wend-slice1-design.md), [`docs/2026-06-19-wend-lists-design.md`](docs/2026-06-19-wend-lists-design.md), [`docs/2026-06-22-wend-cards-design.md`](docs/2026-06-22-wend-cards-design.md), [`docs/2026-06-23-wend-labels-design.md`](docs/2026-06-23-wend-labels-design.md), [`docs/2026-06-24-wend-card-moving-design.md`](docs/2026-06-24-wend-card-moving-design.md), [`docs/2026-06-25-wend-done-design.md`](docs/2026-06-25-wend-done-design.md), [`docs/2026-07-07-wend-delete-undo-design.md`](docs/2026-07-07-wend-delete-undo-design.md), [`docs/2026-07-07-wend-checklist-design.md`](docs/2026-07-07-wend-checklist-design.md), [`docs/2026-07-08-wend-mobile-a11y-polish-design.md`](docs/2026-07-08-wend-mobile-a11y-polish-design.md) @@ -24,7 +24,7 @@ Build plans: [`docs/plans/2026-06-16-slice1-foundation-boards.md`](docs/plans/20 ## Stack - ASP.NET Core (`net10.0`) — minimal API, localhost only -- EF Core → SQLite for storage, behind an `IBoardRepository` seam +- EF Core → PostgreSQL for storage (EF migrations), behind an `IBoardRepository` seam - Vanilla-JavaScript MVC frontend, served from `wwwroot` - NUnit tests @@ -38,14 +38,25 @@ Build plans: [`docs/plans/2026-06-16-slice1-foundation-boards.md`](docs/plans/20 ## Run it +Wend stores data in **PostgreSQL**. Install a local server once — a normal Windows service, no Docker: + +``` +winget install --exact --id PostgreSQL.PostgreSQL.17 +``` + +Set the `postgres` password to `postgres` and keep port `5432`. Store the dev connection string once, then run: + ``` +dotnet user-secrets set "ConnectionStrings:WendDb" "Host=localhost;Port=5432;Database=wend;Username=postgres;Password=postgres" --project Wend.Api dotnet run --project Wend.Api ``` -Then open http://127.0.0.1:5174 to create boards, open one to manage its lists (create, rename, delete, reorder), add cards and move them within or between lists — open a card for its task view to edit the title, notes, due date, labels, and a per-card checklist. The API lives under `/api/boards`, `/api/lists`, `/api/cards`, `/api/labels`, and `/api/checklist-items`. On first run the SQLite database is created at `%LOCALAPPDATA%\Wend\data.db`. +Then open http://127.0.0.1:5174 to create boards, open one to manage its lists (create, rename, delete, reorder), add cards and move them within or between lists — open a card for its task view to edit the title, notes, due date, labels, and a per-card checklist. The API lives under `/api/boards`, `/api/lists`, `/api/cards`, `/api/labels`, and `/api/checklist-items`. The schema is created and kept current by EF Core migrations on startup. ## Tests +The API integration tests need the local PostgreSQL server running (each creates a throwaway database on it); the repository unit tests run on in-memory SQLite. + ``` dotnet test ```