Files
jobtrackingapp/JobTrackerApi.Tests/MigrationChainTests.cs
T
cesnimda b425f1edc5 refactor(db): migrate Gmail review decisions
Move creation ownership from startup reconciliation to an additive provider-aware migration. Preserve legacy decisions during downgrade and add the previously missing MariaDB table path.
2026-08-30 16:48:32 +02:00

249 lines
12 KiB
C#

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<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('AspNetUsers', 'AiInteractions', 'AiUsageRecords');
"""));
Assert.Equal(10, await ScalarAsync<long>(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<long>(connection, """
SELECT COUNT(*) FROM pragma_table_info('AspNetUsers')
WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true');
"""));
Assert.Equal(1, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM pragma_table_info('AspNetUsers') WHERE name = 'UiLanguage';
"""));
Assert.Equal(10, await ScalarAsync<long>(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<IMigrator>();
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<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns((string?)null);
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.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<IMigrator>().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("`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<IMigrator>();
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<string>(connection,
"SELECT SmtpHost FROM SystemEmailSettings WHERE Id = 1;"));
Assert.Equal(587L, await ScalarAsync<long>(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<string>(connection,
"SELECT SmtpHost FROM SystemEmailSettings WHERE Id = 1;"));
await migrator.MigrateAsync();
Assert.Equal("smtp.fixture.invalid", await ScalarAsync<string>(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<IMigrator>();
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<long>(connection,
"SELECT AppliedFollowUpDays FROM UserRuleSettings WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync("20260830120000_AdoptSystemEmailSettingsSchema");
Assert.Equal(3L, await ScalarAsync<long>(connection,
"SELECT AppliedFollowUpDays FROM UserRuleSettings WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(3L, await ScalarAsync<long>(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<IMigrator>();
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<string>(connection,
"SELECT Decision FROM GmailReviewDecisions WHERE ThreadId = 'thread-fixture';"));
await migrator.MigrateAsync("20260830121000_AdoptUserRuleSettingsSchema");
Assert.Equal("Preserve me", await ScalarAsync<string>(connection,
"SELECT Note FROM GmailReviewDecisions WHERE ThreadId = 'thread-fixture';"));
await migrator.MigrateAsync();
Assert.Equal("Preserve me", await ScalarAsync<string>(connection,
"SELECT Note FROM GmailReviewDecisions WHERE ThreadId = 'thread-fixture';"));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns((string?)null);
return new JobTrackerContext(
new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options,
currentUser.Object);
}
private static async Task<T> ScalarAsync<T>(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();
}
}