refactor(db): migrate career profile aggregate

Transfer the canonical career profile, revision history, and six relational child collections to provider-aware migration ownership. Preserve legacy JSON, ordered child data, indexes, and cascade semantics.
This commit is contained in:
cesnimda
2026-08-30 21:54:54 +02:00
parent a906916acc
commit 90ca23de33
13 changed files with 404 additions and 355 deletions
+137
View File
@@ -118,6 +118,14 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CoverLetterVersions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `InterviewPrepItems`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfiles`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfileVersions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerExperiences`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerEducations`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerSkills`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProjects`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerCertifications`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerLanguages`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -878,6 +886,135 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Career_profile_adoption_preserves_canonical_history_and_relational_children()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var migrator = db.GetService<IMigrator>();
await migrator.MigrateAsync("20260830132000_AdoptInterviewPrepItemSchema");
await ExecuteAsync(connection, """
CREATE TABLE "CareerProfiles" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "ProfileJson" TEXT NOT NULL,
"LongTailJson" TEXT NOT NULL DEFAULT '', "Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL);
CREATE TABLE "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "CareerProfileId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL, "ProfileJson" TEXT NOT NULL, "Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerExperiences" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Title" TEXT NULL, "Company" TEXT NULL,
"Location" TEXT NULL, "Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL,
"EndDate" TEXT NULL, "IsCurrent" INTEGER NOT NULL DEFAULT 0,
"BulletsJson" TEXT NOT NULL DEFAULT '[]', "SkillsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerEducations" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Qualification" TEXT NULL,
"QualificationLevel" TEXT NULL, "Institution" TEXT NULL, "Location" TEXT NULL,
"Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL, "EndDate" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]', "CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL, "ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerSkills" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Category" TEXT NULL,
"Proficiency" TEXT NULL, "CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL, "ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerProjects" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Role" TEXT NULL,
"Location" TEXT NULL, "Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL,
"EndDate" TEXT NULL, "BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]', "LinksJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerCertifications" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Issuer" TEXT NULL,
"Location" TEXT NULL, "Date" TEXT NULL, "DateNormalized" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]', "CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL, "ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerLanguages" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Level" TEXT NULL,
"Notes" TEXT NULL, "CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
INSERT INTO "CareerProfiles" VALUES
(1, 'owner-fixture', '{"summary":"Canonical profile"}',
'{"interests":["friluftsliv"]}', 3,
'2026-08-01T09:00:00+00:00', '2026-08-30T09:00:00+00:00');
INSERT INTO "CareerProfileVersions"
("OwnerUserId", "CareerProfileId", "Version", "ProfileJson", "Source", "CreatedAtUtc")
VALUES ('owner-fixture', 1, 2, '{"summary":"Previous version"}', 'manual',
'2026-08-20T09:00:00+00:00');
INSERT INTO "CareerExperiences"
("Title", "Company", "IsCurrent", "BulletsJson", "SkillsJson", "CareerProfileId",
"OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Systemutvikler', 'Eksempel AS', 1, '["Bygget pålitelig API"]', '[".NET"]',
1, 'owner-fixture', 'experience-1', 10);
INSERT INTO "CareerEducations"
("Qualification", "Institution", "DetailsJson", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Bachelor', 'Høgskolen', '["Programmering"]', 1, 'owner-fixture', 'education-1', 20);
INSERT INTO "CareerSkills"
("Name", "Category", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('C#', 'Backend', 1, 'owner-fixture', 'skill-1', 30);
INSERT INTO "CareerProjects"
("Name", "BulletsJson", "SkillsJson", "LinksJson", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Jobbjakt', '["Dynamisk CV"]', '["React"]', '["https://example.test"]',
1, 'owner-fixture', 'project-1', 40);
INSERT INTO "CareerCertifications"
("Name", "Issuer", "DetailsJson", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Azure', 'Microsoft', '[]', 1, 'owner-fixture', 'certification-1', 50);
INSERT INTO "CareerLanguages"
("Name", "Level", "Notes", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Norsk', 'B2', 'Aktivt lærende', 1, 'owner-fixture', 'language-1', 60);
""");
await migrator.MigrateAsync();
Assert.Equal("{\"interests\":[\"friluftsliv\"]}", await ScalarAsync<string>(connection,
"SELECT LongTailJson FROM CareerProfiles WHERE Id = 1;"));
Assert.Equal("Previous version", await ScalarAsync<string>(connection,
"SELECT json_extract(ProfileJson, '$.summary') FROM CareerProfileVersions WHERE Version = 2;"));
Assert.Equal(6L, await ScalarAsync<long>(connection, """
SELECT (SELECT COUNT(*) FROM CareerExperiences) +
(SELECT COUNT(*) FROM CareerEducations) +
(SELECT COUNT(*) FROM CareerSkills) +
(SELECT COUNT(*) FROM CareerProjects) +
(SELECT COUNT(*) FROM CareerCertifications) +
(SELECT COUNT(*) FROM CareerLanguages);
"""));
await migrator.MigrateAsync("20260830132000_AdoptInterviewPrepItemSchema");
Assert.Equal("Eksempel AS", await ScalarAsync<string>(connection,
"SELECT Company FROM CareerExperiences WHERE ItemKey = 'experience-1';"));
await migrator.MigrateAsync();
Assert.Equal(14L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (
'IX_CareerProfiles_OwnerUserId',
'IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version',
'IX_CareerExperiences_CareerProfileId', 'IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerEducations_CareerProfileId', 'IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerSkills_CareerProfileId', 'IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerProjects_CareerProfileId', 'IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerCertifications_CareerProfileId', 'IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerLanguages_CareerProfileId', 'IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder');
"""));
await ExecuteAsync(connection, "DELETE FROM CareerProfiles WHERE Id = 1;");
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CareerProfileVersions;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CareerExperiences;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CareerLanguages;"));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();