using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.DatabaseMigration; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Providers; using Cleanuparr.Persistence.Models.Auth; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Events; using Cleanuparr.Shared.Configuration; using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Npgsql; using Shouldly; using Testcontainers.PostgreSql; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.DatabaseMigration; [Collection("SeedParity")] public class SqliteToPostgresMigrationTests { private const string EventsMigrationBeforeCustomFormatScoreEntries = "20260706084236_EventStreamRework"; [SkippableFact] public async Task RunAsync_copies_all_rows_preserves_keys_and_enforces_force_guard() { PostgreSqlContainer postgresContainer = await StartPostgresOrSkipAsync(); string tempConfigDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-migrator-{Guid.NewGuid():N}"); string previousConfigPath = ConfigurationPathProvider.GetConfigPath(); Guid arrConfigId = Guid.Parse("22222222-2222-2222-2222-222222222222"); Guid appEventId = Guid.Parse("33333333-3333-3333-3333-333333333333"); Guid userId = Guid.Parse("44444444-4444-4444-4444-444444444444"); try { ConfigurationPathProvider.SetConfigPath(tempConfigDir); int arrConfigCount = await SeedSqliteSourceAsync(arrConfigId, appEventId, userId); InitializeDatabaseConfig(postgresContainer); SqliteToPostgresMigrator migrator = new(); MigrationResult result = await migrator.RunAsync(force: false, null, CancellationToken.None); result.Success.ShouldBeTrue(result.Error); result.TableCounts["arr_configs"].ShouldBe(arrConfigCount); result.TableCounts["events"].ShouldBe(1); result.TableCounts["users"].ShouldBe(1); await AssertKeysPreservedAsync(postgresContainer, arrConfigId, appEventId, userId); await AssertAppSeesMigrationsAppliedAsync(); MigrationResult second = await migrator.RunAsync(force: false, null, CancellationToken.None); second.Success.ShouldBeFalse(); MigrationResult forced = await migrator.RunAsync(force: true, null, CancellationToken.None); forced.Success.ShouldBeTrue(forced.Error); forced.TableCounts["arr_configs"].ShouldBe(arrConfigCount); } finally { DatabaseConfigProvider.Initialize(new ConfigurationBuilder().Build()); ConfigurationPathProvider.SetConfigPath(previousConfigPath); await postgresContainer.DisposeAsync(); if (Directory.Exists(tempConfigDir)) { Directory.Delete(tempConfigDir, recursive: true); } } } [SkippableFact] public async Task RunAsync_upgrades_a_stale_sqlite_source_before_copying() { PostgreSqlContainer postgresContainer = await StartPostgresOrSkipAsync(); string tempConfigDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-migrator-stale-{Guid.NewGuid():N}"); string previousConfigPath = ConfigurationPathProvider.GetConfigPath(); Guid arrConfigId = Guid.Parse("55555555-5555-5555-5555-555555555555"); Guid userId = Guid.Parse("66666666-6666-6666-6666-666666666666"); try { ConfigurationPathProvider.SetConfigPath(tempConfigDir); int arrConfigCount = await SeedStaleSqliteSourceAsync(arrConfigId, userId); InitializeDatabaseConfig(postgresContainer); SqliteToPostgresMigrator migrator = new(); MigrationResult result = await migrator.RunAsync(force: false, null, CancellationToken.None); result.Success.ShouldBeTrue(result.Error); result.TableCounts["arr_configs"].ShouldBe(arrConfigCount); result.TableCounts.ShouldContainKey("custom_format_score_entries"); } finally { DatabaseConfigProvider.Initialize(new ConfigurationBuilder().Build()); ConfigurationPathProvider.SetConfigPath(previousConfigPath); await postgresContainer.DisposeAsync(); if (Directory.Exists(tempConfigDir)) { Directory.Delete(tempConfigDir, recursive: true); } } } [SkippableFact] public async Task Cleaning_up_schemas_lets_a_retry_run_without_force() { PostgreSqlContainer postgresContainer = await StartPostgresOrSkipAsync(); string tempConfigDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-migrator-cleanup-{Guid.NewGuid():N}"); string previousConfigPath = ConfigurationPathProvider.GetConfigPath(); Guid arrConfigId = Guid.Parse("77777777-7777-7777-7777-777777777777"); Guid appEventId = Guid.Parse("88888888-8888-8888-8888-888888888888"); Guid userId = Guid.Parse("99999999-9999-9999-9999-999999999999"); try { ConfigurationPathProvider.SetConfigPath(tempConfigDir); await SeedSqliteSourceAsync(arrConfigId, appEventId, userId); InitializeDatabaseConfig(postgresContainer); SqliteToPostgresMigrator migrator = new(); MigrationResult first = await migrator.RunAsync(force: false, null, CancellationToken.None); first.Success.ShouldBeTrue(first.Error); MigrationResult refused = await migrator.RunAsync(force: false, null, CancellationToken.None); refused.Success.ShouldBeFalse(); await SqliteToPostgresMigrator.DropCleanuparrSchemasAsync(postgresContainer.GetConnectionString(), null, CancellationToken.None); MigrationResult retry = await migrator.RunAsync(force: false, null, CancellationToken.None); retry.Success.ShouldBeTrue(retry.Error); } finally { DatabaseConfigProvider.Initialize(new ConfigurationBuilder().Build()); ConfigurationPathProvider.SetConfigPath(previousConfigPath); await postgresContainer.DisposeAsync(); if (Directory.Exists(tempConfigDir)) { Directory.Delete(tempConfigDir, recursive: true); } } } [SkippableFact] public async Task RunAsync_refuses_a_source_holding_values_a_newer_version_wrote() { PostgreSqlContainer postgresContainer = await StartPostgresOrSkipAsync(); string tempConfigDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-migrator-unknown-{Guid.NewGuid():N}"); string previousConfigPath = ConfigurationPathProvider.GetConfigPath(); Guid arrConfigId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid appEventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); Guid userId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); try { ConfigurationPathProvider.SetConfigPath(tempConfigDir); await SeedSqliteSourceAsync(arrConfigId, appEventId, userId); InitializeDatabaseConfig(postgresContainer); await UpdateArrConfigTypeAsync("lidarr", "fromthefuture"); SqliteToPostgresMigrator migrator = new(); MigrationResult refused = await migrator.RunAsync(force: false, null, CancellationToken.None); refused.Success.ShouldBeFalse(); refused.Error.ShouldNotBeNull(); refused.Error!.ShouldContain("arr_configs"); refused.Error.ShouldContain("Upgrade Cleanuparr"); refused.Error.ShouldNotContain("Microsoft.EntityFrameworkCore"); // The refusal dropped what it created, so the retry needs no --force. await UpdateArrConfigTypeAsync("fromthefuture", "lidarr"); MigrationResult retry = await migrator.RunAsync(force: false, null, CancellationToken.None); retry.Success.ShouldBeTrue(retry.Error); } finally { DatabaseConfigProvider.Initialize(new ConfigurationBuilder().Build()); ConfigurationPathProvider.SetConfigPath(previousConfigPath); await postgresContainer.DisposeAsync(); if (Directory.Exists(tempConfigDir)) { Directory.Delete(tempConfigDir, recursive: true); } } } private static async Task UpdateArrConfigTypeAsync(string from, string to) { await using DataContext data = DataContext.CreateStaticInstance(); int affected = await data.Database.ExecuteSqlRawAsync( "UPDATE arr_configs SET type = {0} WHERE type = {1}", to, from); affected.ShouldBeGreaterThan(0); } private static async Task StartPostgresOrSkipAsync() { try { PostgreSqlContainer container = new PostgreSqlBuilder() .WithImage("postgres:17") .Build(); await container.StartAsync(); return container; } catch (Exception exception) { throw new SkipException($"Docker is unavailable, skipping migrator test: {exception.Message}"); } } private static async Task SeedStaleSqliteSourceAsync(Guid arrConfigId, Guid userId) { await using DataContext data = DataContext.CreateStaticInstance(); await data.Database.MigrateAsync(); data.ArrConfigs.Add(new ArrConfig { Id = arrConfigId, Type = InstanceType.Sonarr }); await data.SaveChangesAsync(); int arrConfigCount = await data.ArrConfigs.CountAsync(); await using UsersContext users = UsersContext.CreateStaticInstance(); await users.Database.MigrateAsync(); users.Users.Add(new User { Id = userId, Username = "stale-user", PasswordHash = "hash", TotpSecret = "secret", ApiKey = "api-key", }); await users.SaveChangesAsync(); await using EventsContext events = EventsContext.CreateStaticInstance(); await events.Database.MigrateAsync(EventsMigrationBeforeCustomFormatScoreEntries); return arrConfigCount; } private static async Task SeedSqliteSourceAsync(Guid arrConfigId, Guid appEventId, Guid userId) { await using DataContext data = DataContext.CreateStaticInstance(); await data.Database.MigrateAsync(); data.ArrConfigs.Add(new ArrConfig { Id = arrConfigId, Type = InstanceType.Sonarr }); await data.SaveChangesAsync(); int arrConfigCount = await data.ArrConfigs.CountAsync(); await using EventsContext events = EventsContext.CreateStaticInstance(); await events.Database.MigrateAsync(); events.Events.Add(new AppEvent { Id = appEventId, EventType = EventType.QueueItemDeleted, Message = "seed event", Severity = EventSeverity.Information, }); await events.SaveChangesAsync(); await using UsersContext users = UsersContext.CreateStaticInstance(); await users.Database.MigrateAsync(); users.Users.Add(new User { Id = userId, Username = "seed-user", PasswordHash = "hash", TotpSecret = "secret", ApiKey = "api-key", }); await users.SaveChangesAsync(); return arrConfigCount; } private static async Task AssertAppSeesMigrationsAppliedAsync() { PostgresDatabaseProvider provider = new(); await using DataContext data = BuildAppContext(provider, DbContextKind.Data); (await data.Database.GetPendingMigrationsAsync()).ShouldBeEmpty(); await using EventsContext events = BuildAppContext(provider, DbContextKind.Events); (await events.Database.GetPendingMigrationsAsync()).ShouldBeEmpty(); await using UsersContext users = BuildAppContext(provider, DbContextKind.Users); (await users.Database.GetPendingMigrationsAsync()).ShouldBeEmpty(); } private static TContext BuildAppContext(PostgresDatabaseProvider provider, DbContextKind kind) where TContext : DbContext { DbContextOptionsBuilder builder = new(); provider.ConfigureContext(builder, kind); return (TContext)Activator.CreateInstance(typeof(TContext), builder.Options, provider)!; } private static void InitializeDatabaseConfig(PostgreSqlContainer postgresContainer) { NpgsqlConnectionStringBuilder parsed = new(postgresContainer.GetConnectionString()); IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { [ConfigurationKeys.PostgresHost] = parsed.Host, [ConfigurationKeys.PostgresPort] = parsed.Port.ToString(), [ConfigurationKeys.PostgresUser] = parsed.Username, [ConfigurationKeys.PostgresPassword] = parsed.Password, [ConfigurationKeys.PostgresDatabase] = parsed.Database, }) .Build(); DatabaseConfigProvider.Initialize(configuration); } private static async Task AssertKeysPreservedAsync(PostgreSqlContainer postgresContainer, Guid arrConfigId, Guid appEventId, Guid userId) { await using NpgsqlConnection connection = new(postgresContainer.GetConnectionString()); await connection.OpenAsync(); await using NpgsqlCommand arrCommand = new("SELECT COUNT(*) FROM data.arr_configs WHERE id = @id", connection); arrCommand.Parameters.AddWithValue("id", arrConfigId); long arrMatches = (long)(await arrCommand.ExecuteScalarAsync())!; arrMatches.ShouldBe(1L); await using NpgsqlCommand eventCommand = new("SELECT COUNT(*) FROM events.events WHERE id = @id", connection); eventCommand.Parameters.AddWithValue("id", appEventId); long eventMatches = (long)(await eventCommand.ExecuteScalarAsync())!; eventMatches.ShouldBe(1L); await using NpgsqlCommand userCommand = new("SELECT COUNT(*) FROM users.users WHERE id = @id", connection); userCommand.Parameters.AddWithValue("id", userId); long userMatches = (long)(await userCommand.ExecuteScalarAsync())!; userMatches.ShouldBe(1L); } }