193 lines
9.7 KiB
C#
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);
|
|
}
|