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(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("`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()); } 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(); } }