1135 lines
67 KiB
C#
1135 lines
67 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(2, await ScalarAsync<long>(connection, """
|
|
SELECT COUNT(*) FROM sqlite_master
|
|
WHERE type = 'table' AND name IN ('SubmittedApplicationPackages', 'SubmittedPackageAttachments');
|
|
"""));
|
|
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("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 `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<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());
|
|
}
|
|
|
|
[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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT ResultJson FROM AiInteractions WHERE Id = 1;"));
|
|
Assert.Equal(460L, await ScalarAsync<long>(connection,
|
|
"SELECT EstimatedTokenCount FROM AiInteractions WHERE Id = 1;"));
|
|
|
|
await migrator.MigrateAsync("20260830128000_AdoptCvVariantSchema");
|
|
Assert.Equal("professional", await ScalarAsync<string>(connection,
|
|
"SELECT Mode FROM AiInteractions WHERE Id = 1;"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(2L, await ScalarAsync<long>(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<long>(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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT Status FROM ApplicationChecklistItems WHERE SystemKey = 'prepare-cv';"));
|
|
Assert.Equal("Call recruiter", await ScalarAsync<string>(connection,
|
|
"SELECT Title FROM ApplicationChecklistItems WHERE SystemKey IS NULL;"));
|
|
|
|
await migrator.MigrateAsync("20260830129000_AdoptAiInteractionSchema");
|
|
Assert.Equal(2L, await ScalarAsync<long>(connection,
|
|
"SELECT COUNT(*) FROM ApplicationChecklistItems 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_ApplicationChecklistItems_JobApplicationId_SystemKey',
|
|
'IX_ApplicationChecklistItems_Owner_Job_Sort');
|
|
"""));
|
|
|
|
await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;");
|
|
Assert.Equal(0L, await ScalarAsync<long>(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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT Text FROM CoverLetterVersions WHERE Version = 1;"));
|
|
Assert.Equal("shorten", await ScalarAsync<string>(connection,
|
|
"SELECT AiAction FROM CoverLetterVersions WHERE Version = 2;"));
|
|
|
|
await migrator.MigrateAsync("20260830130000_AdoptApplicationChecklistSchema");
|
|
Assert.Equal(2L, await ScalarAsync<long>(connection,
|
|
"SELECT COUNT(*) FROM CoverLetterVersions WHERE JobApplicationId = 1;"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(1L, await ScalarAsync<long>(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<long>(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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT Content FROM InterviewPrepItems WHERE SortOrder = 10;"));
|
|
Assert.Equal(1L, await ScalarAsync<long>(connection,
|
|
"SELECT IsPrepared FROM InterviewPrepItems WHERE SortOrder = 10;"));
|
|
|
|
await migrator.MigrateAsync("20260830131000_AdoptCoverLetterVersionSchema");
|
|
Assert.Equal("ai", await ScalarAsync<string>(connection,
|
|
"SELECT Source FROM InterviewPrepItems WHERE SortOrder = 20;"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(1L, await ScalarAsync<long>(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<long>(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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT LongTailJson FROM CareerProfiles WHERE Id = 1;"));
|
|
Assert.Equal("Previous version", await ScalarAsync<string>(connection,
|
|
"SELECT json_extract(ProfileJson, '$.summary') FROM CareerProfileVersions WHERE Version = 2;"));
|
|
Assert.Equal(6L, await ScalarAsync<long>(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<string>(connection,
|
|
"SELECT Company FROM CareerExperiences WHERE ItemKey = 'experience-1';"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(14L, await ScalarAsync<long>(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<long>(connection, "SELECT COUNT(*) FROM CareerProfileVersions;"));
|
|
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CareerExperiences;"));
|
|
Assert.Equal(0L, await ScalarAsync<long>(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<IMigrator>();
|
|
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<string>(connection,
|
|
"SELECT PasswordHash FROM AspNetUsers WHERE Id = 'user-fixture';"));
|
|
Assert.Equal("nb", await ScalarAsync<string>(connection,
|
|
"SELECT UiLanguage FROM AspNetUsers WHERE Id = 'user-fixture';"));
|
|
Assert.Equal("admin.manage", await ScalarAsync<string>(connection,
|
|
"SELECT ClaimValue FROM AspNetRoleClaims WHERE RoleId = 'role-admin';"));
|
|
Assert.Equal("encrypted-token", await ScalarAsync<string>(connection,
|
|
"SELECT Value FROM AspNetUserTokens WHERE UserId = 'user-fixture';"));
|
|
|
|
await migrator.MigrateAsync("20260830133000_AdoptCareerProfileSchema");
|
|
Assert.Equal("subject-1", await ScalarAsync<string>(connection,
|
|
"SELECT ProviderKey FROM AspNetUserLogins WHERE UserId = 'user-fixture';"));
|
|
await migrator.MigrateAsync();
|
|
Assert.Equal(8L, await ScalarAsync<long>(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<long>(connection, "SELECT COUNT(*) FROM AspNetUserClaims;"));
|
|
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserLogins;"));
|
|
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserRoles;"));
|
|
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserTokens;"));
|
|
await ExecuteAsync(connection, "DELETE FROM AspNetRoles WHERE Id = 'role-admin';");
|
|
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetRoleClaims;"));
|
|
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();
|
|
}
|
|
}
|