using ItWorx.KnowledgeHub.Domain.Identity; using ItWorx.KnowledgeHub.Domain.Knowledge; using ItWorx.KnowledgeHub.Domain.Projects; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql; namespace ItWorx.KnowledgeHub.IntegrationTests.Migration; /// /// V2-M9: rehearses the one real production upgrade path — an existing, already- /// populated V1 database (every migration through AddAutomationAndOutbox, /// matching what real production already had before any V2 code ever ran) being /// upgraded in place by the single new V2 migration /// (AddStructuredInfrastructureKnowledge). A real production LeafWiki/database /// snapshot is not available in this environment (an external blocker, not a reason to /// skip local verification — CLAUDE.md §3) — this uses a synthetic V1-shaped database /// instead, built the same way RestoreServiceTests.cs's clean-target pattern /// already does (a fresh, separately-created database on the shared Testcontainers /// Postgres server). /// /// This closes a genuinely different gap than the existing backup/restore tests: every /// prior migration test starts from an *empty* database and migrates straight to /// latest, or restores a backup into a freshly-migrated-to-latest target. Neither /// exercises "a database that already has real content, migrated forward one more /// step" — the actual shape of a real production rollout. /// [Collection(PostgresTestGroup.Name)] public sealed class InPlaceUpgradeRehearsalTests(PostgresContainerFixture postgres) { // The last migration production already has, before any V2 code ever ran — // confirmed by direct inspection of every migration file: this is the migration // immediately preceding AddStructuredInfrastructureKnowledge. private const string LastV1Migration = "20260801023652_AddAutomationAndOutbox"; [Fact] public async Task Existing_V1_content_survives_the_V2_infrastructure_migration_completely_unchanged() { var databaseName = $"upgrade_rehearsal_{Guid.NewGuid():N}"; await using (var admin = new NpgsqlConnection(postgres.AdminConnectionString)) { await admin.OpenAsync(); await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin); await create.ExecuteNonQueryAsync(); } var connectionString = new NpgsqlConnectionStringBuilder(postgres.AdminConnectionString) { Database = databaseName }.ConnectionString; try { var options = new DbContextOptionsBuilder() .UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name)) .UseSnakeCaseNamingConvention() .Options; Guid workspaceId, pageId, projectId; string pageContentHashBeforeUpgrade, projectSlugBeforeUpgrade; DateTimeOffset pageUpdatedAtBeforeUpgrade; // Step 1: migrate only to the last migration real production already has, // never touching the new V2 tables — matching production's actual current // state exactly, not a hypothetical. await using (var db = new KnowledgeHubDbContext(options)) { await db.Database.GetService().MigrateAsync(LastV1Migration); var now = DateTimeOffset.UtcNow; var workspace = Workspace.Create(WorkspaceName.Create("Productiewerkruimte"), now); db.Workspaces.Add(workspace); workspaceId = workspace.Id; var actorId = Guid.NewGuid(); var page = Page.Create( workspace.Id, parentPageId: null, SortKey.First(), PageTitle.Create("Bestaande productiepagina"), DocumentType.WikiPage, "# Bestaande productiepagina\n\nDeze inhoud bestond al vóór de V2-migratie.", actorId, now); db.Pages.Add(page); pageId = page.Id; var project = Project.Create( workspace.Id, "Bestaand productieproject", "Een project dat al bestond vóór de V2-migratie.", ProjectPriority.Medium, "Actieve productiewerklast.", ownerId: actorId, createdBy: actorId, now); projectId = project.Id; await db.SaveChangesAsync(); // Seed the V1-shaped row with the V1 schema. The current EF model // already maps columns introduced after V1 (such as the project // icon), so using db.Projects.Add here would ask PostgreSQL for a // column that correctly does not exist until the upgrade below. await db.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO projects (id, workspace_id, name, slug, summary, status, priority, current_state_markdown, owner_id, technologies, created_by, created_at, updated_at) VALUES ({project.Id}, {workspace.Id}, {project.Name}, {project.Slug}, {project.Summary}, {(short)project.Status}, {(short)project.Priority}, {project.CurrentStateMarkdown}, {project.OwnerId}, {project.TechnologiesValue}, {project.CreatedBy}, {project.CreatedAt}, {project.UpdatedAt}) """); pageContentHashBeforeUpgrade = page.ContentHash; // PostgreSQL's timestamptz column only stores microsecond precision — // truncate the in-memory .NET tick-precision value the same way before // comparing, so this asserts real round-trip fidelity rather than a // false failure from a column-precision artifact unrelated to the // migration itself. pageUpdatedAtBeforeUpgrade = TruncateToMicroseconds(page.UpdatedAt); projectSlugBeforeUpgrade = project.Slug; } // Step 2: the real production upgrade action — apply every pending // migration (here, exactly one: AddStructuredInfrastructureKnowledge) to // the now-populated database, exactly as SchemaGuard/Program.cs's normal // startup migration would against a real upgraded production instance. await using (var db = new KnowledgeHubDbContext(options)) { await db.Database.MigrateAsync(); } // Step 3: a fresh context/connection (never reusing the one that ran the // migration) re-reads the pre-existing content and the new tables. await using (var db = new KnowledgeHubDbContext(options)) { var workspace = await db.Workspaces.FindAsync(workspaceId); Assert.NotNull(workspace); Assert.Equal("Productiewerkruimte", workspace!.Name); var page = await db.Pages.FindAsync(pageId); Assert.NotNull(page); Assert.Equal("Bestaande productiepagina", page!.TitleValue); Assert.Equal( "# Bestaande productiepagina\n\nDeze inhoud bestond al vóór de V2-migratie.", page.Markdown); Assert.Equal(pageContentHashBeforeUpgrade, page.ContentHash); Assert.Equal(pageUpdatedAtBeforeUpgrade, page.UpdatedAt); Assert.False(page.IsTrashed); var project = await db.Projects.FindAsync(projectId); Assert.NotNull(project); Assert.Equal("Bestaand productieproject", project!.Name); Assert.Equal(projectSlugBeforeUpgrade, project.Slug); Assert.Equal(ProjectLifecycleStatus.Concept, project.Status); // The new V2 tables exist and are queryable (empty — no inventory // snapshot has ever been posted against this rehearsal database), // proving the upgrade is additive, not merely non-destructive to what // already existed. Assert.Equal(0, await db.InventorySnapshots.CountAsync()); Assert.Equal(0, await db.InfrastructureServices.CountAsync()); Assert.Equal(0, await db.EditorialProfiles.CountAsync()); Assert.Equal(0, await db.ManualOverrides.CountAsync()); } } finally { await using var admin = new NpgsqlConnection(postgres.AdminConnectionString); await admin.OpenAsync(); await using (var terminate = admin.CreateCommand()) { terminate.CommandText = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = @name AND pid <> pg_backend_pid()"; terminate.Parameters.AddWithValue("name", databaseName); await terminate.ExecuteNonQueryAsync(); } await using var drop = admin.CreateCommand(); drop.CommandText = $"DROP DATABASE IF EXISTS \"{databaseName}\""; await drop.ExecuteNonQueryAsync(); } } private static DateTimeOffset TruncateToMicroseconds(DateTimeOffset value) => new(value.Ticks - (value.Ticks % (TimeSpan.TicksPerMillisecond / 1000)), value.Offset); }