using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Infrastructure.Features.DatabaseMigration; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Events; using Cleanuparr.Persistence.Models.State; using Cleanuparr.Persistence.Providers; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Shouldly; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.DatabaseMigration; public class ModelDataCopierTests { [Fact] public async Task CopyAsync_names_the_table_holding_values_a_newer_version_wrote() { SqliteConnection sourceConnection = new("DataSource=:memory:"); sourceConnection.Open(); SqliteConnection targetConnection = new("DataSource=:memory:"); targetConnection.Open(); SqliteDatabaseProvider provider = new(); DbContextOptions sourceOptions = new DbContextOptionsBuilder() .UseSqlite(sourceConnection) .UseSnakeCaseNamingConvention() .Options; DbContextOptions targetOptions = new DbContextOptionsBuilder() .UseSqlite(targetConnection) .UseSnakeCaseNamingConvention() .ReplaceService() .Options; await using (DataContext source = new(sourceOptions, provider)) { await source.Database.EnsureCreatedAsync(); source.ArrConfigs.Add(new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr }); await source.SaveChangesAsync(); await source.Database.ExecuteSqlRawAsync("UPDATE arr_configs SET type = 'fromthefuture'"); } await using DataContext target = new(targetOptions, provider); await target.Database.EnsureCreatedAsync(); await using DataContext poisoned = new(sourceOptions, provider); ModelDataCopier copier = new(); UnknownEnumValueException exception = await Should.ThrowAsync( () => copier.CopyAsync(poisoned, target, null, CancellationToken.None)); exception.Message.ShouldContain("arr_configs"); exception.Message.ShouldContain("Upgrade Cleanuparr"); } [Fact] public async Task CopyAsync_reproduces_rows_and_preserves_keys() { SqliteConnection sourceConnection = new("DataSource=:memory:"); sourceConnection.Open(); SqliteConnection targetConnection = new("DataSource=:memory:"); targetConnection.Open(); SqliteDatabaseProvider provider = new(); DbContextOptions sourceOptions = new DbContextOptionsBuilder() .UseSqlite(sourceConnection) .UseSnakeCaseNamingConvention() .Options; DbContextOptions targetOptions = new DbContextOptionsBuilder() .UseSqlite(targetConnection) .UseSnakeCaseNamingConvention() .ReplaceService() .Options; Guid arrConfigId = Guid.Parse("11111111-1111-1111-1111-111111111111"); await using (DataContext source = new(sourceOptions, provider)) { await source.Database.EnsureCreatedAsync(); source.ArrConfigs.Add(new ArrConfig { Id = arrConfigId, Type = InstanceType.Sonarr }); await source.SaveChangesAsync(); } await using (DataContext target = new(targetOptions, provider)) { await target.Database.EnsureCreatedAsync(); await using (DataContext source = new(sourceOptions, provider)) { ModelDataCopier copier = new(); await copier.CopyAsync(source, target, null, CancellationToken.None); } List copied = await target.ArrConfigs.AsNoTracking().ToListAsync(); copied.Count.ShouldBe(1); copied[0].Id.ShouldBe(arrConfigId); copied[0].Type.ShouldBe(InstanceType.Sonarr); } } [Fact] public void OrderByDependencies_orders_principals_before_dependents_in_events_model() { SqliteConnection connection = new("DataSource=:memory:"); connection.Open(); SqliteDatabaseProvider provider = new(); DbContextOptions options = new DbContextOptionsBuilder() .UseSqlite(connection) .UseSnakeCaseNamingConvention() .Options; using EventsContext context = new(options, provider); List ordered = ModelDataCopier.OrderByDependencies(context.Model.GetEntityTypes()).ToList(); int strikeIndex = ordered.FindIndex(entityType => entityType.ClrType == typeof(Strike)); int jobRunIndex = ordered.FindIndex(entityType => entityType.ClrType == typeof(JobRun)); int appEventIndex = ordered.FindIndex(entityType => entityType.ClrType == typeof(AppEvent)); strikeIndex.ShouldBeGreaterThanOrEqualTo(0); jobRunIndex.ShouldBeGreaterThanOrEqualTo(0); appEventIndex.ShouldBeGreaterThanOrEqualTo(0); strikeIndex.ShouldBeLessThan(appEventIndex); jobRunIndex.ShouldBeLessThan(appEventIndex); } [Fact] public async Task CopyAsync_copies_large_sets_in_batches_and_restores_change_tracking() { SqliteConnection sourceConnection = new("DataSource=:memory:"); sourceConnection.Open(); SqliteConnection targetConnection = new("DataSource=:memory:"); targetConnection.Open(); SqliteDatabaseProvider provider = new(); DbContextOptions sourceOptions = new DbContextOptionsBuilder() .UseSqlite(sourceConnection) .UseSnakeCaseNamingConvention() .Options; DbContextOptions targetOptions = new DbContextOptionsBuilder() .UseSqlite(targetConnection) .UseSnakeCaseNamingConvention() .ReplaceService() .Options; const int rowCount = 12_000; await using (EventsContext source = new(sourceOptions, provider)) { await source.Database.EnsureCreatedAsync(); for (int i = 0; i < rowCount; i++) { source.Events.Add(new AppEvent { EventType = EventType.QueueItemDeleted, Message = $"event {i}", Severity = EventSeverity.Information, }); } await source.SaveChangesAsync(); } List progressMessages = new(); Progress progress = new(message => progressMessages.Add(message)); await using (EventsContext target = new(targetOptions, provider)) { await target.Database.EnsureCreatedAsync(); target.ChangeTracker.AutoDetectChangesEnabled.ShouldBeTrue(); await using (EventsContext source = new(sourceOptions, provider)) { ModelDataCopier copier = new(); await copier.CopyAsync(source, target, progress, CancellationToken.None); } (await target.Events.AsNoTracking().CountAsync()).ShouldBe(rowCount); target.ChangeTracker.AutoDetectChangesEnabled.ShouldBeTrue(); } } [Fact] public void OrderByDependencies_throws_on_a_circular_fk_dependency() { DbContextOptions options = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; using CycleContext context = new(options); InvalidOperationException exception = Should.Throw( () => ModelDataCopier.OrderByDependencies(context.Model.GetEntityTypes())); exception.Message.ShouldContain("Circular FK dependency"); } private sealed class CycleContext : DbContext { public CycleContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasOne(node => node.Edge).WithMany().HasForeignKey(node => node.EdgeId); modelBuilder.Entity().HasOne(edge => edge.Node).WithMany().HasForeignKey(edge => edge.NodeId); } } private sealed class CycleNode { public Guid Id { get; set; } public Guid? EdgeId { get; set; } public CycleEdge? Edge { get; set; } } private sealed class CycleEdge { public Guid Id { get; set; } public Guid? NodeId { get; set; } public CycleNode? Node { get; set; } } }