Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+192
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user