bc28893b6f
Move saved CVs and append-only revisions into an additive provider-aware migration. Preserve public slugs, builder settings, job-link nulling, and revision cascades.
682 lines
38 KiB
C#
682 lines
38 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("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("`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());
|
|
}
|
|
|
|
[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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT SummaryJson FROM TailoredCvDrafts WHERE JobApplicationId = 1;"));
|
|
|
|
await migrator.MigrateAsync("20260830125000_AdoptCvExtractionSchema");
|
|
Assert.Equal("context-fixture", await ScalarAsync<string>(connection,
|
|
"SELECT GenerationContextHash FROM TailoredCvDrafts WHERE JobApplicationId = 1;"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(2L, await ScalarAsync<long>(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<long>(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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT Summary FROM InterviewPrepNotes WHERE JobApplicationId = 1;"));
|
|
Assert.Equal("{\"strength\":\"preserve me\"}", await ScalarAsync<string>(connection,
|
|
"SELECT ResultJson FROM AiWorkspaceNotes WHERE JobApplicationId = 1;"));
|
|
|
|
await migrator.MigrateAsync("20260830126000_AdoptTailoredCvDraftSchema");
|
|
Assert.Equal("attachments-v1", await ScalarAsync<string>(connection,
|
|
"SELECT AttachmentContextSignature FROM InterviewPrepNotes WHERE JobApplicationId = 1;"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(2L, await ScalarAsync<long>(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<long>(connection, "SELECT COUNT(*) FROM InterviewPrepNotes;"));
|
|
Assert.Equal(0L, await ScalarAsync<long>(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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT PublicSlug FROM CvVariants WHERE Id = 1;"));
|
|
Assert.Equal(2L, await ScalarAsync<long>(connection,
|
|
"SELECT COUNT(*) FROM CvVariantVersions WHERE CvVariantId = 1;"));
|
|
|
|
await migrator.MigrateAsync("20260830127000_AdoptJobWorkspaceNotesSchema");
|
|
Assert.Equal("Backend CV", await ScalarAsync<string>(connection,
|
|
"SELECT Name FROM CvVariants WHERE Id = 1;"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(4L, await ScalarAsync<long>(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<long>(connection,
|
|
"SELECT COUNT(*) FROM CvVariants WHERE JobApplicationId IS NULL;"));
|
|
await ExecuteAsync(connection, "DELETE FROM CvVariants WHERE Id = 1;");
|
|
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CvVariantVersions;"));
|
|
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();
|
|
}
|
|
}
|