using System.Data; using System.Text.RegularExpressions; using JobTrackerApi.Data; using JobTrackerApi.Services; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class MigrationChainTests { private const string SnapshotMigration = "20260711181039_SyncModelSnapshot"; [Fact] public async Task Blank_sqlite_chain_reaches_latest_and_is_idempotent() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); await db.Database.MigrateAsync(); await db.Database.MigrateAsync(); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); Assert.Equal(3, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('AspNetUsers', 'AiInteractions', 'AiUsageRecords'); """)); Assert.Equal(2, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('SubmittedApplicationPackages', 'SubmittedPackageAttachments'); """)); Assert.Equal(10, await ScalarAsync(connection, """ SELECT COUNT(*) FROM pragma_table_info('JobApplications') WHERE name IN ('OwnerUserId', 'ShortSummary', 'TailoredCvText', 'TailoredCvUpdatedAt', 'LastReminderEmailSentAt', 'RecruiterMessageDraft', 'SalaryMin', 'SalaryMax', 'SalaryCurrency', 'SalaryPeriod'); """)); Assert.Equal(1, await ScalarAsync(connection, """ SELECT COUNT(*) FROM pragma_table_info('AspNetUsers') WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true'); """)); Assert.Equal(1, await ScalarAsync(connection, """ SELECT COUNT(*) FROM pragma_table_info('AspNetUsers') WHERE name = 'UiLanguage'; """)); Assert.Equal(10, await ScalarAsync(connection, """ SELECT COUNT(*) FROM pragma_table_info('SystemEmailSettings'); """)); } [Fact] public async Task Populated_pre_job_split_database_preserves_rows_through_latest() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync(SnapshotMigration); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Migration fixture'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, Status, ResponseReceived, OwnerUserId, ShortSummary) VALUES (1, 'Preserved role', '2026-07-01 09:30:00', 'Applied', 0, 'owner-1', 'Preserved summary'); """); await migrator.MigrateAsync(); await using var command = connection.CreateCommand(); command.CommandText = """ SELECT JobTitle, DateApplied, SavedAt, OwnerUserId, ShortSummary FROM JobApplications WHERE Id = 1; """; await using var reader = await command.ExecuteReaderAsync(); Assert.True(await reader.ReadAsync()); Assert.Equal("Preserved role", reader.GetString(0)); Assert.Equal(reader.GetString(1), reader.GetString(2)); Assert.Equal("owner-1", reader.GetString(3)); Assert.Equal("Preserved summary", reader.GetString(4)); Assert.False(await reader.ReadAsync()); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task MariaDb_script_keeps_new_identifiers_and_bootstrap_types_provider_safe() { var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns((string?)null); var options = new DbContextOptionsBuilder() .UseMySql( "Server=127.0.0.1;Database=script_only;User=none;Password=none;", new MariaDbServerVersion(new Version(11, 0, 0))) .Options; await using var db = new JobTrackerContext(options, currentUser.Object); var script = db.GetService().GenerateScript(); Assert.Contains("`OwnerUserId` varchar(255)", script, StringComparison.Ordinal); Assert.Contains("CONSTRAINT `FK_AccountDeletionFiles_Request`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUsers`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AiInteractions`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `SystemEmailSettings`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `UserRuleSettings`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `GmailReviewDecisions`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `TrustedDevices`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `UserSessions`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `GmailConnections`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `ImapConnections`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CvUploadArtifacts`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CvExtractionRuns`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `TailoredCvDrafts`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `InterviewPrepNotes`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CvVariants`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CvVariantVersions`", script, StringComparison.Ordinal); 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 `SubmittedApplicationPackages`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `SubmittedPackageAttachments`", 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("CREATE TABLE IF NOT EXISTS `CareerEvidence`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetRoles`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserClaims`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserLogins`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserRoles`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserTokens`", script, StringComparison.Ordinal); Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal); Assert.All( Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value), identifier => Assert.True(identifier.Length <= 64, $"MariaDB constraint identifier exceeds 64 characters: {identifier}")); } [Fact] public async Task System_email_settings_adoption_preserves_legacy_rows() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260828090000_AddUiLanguagePreference"); await ExecuteAsync(connection, """ CREATE TABLE "SystemEmailSettings" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_SystemEmailSettings" PRIMARY KEY, "Enabled" INTEGER NULL, "SmtpHost" TEXT NULL, "SmtpPort" INTEGER NULL, "SmtpUser" TEXT NULL, "SmtpPassword" TEXT NULL, "From" TEXT NULL, "FromName" TEXT NULL, "SmtpEnableSsl" INTEGER NULL, "SmtpTimeoutMs" INTEGER NULL ); INSERT INTO "SystemEmailSettings" ("Id", "Enabled", "SmtpHost", "SmtpPort") VALUES (1, 1, 'smtp.fixture.invalid', 587); """); await migrator.MigrateAsync(); Assert.Equal("smtp.fixture.invalid", await ScalarAsync(connection, "SELECT SmtpHost FROM SystemEmailSettings WHERE Id = 1;")); Assert.Equal(587L, await ScalarAsync(connection, "SELECT SmtpPort FROM SystemEmailSettings WHERE Id = 1;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); await migrator.MigrateAsync("20260828090000_AddUiLanguagePreference"); Assert.Equal("smtp.fixture.invalid", await ScalarAsync(connection, "SELECT SmtpHost FROM SystemEmailSettings WHERE Id = 1;")); await migrator.MigrateAsync(); Assert.Equal("smtp.fixture.invalid", await ScalarAsync(connection, "SELECT SmtpHost FROM SystemEmailSettings WHERE Id = 1;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task User_rule_settings_adoption_preserves_legacy_rows() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830120000_AdoptSystemEmailSettingsSchema"); await ExecuteAsync(connection, """ CREATE TABLE "UserRuleSettings" ( "OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY, "AppliedFollowUpDays" INTEGER NOT NULL, "AppliedGhostDays" INTEGER NOT NULL, "OfferFollowUpDays" INTEGER NOT NULL, "OfferGhostDays" INTEGER NOT NULL, "FeedbackFollowUpDays" INTEGER NOT NULL, "FeedbackGhostDays" INTEGER NOT NULL ); INSERT INTO "UserRuleSettings" ("OwnerUserId", "AppliedFollowUpDays", "AppliedGhostDays", "OfferFollowUpDays", "OfferGhostDays", "FeedbackFollowUpDays", "FeedbackGhostDays") VALUES ('owner-fixture', 3, 9, 4, 10, 5, 11); """); await migrator.MigrateAsync(); Assert.Equal(3L, await ScalarAsync(connection, "SELECT AppliedFollowUpDays FROM UserRuleSettings WHERE OwnerUserId = 'owner-fixture';")); await migrator.MigrateAsync("20260830120000_AdoptSystemEmailSettingsSchema"); Assert.Equal(3L, await ScalarAsync(connection, "SELECT AppliedFollowUpDays FROM UserRuleSettings WHERE OwnerUserId = 'owner-fixture';")); await migrator.MigrateAsync(); Assert.Equal(3L, await ScalarAsync(connection, "SELECT AppliedFollowUpDays FROM UserRuleSettings WHERE OwnerUserId = 'owner-fixture';")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Gmail_review_decision_adoption_preserves_legacy_rows() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830121000_AdoptUserRuleSettingsSchema"); await ExecuteAsync(connection, """ CREATE TABLE "GmailReviewDecisions" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "ThreadId" TEXT NOT NULL, "JobApplicationId" INTEGER NULL, "Decision" TEXT NOT NULL, "Note" TEXT NULL, "UpdatedAt" TEXT NOT NULL ); INSERT INTO "GmailReviewDecisions" ("OwnerUserId", "ThreadId", "JobApplicationId", "Decision", "Note", "UpdatedAt") VALUES ('owner-fixture', 'thread-fixture', NULL, 'linked', 'Preserve me', '2026-08-30T09:00:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("linked", await ScalarAsync(connection, "SELECT Decision FROM GmailReviewDecisions WHERE ThreadId = 'thread-fixture';")); await migrator.MigrateAsync("20260830121000_AdoptUserRuleSettingsSchema"); Assert.Equal("Preserve me", await ScalarAsync(connection, "SELECT Note FROM GmailReviewDecisions WHERE ThreadId = 'thread-fixture';")); await migrator.MigrateAsync(); Assert.Equal("Preserve me", await ScalarAsync(connection, "SELECT Note FROM GmailReviewDecisions WHERE ThreadId = 'thread-fixture';")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Authentication_support_adoption_preserves_legacy_rows() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema"); await ExecuteAsync(connection, """ CREATE TABLE "TwoFactorRecoveryCodes" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT, "UserId" TEXT NOT NULL, "CodeHash" TEXT NOT NULL, "CreatedAtUtc" TEXT NOT NULL, "UsedAtUtc" TEXT NULL ); INSERT INTO "TwoFactorRecoveryCodes" ("UserId", "CodeHash", "CreatedAtUtc") VALUES ('owner-fixture', 'recovery-hash', '2026-08-30T09:00:00+00:00'); CREATE TABLE "TrustedDevices" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT, "UserId" TEXT NOT NULL, "TokenHash" TEXT NOT NULL, "DeviceLabel" TEXT NULL, "CreatedAtUtc" TEXT NOT NULL, "LastSeenAtUtc" TEXT NOT NULL, "ExpiresAtUtc" TEXT NOT NULL ); INSERT INTO "TrustedDevices" ("UserId", "TokenHash", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc") VALUES ('owner-fixture', 'device-hash', 'Fixture browser', '2026-08-30T09:00:00+00:00', '2026-08-30T09:30:00+00:00', '2026-09-29T09:00:00+00:00'); CREATE TABLE "UserSessions" ( "Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY, "UserId" TEXT NOT NULL, "DeviceLabel" TEXT NULL, "CreatedAtUtc" TEXT NOT NULL, "LastSeenAtUtc" TEXT NOT NULL, "ExpiresAtUtc" TEXT NOT NULL, "RevokedAtUtc" TEXT NULL ); INSERT INTO "UserSessions" ("Id", "UserId", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc") VALUES ('session-fixture', 'owner-fixture', 'Fixture session', '2026-08-30T09:00:00+00:00', '2026-08-30T09:30:00+00:00', '2026-08-31T09:00:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("recovery-hash", await ScalarAsync(connection, "SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';")); Assert.Equal("Fixture browser", await ScalarAsync(connection, "SELECT DeviceLabel FROM TrustedDevices WHERE TokenHash = 'device-hash';")); Assert.Equal("Fixture session", await ScalarAsync(connection, "SELECT DeviceLabel FROM UserSessions WHERE Id = 'session-fixture';")); await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema"); Assert.Equal("recovery-hash", await ScalarAsync(connection, "SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';")); Assert.Equal("device-hash", await ScalarAsync(connection, "SELECT TokenHash FROM TrustedDevices WHERE UserId = 'owner-fixture';")); Assert.Equal("session-fixture", await ScalarAsync(connection, "SELECT Id FROM UserSessions WHERE UserId = 'owner-fixture';")); await migrator.MigrateAsync(); Assert.Equal(3L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc', 'IX_TrustedDevices_UserId', 'IX_UserSessions_UserId'); """)); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Email_connection_adoption_preserves_encrypted_credentials() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830123000_AdoptAuthenticationSupportSchema"); await ExecuteAsync(connection, """ CREATE TABLE "GmailConnections" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "GmailAddress" TEXT NOT NULL, "EncryptedRefreshToken" TEXT NOT NULL, "EncryptedAccessToken" TEXT NULL, "AccessTokenExpiresAt" TEXT NULL, "Scope" TEXT NOT NULL, "ConnectedAt" TEXT NOT NULL, "LastSyncedAt" TEXT NULL, "LastSyncAttemptedAt" TEXT NULL, "LastSyncSucceededAt" TEXT NULL, "LastSyncMode" TEXT NULL, "LastSyncSource" TEXT NULL, "LastSyncStatus" TEXT NULL, "LastSyncError" TEXT NULL ); INSERT INTO "GmailConnections" ("OwnerUserId", "GmailAddress", "EncryptedRefreshToken", "Scope", "ConnectedAt") VALUES ('owner-fixture', 'gmail@example.invalid', 'gmail-secret', 'mail.read', '2026-08-30T09:00:00+00:00'); CREATE TABLE "MicrosoftGraphConnections" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_MicrosoftGraphConnections" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "MailAddress" TEXT NOT NULL, "EncryptedRefreshToken" TEXT NOT NULL, "EncryptedAccessToken" TEXT NULL, "AccessTokenExpiresAt" TEXT NULL, "Scope" TEXT NOT NULL, "ConnectedAt" TEXT NOT NULL, "LastSyncedAt" TEXT NULL, "LastSyncAttemptedAt" TEXT NULL, "LastSyncSucceededAt" TEXT NULL, "LastSyncMode" TEXT NULL, "LastSyncSource" TEXT NULL, "LastSyncStatus" TEXT NULL, "LastSyncError" TEXT NULL ); INSERT INTO "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress", "EncryptedRefreshToken", "Scope", "ConnectedAt") VALUES ('owner-fixture', 'graph@example.invalid', 'graph-secret', 'Mail.Read', '2026-08-30T09:00:00+00:00'); CREATE TABLE "ImapConnections" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "Host" TEXT NOT NULL, "Port" INTEGER NOT NULL, "UseSsl" INTEGER NOT NULL, "Username" TEXT NOT NULL, "EncryptedPassword" TEXT NOT NULL, "ConnectedAt" TEXT NOT NULL, "LastSyncedAt" TEXT NULL, "LastSyncAttemptedAt" TEXT NULL, "LastSyncSucceededAt" TEXT NULL, "LastSyncMode" TEXT NULL, "LastSyncSource" TEXT NULL, "LastSyncStatus" TEXT NULL, "LastSyncError" TEXT NULL ); INSERT INTO "ImapConnections" ("OwnerUserId", "Host", "Port", "UseSsl", "Username", "EncryptedPassword", "ConnectedAt") VALUES ('owner-fixture', 'imap.example.invalid', 993, 1, 'imap-user', 'imap-secret', '2026-08-30T09:00:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("gmail-secret", await ScalarAsync(connection, "SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';")); Assert.Equal("graph-secret", await ScalarAsync(connection, "SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';")); Assert.Equal("imap-secret", await ScalarAsync(connection, "SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';")); await migrator.MigrateAsync("20260830123000_AdoptAuthenticationSupportSchema"); Assert.Equal("gmail-secret", await ScalarAsync(connection, "SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';")); Assert.Equal("graph-secret", await ScalarAsync(connection, "SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';")); Assert.Equal("imap-secret", await ScalarAsync(connection, "SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';")); await migrator.MigrateAsync(); Assert.Equal(5L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_GmailConnections_OwnerUserId', 'IX_GmailConnections_OwnerUserId_GmailAddress', 'IX_MicrosoftGraphConnections_OwnerUserId', 'IX_MicrosoftGraphConnections_OwnerUserId_MailAddress', 'IX_ImapConnections_OwnerUserId'); """)); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Cv_extraction_adoption_preserves_artifacts_results_and_relationship() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830124000_AdoptEmailConnectionSchema"); await ExecuteAsync(connection, """ CREATE TABLE "CvUploadArtifacts" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_CvUploadArtifacts" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "OriginalFileName" TEXT NOT NULL, "StoredFileName" TEXT NOT NULL, "MimeType" TEXT NOT NULL, "ByteSize" INTEGER NOT NULL, "Sha256" TEXT NOT NULL, "StoragePath" TEXT NOT NULL, "UploadedAtUtc" TEXT NOT NULL ); CREATE TABLE "CvExtractionRuns" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_CvExtractionRuns" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "ArtifactId" INTEGER NULL, "Trigger" TEXT NOT NULL, "ParserVersion" TEXT NOT NULL, "NormalizerVersion" TEXT NOT NULL, "LlmPromptVersion" TEXT NOT NULL, "Status" TEXT NOT NULL, "RawExtractedText" TEXT NULL, "NormalizedText" TEXT NULL, "StructuredProfileJson" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAtUtc" TEXT NOT NULL, "CompletedAtUtc" TEXT NULL, "AppliedAtUtc" TEXT NULL, CONSTRAINT "FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId" FOREIGN KEY ("ArtifactId") REFERENCES "CvUploadArtifacts" ("Id") ON DELETE SET NULL ); INSERT INTO "CvUploadArtifacts" ("OwnerUserId", "OriginalFileName", "StoredFileName", "MimeType", "ByteSize", "Sha256", "StoragePath", "UploadedAtUtc") VALUES ('owner-fixture', 'resume.pdf', 'fixture.bin', 'application/pdf', 4096, 'fixture-sha', 'uploads/fixture.bin', '2026-08-30T09:00:00+00:00'); INSERT INTO "CvExtractionRuns" ("OwnerUserId", "ArtifactId", "Trigger", "ParserVersion", "NormalizerVersion", "LlmPromptVersion", "Status", "StructuredProfileJson", "StartedAtUtc") VALUES ('owner-fixture', 1, 'upload', 'parser-v1', 'normalizer-v1', 'prompt-v1', 'completed', '{"summary":"preserve me"}', '2026-08-30T09:01:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("fixture-sha", await ScalarAsync(connection, "SELECT Sha256 FROM CvUploadArtifacts WHERE Id = 1;")); Assert.Equal("{\"summary\":\"preserve me\"}", await ScalarAsync(connection, "SELECT StructuredProfileJson FROM CvExtractionRuns WHERE ArtifactId = 1;")); await migrator.MigrateAsync("20260830124000_AdoptEmailConnectionSchema"); Assert.Equal("fixture-sha", await ScalarAsync(connection, "SELECT Sha256 FROM CvUploadArtifacts WHERE Id = 1;")); await migrator.MigrateAsync(); Assert.Equal(3L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc', 'IX_CvExtractionRuns_OwnerUserId_StartedAtUtc', 'IX_CvExtractionRuns_ArtifactId'); """)); await ExecuteAsync(connection, "DELETE FROM CvUploadArtifacts WHERE Id = 1;"); Assert.Equal(1L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CvExtractionRuns WHERE ArtifactId IS NULL;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Tailored_cv_adoption_preserves_document_and_cascade_relationship() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830125000_AdoptCvExtractionSchema"); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Fixture company'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId, HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) VALUES (1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00', 'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0); CREATE TABLE "TailoredCvDrafts" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL, "CanonicalProfileVersion" INTEGER NULL, "TemplateId" TEXT NOT NULL, "Headline" TEXT NULL, "SummaryJson" TEXT NULL, "SelectedSkillsJson" TEXT NULL, "ExperienceJson" TEXT NULL, "EducationJson" TEXT NULL, "CustomSectionsJson" TEXT NULL, "RenderOptionsJson" TEXT NULL, "GenerationContextHash" TEXT NULL, "LastGeneratedAtUtc" TEXT NULL, "LastEditedAtUtc" TEXT NULL, "Status" TEXT NOT NULL, CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE ); INSERT INTO "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId", "CanonicalProfileVersion", "TemplateId", "SummaryJson", "CustomSectionsJson", "GenerationContextHash", "Status") VALUES ('owner-fixture', 1, 7, 'code', '{"text":"preserve me"}', '[{"title":"Awards"}]', 'context-fixture', 'edited'); """); await migrator.MigrateAsync(); Assert.Equal("{\"text\":\"preserve me\"}", await ScalarAsync(connection, "SELECT SummaryJson FROM TailoredCvDrafts WHERE JobApplicationId = 1;")); await migrator.MigrateAsync("20260830125000_AdoptCvExtractionSchema"); Assert.Equal("context-fixture", await ScalarAsync(connection, "SELECT GenerationContextHash FROM TailoredCvDrafts WHERE JobApplicationId = 1;")); await migrator.MigrateAsync(); Assert.Equal(2L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_TailoredCvDrafts_OwnerUserId_JobApplicationId', 'IX_TailoredCvDrafts_JobApplicationId'); """)); await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM TailoredCvDrafts;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Job_workspace_note_adoption_preserves_outputs_and_cascade_relationships() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830126000_AdoptTailoredCvDraftSchema"); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Fixture company'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId, HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) VALUES (1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00', 'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0); CREATE TABLE "InterviewPrepNotes" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepNotes" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL, "AttachmentContextSignature" TEXT NOT NULL, "Summary" TEXT NOT NULL, "TalkingPointsJson" TEXT NOT NULL, "LikelyQuestionsJson" TEXT NOT NULL, "WeakSpotsJson" TEXT NOT NULL, "GeneratedAtUtc" TEXT NOT NULL, CONSTRAINT "FK_InterviewPrepNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE ); INSERT INTO "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId", "AttachmentContextSignature", "Summary", "TalkingPointsJson", "LikelyQuestionsJson", "WeakSpotsJson", "GeneratedAtUtc") VALUES ('owner-fixture', 1, 'attachments-v1', 'Preserve interview summary', '["point"]', '["question"]', '["gap"]', '2026-08-30T09:00:00+00:00'); CREATE TABLE "AiWorkspaceNotes" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_AiWorkspaceNotes" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL, "NoteType" TEXT NOT NULL, "AttachmentContextSignature" TEXT NOT NULL, "ResultJson" TEXT NOT NULL, "GeneratedAtUtc" TEXT NOT NULL, CONSTRAINT "FK_AiWorkspaceNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE ); INSERT INTO "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType", "AttachmentContextSignature", "ResultJson", "GeneratedAtUtc") VALUES ('owner-fixture', 1, 'candidate-fit', 'attachments-v1', '{"strength":"preserve me"}', '2026-08-30T09:00:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("Preserve interview summary", await ScalarAsync(connection, "SELECT Summary FROM InterviewPrepNotes WHERE JobApplicationId = 1;")); Assert.Equal("{\"strength\":\"preserve me\"}", await ScalarAsync(connection, "SELECT ResultJson FROM AiWorkspaceNotes WHERE JobApplicationId = 1;")); await migrator.MigrateAsync("20260830126000_AdoptTailoredCvDraftSchema"); Assert.Equal("attachments-v1", await ScalarAsync(connection, "SELECT AttachmentContextSignature FROM InterviewPrepNotes WHERE JobApplicationId = 1;")); await migrator.MigrateAsync(); Assert.Equal(2L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_InterviewPrepNotes_OwnerUserId_JobApplicationId', 'IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType'); """)); await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM InterviewPrepNotes;")); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AiWorkspaceNotes;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Cv_variant_adoption_preserves_settings_versions_and_relationships() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830127000_AdoptJobWorkspaceNotesSchema"); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Fixture company'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId, HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) VALUES (1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00', 'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0); CREATE TABLE "CvVariants" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariants" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "PublicSlug" TEXT NOT NULL, "Name" TEXT NOT NULL, "JobApplicationId" INTEGER NULL, "SettingsJson" TEXT NOT NULL, "IsPublic" INTEGER NOT NULL, "Version" INTEGER NOT NULL, "CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL, CONSTRAINT "FK_CvVariants_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE SET NULL ); CREATE TABLE "CvVariantVersions" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariantVersions" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "CvVariantId" INTEGER NOT NULL, "Version" INTEGER NOT NULL, "SettingsJson" TEXT NOT NULL, "Source" TEXT NOT NULL, "CreatedAtUtc" TEXT NOT NULL, CONSTRAINT "FK_CvVariantVersions_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE ); INSERT INTO "CvVariants" ("OwnerUserId", "PublicSlug", "Name", "JobApplicationId", "SettingsJson", "IsPublic", "Version", "CreatedAtUtc", "UpdatedAtUtc") VALUES ('owner-fixture', 'public-fixture', 'Backend CV', 1, '{"templateId":"code","accent":"#123456"}', 1, 2, '2026-08-30T09:00:00+00:00', '2026-08-30T09:10:00+00:00'); INSERT INTO "CvVariantVersions" ("OwnerUserId", "CvVariantId", "Version", "SettingsJson", "Source", "CreatedAtUtc") VALUES ('owner-fixture', 1, 1, '{"templateId":"code"}', 'create', '2026-08-30T09:00:00+00:00'), ('owner-fixture', 1, 2, '{"templateId":"code","accent":"#123456"}', 'autosave', '2026-08-30T09:10:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("public-fixture", await ScalarAsync(connection, "SELECT PublicSlug FROM CvVariants WHERE Id = 1;")); Assert.Equal(2L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CvVariantVersions WHERE CvVariantId = 1;")); await migrator.MigrateAsync("20260830127000_AdoptJobWorkspaceNotesSchema"); Assert.Equal("Backend CV", await ScalarAsync(connection, "SELECT Name FROM CvVariants WHERE Id = 1;")); await migrator.MigrateAsync(); Assert.Equal(4L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_CvVariants_JobApplicationId', 'IX_CvVariants_OwnerUserId_UpdatedAtUtc', 'IX_CvVariants_PublicSlug', 'IX_CvVariantVersions_CvVariantId_Version'); """)); await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;"); Assert.Equal(1L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CvVariants WHERE JobApplicationId IS NULL;")); await ExecuteAsync(connection, "DELETE FROM CvVariants WHERE Id = 1;"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CvVariantVersions;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Ai_interaction_adoption_preserves_history_usage_and_cascade() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830128000_AdoptCvVariantSchema"); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Fixture company'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId, HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) VALUES (1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00', 'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0); INSERT INTO AiInteractions (OwnerUserId, JobApplicationId, Module, Mode, Title, Provider, ResultJson, InputCharacterCount, OutputCharacterCount, EstimatedTokenCount, CreatedAtUtc) VALUES ('owner-fixture', 1, 'cover-letter', 'professional', 'Cover letter draft', 'fixture-provider', '{"text":"preserve me"}', 1200, 640, 460, '2026-08-30T09:00:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("{\"text\":\"preserve me\"}", await ScalarAsync(connection, "SELECT ResultJson FROM AiInteractions WHERE Id = 1;")); Assert.Equal(460L, await ScalarAsync(connection, "SELECT EstimatedTokenCount FROM AiInteractions WHERE Id = 1;")); await migrator.MigrateAsync("20260830128000_AdoptCvVariantSchema"); Assert.Equal("professional", await ScalarAsync(connection, "SELECT Mode FROM AiInteractions WHERE Id = 1;")); await migrator.MigrateAsync(); Assert.Equal(2L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_AiInteractions_JobApplicationId', 'IX_AiInteractions_Owner_Job_Module_Created'); """)); await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AiInteractions;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Checklist_adoption_preserves_system_and_manual_workflow_state() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830129000_AdoptAiInteractionSchema"); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Fixture company'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId, HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) VALUES (1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00', 'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0); CREATE TABLE "ApplicationChecklistItems" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_ApplicationChecklistItems" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL, "SystemKey" TEXT NULL, "AutoSignal" TEXT NULL, "Title" TEXT NOT NULL, "Description" TEXT NULL, "Category" TEXT NOT NULL, "Status" TEXT NOT NULL, "Section" TEXT NULL, "SortOrder" INTEGER NOT NULL, "IsSystemGenerated" INTEGER NOT NULL, "IsAutoCompleted" INTEGER NOT NULL, "CompletedAt" TEXT NULL, "CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL, CONSTRAINT "FK_ApplicationChecklistItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE ); INSERT INTO "ApplicationChecklistItems" ("OwnerUserId", "JobApplicationId", "SystemKey", "AutoSignal", "Title", "Category", "Status", "Section", "SortOrder", "IsSystemGenerated", "IsAutoCompleted", "CompletedAt", "CreatedAtUtc", "UpdatedAtUtc") VALUES ('owner-fixture', 1, 'prepare-cv', 'cv-ready', 'Prepare CV', 'preparation', 'done', 'cv', 10, 1, 1, '2026-08-30T09:10:00+00:00', '2026-08-30T09:00:00+00:00', '2026-08-30T09:10:00+00:00'), ('owner-fixture', 1, NULL, NULL, 'Call recruiter', 'custom', 'pending', NULL, 20, 0, 0, NULL, '2026-08-30T09:00:00+00:00', '2026-08-30T09:00:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("done", await ScalarAsync(connection, "SELECT Status FROM ApplicationChecklistItems WHERE SystemKey = 'prepare-cv';")); Assert.Equal("Call recruiter", await ScalarAsync(connection, "SELECT Title FROM ApplicationChecklistItems WHERE SystemKey IS NULL;")); await migrator.MigrateAsync("20260830129000_AdoptAiInteractionSchema"); Assert.Equal(2L, await ScalarAsync(connection, "SELECT COUNT(*) FROM ApplicationChecklistItems WHERE JobApplicationId = 1;")); await migrator.MigrateAsync(); Assert.Equal(2L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'IX_ApplicationChecklistItems_JobApplicationId_SystemKey', 'IX_ApplicationChecklistItems_Owner_Job_Sort'); """)); await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM ApplicationChecklistItems;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Cover_letter_version_adoption_preserves_manual_and_ai_history() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830130000_AdoptApplicationChecklistSchema"); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Fixture company'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId, HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) VALUES (1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00', 'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0); CREATE TABLE "CoverLetterVersions" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_CoverLetterVersions" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL, "Version" INTEGER NOT NULL, "Text" TEXT NOT NULL, "Source" TEXT NOT NULL, "AiAction" TEXT NULL, "CreatedAtUtc" TEXT NOT NULL, CONSTRAINT "FK_CoverLetterVersions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE ); INSERT INTO "CoverLetterVersions" ("OwnerUserId", "JobApplicationId", "Version", "Text", "Source", "AiAction", "CreatedAtUtc") VALUES ('owner-fixture', 1, 1, 'Manual first draft', 'manual', NULL, '2026-08-30T09:00:00+00:00'), ('owner-fixture', 1, 2, 'Shorter approved draft', 'ai', 'shorten', '2026-08-30T09:10:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("Manual first draft", await ScalarAsync(connection, "SELECT Text FROM CoverLetterVersions WHERE Version = 1;")); Assert.Equal("shorten", await ScalarAsync(connection, "SELECT AiAction FROM CoverLetterVersions WHERE Version = 2;")); await migrator.MigrateAsync("20260830130000_AdoptApplicationChecklistSchema"); Assert.Equal(2L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CoverLetterVersions WHERE JobApplicationId = 1;")); await migrator.MigrateAsync(); Assert.Equal(1L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'IX_CoverLetterVersions_Owner_Job_Version'; """)); await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CoverLetterVersions;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Interview_prep_item_adoption_preserves_user_content_and_practice_state() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830131000_AdoptCoverLetterVersionSchema"); await ExecuteAsync(connection, """ INSERT INTO Companies (Name) VALUES ('Fixture company'); INSERT INTO JobApplications (CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId, HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) VALUES (1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00', 'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0); CREATE TABLE "InterviewPrepItems" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepItems" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL, "Category" TEXT NOT NULL, "Title" TEXT NOT NULL, "Content" TEXT NULL, "Source" TEXT NOT NULL, "IsPrepared" INTEGER NOT NULL, "SortOrder" INTEGER NOT NULL, "CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL, CONSTRAINT "FK_InterviewPrepItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE ); INSERT INTO "InterviewPrepItems" ("OwnerUserId", "JobApplicationId", "Category", "Title", "Content", "Source", "IsPrepared", "SortOrder", "CreatedAtUtc", "UpdatedAtUtc") VALUES ('owner-fixture', 1, 'star', 'Describe a difficult incident', 'Situation, task, action, result', 'user', 1, 10, '2026-08-30T09:00:00+00:00', '2026-08-30T09:10:00+00:00'), ('owner-fixture', 1, 'technical', 'Explain dependency injection', 'Accepted suggestion, then edited', 'ai', 0, 20, '2026-08-30T09:00:00+00:00', '2026-08-30T09:15:00+00:00'); """); await migrator.MigrateAsync(); Assert.Equal("Situation, task, action, result", await ScalarAsync(connection, "SELECT Content FROM InterviewPrepItems WHERE SortOrder = 10;")); Assert.Equal(1L, await ScalarAsync(connection, "SELECT IsPrepared FROM InterviewPrepItems WHERE SortOrder = 10;")); await migrator.MigrateAsync("20260830131000_AdoptCoverLetterVersionSchema"); Assert.Equal("ai", await ScalarAsync(connection, "SELECT Source FROM InterviewPrepItems WHERE SortOrder = 20;")); await migrator.MigrateAsync(); Assert.Equal(1L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'IX_InterviewPrepItems_Owner_Job_Sort'; """)); await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM InterviewPrepItems;")); 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(); 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(connection, "SELECT LongTailJson FROM CareerProfiles WHERE Id = 1;")); Assert.Equal("Previous version", await ScalarAsync(connection, "SELECT json_extract(ProfileJson, '$.summary') FROM CareerProfileVersions WHERE Version = 2;")); Assert.Equal(6L, await ScalarAsync(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(connection, "SELECT Company FROM CareerExperiences WHERE ItemKey = 'experience-1';")); await migrator.MigrateAsync(); Assert.Equal(14L, await ScalarAsync(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(connection, "SELECT COUNT(*) FROM CareerProfileVersions;")); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CareerExperiences;")); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM CareerLanguages;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Identity_adoption_preserves_accounts_credentials_roles_logins_claims_and_tokens() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); var migrator = db.GetService(); await migrator.MigrateAsync("20260830133000_AdoptCareerProfileSchema"); await ExecuteAsync(connection, """ CREATE TABLE "AspNetRoles" ( "Id" TEXT NOT NULL PRIMARY KEY, "Name" TEXT NULL, "NormalizedName" TEXT NULL, "ConcurrencyStamp" TEXT NULL); CREATE TABLE "AspNetRoleClaims" ( "Id" INTEGER PRIMARY KEY AUTOINCREMENT, "RoleId" TEXT NOT NULL, "ClaimType" TEXT NULL, "ClaimValue" TEXT NULL, FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE); CREATE TABLE "AspNetUserClaims" ( "Id" INTEGER PRIMARY KEY AUTOINCREMENT, "UserId" TEXT NOT NULL, "ClaimType" TEXT NULL, "ClaimValue" TEXT NULL, FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); CREATE TABLE "AspNetUserLogins" ( "LoginProvider" TEXT NOT NULL, "ProviderKey" TEXT NOT NULL, "ProviderDisplayName" TEXT NULL, "UserId" TEXT NOT NULL, PRIMARY KEY ("LoginProvider", "ProviderKey"), FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); CREATE TABLE "AspNetUserRoles" ( "UserId" TEXT NOT NULL, "RoleId" TEXT NOT NULL, PRIMARY KEY ("UserId", "RoleId"), FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); CREATE TABLE "AspNetUserTokens" ( "UserId" TEXT NOT NULL, "LoginProvider" TEXT NOT NULL, "Name" TEXT NOT NULL, "Value" TEXT NULL, PRIMARY KEY ("UserId", "LoginProvider", "Name"), FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); INSERT INTO "AspNetUsers" ("Id", "UserName", "NormalizedUserName", "Email", "NormalizedEmail", "EmailConfirmed", "PasswordHash", "PhoneNumberConfirmed", "TwoFactorEnabled", "LockoutEnabled", "AccessFailedCount", "AiEnabled", "ExternalAiProcessingAllowed", "DeletionStatus", "EmailFollowUpRemindersEnabled", "UiLanguage") VALUES ('user-fixture', 'connor', 'CONNOR', 'connor@example.test', 'CONNOR@EXAMPLE.TEST', 1, 'preserved-password-hash', 0, 1, 1, 0, 1, 0, 'active', 1, 'nb'); INSERT INTO "AspNetRoles" VALUES ('role-admin', 'Admin', 'ADMIN', 'role-stamp'); INSERT INTO "AspNetRoleClaims" ("RoleId", "ClaimType", "ClaimValue") VALUES ('role-admin', 'permission', 'admin.manage'); INSERT INTO "AspNetUserClaims" ("UserId", "ClaimType", "ClaimValue") VALUES ('user-fixture', 'locale', 'nb'); INSERT INTO "AspNetUserLogins" VALUES ('google', 'subject-1', 'Google', 'user-fixture'); INSERT INTO "AspNetUserRoles" VALUES ('user-fixture', 'role-admin'); INSERT INTO "AspNetUserTokens" VALUES ('user-fixture', 'authenticator', 'refresh', 'encrypted-token'); """); await migrator.MigrateAsync(); Assert.Equal("preserved-password-hash", await ScalarAsync(connection, "SELECT PasswordHash FROM AspNetUsers WHERE Id = 'user-fixture';")); Assert.Equal("nb", await ScalarAsync(connection, "SELECT UiLanguage FROM AspNetUsers WHERE Id = 'user-fixture';")); Assert.Equal("admin.manage", await ScalarAsync(connection, "SELECT ClaimValue FROM AspNetRoleClaims WHERE RoleId = 'role-admin';")); Assert.Equal("encrypted-token", await ScalarAsync(connection, "SELECT Value FROM AspNetUserTokens WHERE UserId = 'user-fixture';")); await migrator.MigrateAsync("20260830133000_AdoptCareerProfileSchema"); Assert.Equal("subject-1", await ScalarAsync(connection, "SELECT ProviderKey FROM AspNetUserLogins WHERE UserId = 'user-fixture';")); await migrator.MigrateAsync(); Assert.Equal(8L, await ScalarAsync(connection, """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ( 'RoleNameIndex', 'IX_AspNetRoleClaims_RoleId', 'EmailIndex', 'UserNameIndex', 'IX_AspNetUsers_MicrosoftTenantId_MicrosoftObjectId', 'IX_AspNetUserClaims_UserId', 'IX_AspNetUserLogins_UserId', 'IX_AspNetUserRoles_RoleId'); """)); await ExecuteAsync(connection, "DELETE FROM AspNetUsers WHERE Id = 'user-fixture';"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetUserClaims;")); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetUserLogins;")); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetUserRoles;")); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetUserTokens;")); await ExecuteAsync(connection, "DELETE FROM AspNetRoles WHERE Id = 'role-admin';"); Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetRoleClaims;")); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } private static JobTrackerContext Context(SqliteConnection connection) { var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns((string?)null); return new JobTrackerContext( new DbContextOptionsBuilder().UseSqlite(connection).Options, currentUser.Object); } private static async Task ScalarAsync(SqliteConnection connection, string sql) { await using var command = connection.CreateCommand(); command.CommandText = sql; return (T)Convert.ChangeType(await command.ExecuteScalarAsync() ?? throw new DataException(), typeof(T)); } private static async Task ExecuteAsync(SqliteConnection connection, string sql) { await using var command = connection.CreateCommand(); command.CommandText = sql; await command.ExecuteNonQueryAsync(); } }