Files
jobtrackingapp/JobTrackerApi.Tests/MigrationChainTests.cs
T
cesnimda e28cc0ad47 refactor(db): migrate CV extraction data
Move upload artifacts and extraction runs into an additive provider-aware migration. Preserve parsed results, indexes, and nullable artifact retention semantics.
2026-08-30 16:59:05 +02:00

477 lines
25 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("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("`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());
}
[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<IMigrator>();
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<string>(connection,
"SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';"));
Assert.Equal("Fixture browser", await ScalarAsync<string>(connection,
"SELECT DeviceLabel FROM TrustedDevices WHERE TokenHash = 'device-hash';"));
Assert.Equal("Fixture session", await ScalarAsync<string>(connection,
"SELECT DeviceLabel FROM UserSessions WHERE Id = 'session-fixture';"));
await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema");
Assert.Equal("recovery-hash", await ScalarAsync<string>(connection,
"SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';"));
Assert.Equal("device-hash", await ScalarAsync<string>(connection,
"SELECT TokenHash FROM TrustedDevices WHERE UserId = 'owner-fixture';"));
Assert.Equal("session-fixture", await ScalarAsync<string>(connection,
"SELECT Id FROM UserSessions WHERE UserId = 'owner-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(3L, await ScalarAsync<long>(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<IMigrator>();
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<string>(connection,
"SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("graph-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("imap-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync("20260830123000_AdoptAuthenticationSupportSchema");
Assert.Equal("gmail-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("graph-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("imap-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(5L, await ScalarAsync<long>(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<IMigrator>();
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<string>(connection,
"SELECT Sha256 FROM CvUploadArtifacts WHERE Id = 1;"));
Assert.Equal("{\"summary\":\"preserve me\"}", await ScalarAsync<string>(connection,
"SELECT StructuredProfileJson FROM CvExtractionRuns WHERE ArtifactId = 1;"));
await migrator.MigrateAsync("20260830124000_AdoptEmailConnectionSchema");
Assert.Equal("fixture-sha", await ScalarAsync<string>(connection,
"SELECT Sha256 FROM CvUploadArtifacts WHERE Id = 1;"));
await migrator.MigrateAsync();
Assert.Equal(3L, await ScalarAsync<long>(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<long>(connection,
"SELECT COUNT(*) FROM CvExtractionRuns WHERE ArtifactId IS NULL;"));
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();
}
}