Files
itworx-knowledge-hub-Public/tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/InPlaceUpgradeRehearsalTests.cs
T

193 lines
9.7 KiB
C#

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;
/// <summary>
/// V2-M9: rehearses the one real production upgrade path — an existing, already-
/// populated V1 database (every migration through <c>AddAutomationAndOutbox</c>,
/// matching what real production already had before any V2 code ever ran) being
/// upgraded in place by the single new V2 migration
/// (<c>AddStructuredInfrastructureKnowledge</c>). 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 <c>RestoreServiceTests.cs</c>'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.
/// </summary>
[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<KnowledgeHubDbContext>()
.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<IMigrator>().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);
}