Ctrl K
ComunidadGuías…

Nostos.Backend.Tests/Portability/MigrationCleanupEngineTests.cs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Nostos.Backend.Data.Models;
using Nostos.Backend.Services.Portability;
using Nostos.Backend.Services.Portability.Migration;
using Nostos.Backend.Services.Portability.Transfers;
using Xunit;

namespace Nostos.Backend.Tests.Portability;

public sealed class MigrationCleanupEngineTests
{
    [Fact]
    public async Task Unclaimed_reservation_releases_exactly_at_TTL_claimed_hold_survives_until_terminal()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync(); var bytes = MigrationEngineHarness.Bytes(); var job = await h.NewJobAsync(); await h.StartAsync(job, bytes);
        Guid unclaimed;
        await using (var scope = h.Provider.CreateAsyncScope()) unclaimed = (await scope.ServiceProvider.GetRequiredService<ITransferStorageCapacity>()
            .TryReserveAsync(100, MigrationSessionPurpose.Import, TimeSpan.FromMinutes(15), default)).ReservationId!.Value;
        h.Clock.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromTicks(1)); await h.Sweep();
        (await h.WithDb(db => db.MigrationStorageReservations.SingleAsync(r => r.Id == unclaimed))).ReleasedAtUtc.Should().BeNull();
        h.Clock.Advance(TimeSpan.FromTicks(1)); await h.Sweep();
        var all = await h.WithDb(db => db.MigrationStorageReservations.ToListAsync());
        all.Single(r => r.Id == unclaimed).ReleasedAtUtc.Should().NotBeNull();
        all.Single(r => r.ClaimedJobId == job).ReleasedAtUtc.Should().BeNull();
        await h.Sweep(); all = await h.WithDb(db => db.MigrationStorageReservations.ToListAsync()); all.Count.Should().Be(2);
    }

    [Fact]
    public async Task Session_and_job_expire_at_TTL_and_cleanup_is_idempotent()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync(); var bytes = MigrationEngineHarness.Bytes(); var job = await h.NewJobAsync(); var session = await h.StartAsync(job, bytes); await h.Upload(job, session, bytes);
        h.Clock.Advance(TimeSpan.FromDays(1) - TimeSpan.FromTicks(1)); await h.Sweep();
        File.Exists(h.Paths.GetUploadArchivePartPath(session.SessionId)).Should().BeTrue();
        (await h.Status(job, session)).State.Should().Be(MigrationSessionState.Receiving);
        h.Clock.Advance(TimeSpan.FromTicks(1)); await h.Sweep();
        (await h.Status(job, session)).State.Should().Be(MigrationSessionState.Expired);
        (await h.WithJobs(s => s.GetAsync(job, default)))!.State.Should().Be(MigrationJobState.Expired);
        Directory.Exists(h.Paths.GetUploadSessionDirectory(session.SessionId)).Should().BeFalse();
        (await h.WithDb(db => db.MigrationStorageReservations.SingleAsync())).ReleasedAtUtc.Should().NotBeNull();
        await h.Sweep(); (await h.WithDb(db => db.MigrationSessionRecords.CountAsync())).Should().Be(1);
    }

    [Fact]
    public async Task Live_lease_fences_expiry_cleanup_until_released()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync(); var job = await h.NewJobAsync(); var session = await h.StartAsync(job, MigrationEngineHarness.Bytes());
        var token = await h.WithJobs(s => s.TryAcquireLeaseAsync(job, TimeSpan.FromDays(2), default));
        h.Clock.Advance(TimeSpan.FromDays(1)); await h.Sweep();
        Directory.Exists(h.Paths.GetUploadSessionDirectory(session.SessionId)).Should().BeTrue();
        (await h.WithJobs(s => s.GetAsync(job, default)))!.State.Should().Be(MigrationJobState.Pending);
        await h.WithJobs(s => s.ReleaseLeaseAsync(job, token!, default)); await h.Sweep();
        Directory.Exists(h.Paths.GetUploadSessionDirectory(session.SessionId)).Should().BeFalse();
    }

    [Fact]
    public async Task Renewed_job_and_session_are_never_cleaned_using_stale_cutoff()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync(); var job = await h.NewJobAsync(); var session = await h.StartAsync(job, MigrationEngineHarness.Bytes());
        h.Clock.Advance(TimeSpan.FromHours(23));
        await h.WithDb(async db =>
        {
            await db.MigrationJobRecords.Where(j => j.Id == job).ExecuteUpdateAsync(s => s.SetProperty(j => j.ExpiresAtUtc, h.Clock.GetUtcNow().AddDays(1).UtcDateTime));
            await db.MigrationSessionRecords.Where(s => s.Id == session.SessionId).ExecuteUpdateAsync(s => s.SetProperty(s => s.ExpiresAtUtc, h.Clock.GetUtcNow().AddDays(1).UtcDateTime));
        });
        h.Clock.Advance(TimeSpan.FromHours(2)); await h.Sweep();
        Directory.Exists(h.Paths.GetUploadSessionDirectory(session.SessionId)).Should().BeTrue();
        (await h.WithJobs(s => s.GetAsync(job, default)))!.State.Should().Be(MigrationJobState.Pending);
    }

    [Fact]
    public async Task Orphan_upload_scope_has_a_TTL_grace_and_unknown_siblings_are_untouched()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync();
        var id = Guid.NewGuid(); var orphan = h.Paths.EnsureDirectoryExists(h.Paths.GetUploadSessionDirectory(id));
        await File.WriteAllTextAsync(h.Paths.VerifyPathWithinRoot(h.Paths.GetUploadArchivePartPath(id)), "orphan");
        Directory.SetLastWriteTimeUtc(orphan, h.Clock.GetUtcNow().UtcDateTime);
        var unknown = Path.Combine(h.Paths.GetUploadsRoot(), "operator-data"); Directory.CreateDirectory(unknown); var other = Path.Combine(unknown, "keep"); await File.WriteAllTextAsync(other, "keep");
        var rootSibling = Path.Combine(h.Paths.RootPath, "keep.txt"); await File.WriteAllTextAsync(rootSibling, "keep");
        h.Clock.Advance(TimeSpan.FromDays(1) - TimeSpan.FromTicks(1)); await h.Sweep(); Directory.Exists(orphan).Should().BeTrue();
        h.Clock.Advance(TimeSpan.FromTicks(1)); await h.Sweep(); Directory.Exists(orphan).Should().BeFalse();
        File.Exists(other).Should().BeTrue(); File.Exists(rootSibling).Should().BeTrue(); await h.Sweep(); File.Exists(other).Should().BeTrue();
    }

    [Fact]
    public async Task Symlinked_scope_and_descendant_never_delete_outside_root()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync();
        var outside = Path.Combine(h.DirectoryPath, "outside"); Directory.CreateDirectory(outside); var sentinel = Path.Combine(outside, "sentinel"); await File.WriteAllTextAsync(sentinel, "safe");
        var id = Guid.NewGuid(); h.Paths.EnsureDirectoryExists(h.Paths.GetUploadsRoot());
        var link = h.Paths.GetUploadSessionDirectory(id); Directory.CreateSymbolicLink(link, outside);
        h.Clock.Advance(TimeSpan.FromDays(2)); await h.Sweep(); (await File.ReadAllTextAsync(sentinel)).Should().Be("safe");
        Directory.Delete(link);
        var job = await h.NewJobAsync(); var session = await h.StartAsync(job, MigrationEngineHarness.Bytes());
        var descendant = Path.Combine(h.Paths.GetUploadSessionDirectory(session.SessionId), "linked"); Directory.CreateSymbolicLink(descendant, outside);
        await h.WithJobs(s => s.CancelAsync(job, new(), default)); h.Clock.Advance(TimeSpan.FromDays(1)); await h.Sweep();
        (await File.ReadAllTextAsync(sentinel)).Should().Be("safe"); Directory.Delete(descendant);
        await h.Sweep(); Directory.Exists(h.Paths.GetUploadSessionDirectory(session.SessionId)).Should().BeFalse();
    }

    [Fact]
    public async Task Root_symlink_preserves_operator_target_and_only_generated_eligible_scopes_are_removed()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync();
        var actualRoot = Path.Combine(h.DirectoryPath, "operator-target"); Directory.Move(h.Paths.RootPath, actualRoot);
        Directory.CreateSymbolicLink(h.Paths.RootPath, actualRoot);
        var sentinel = Path.Combine(actualRoot, "sentinel"); await File.WriteAllTextAsync(sentinel, "keep");
        var job = await h.NewJobAsync(); var session = await h.StartAsync(job, MigrationEngineHarness.Bytes());
        h.Clock.Advance(TimeSpan.FromDays(1)); await h.Sweep();
        Directory.Exists(actualRoot).Should().BeTrue(); File.Exists(sentinel).Should().BeTrue();
        Directory.Exists(h.Paths.GetUploadSessionDirectory(session.SessionId)).Should().BeFalse();
        Directory.Delete(h.Paths.RootPath);
    }

    [Fact]
    public async Task Terminal_files_reservations_and_known_staging_are_removed_and_missing_files_are_success()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync(); var job = await h.NewJobAsync(); var session = await h.StartAsync(job, MigrationEngineHarness.Bytes());
        var staging = Guid.NewGuid(); var stagePath = h.Paths.EnsureDirectoryExists(h.Paths.GetStagingDirectory(staging));
        await File.WriteAllTextAsync(Path.Combine(stagePath, "state.json"), "staged");
        var exportPath = h.Paths.EnsureDirectoryExists(h.Paths.GetExportDirectory(job)); await File.WriteAllTextAsync(h.Paths.GetExportTempPath(job), "tmp");
        await h.WithDb(db => db.MigrationJobRecords.Where(j => j.Id == job).ExecuteUpdateAsync(s => s.SetProperty(j => j.PreparedStagingId, staging)));
        await h.WithUploads(s => s.DeleteSessionAsync(job, session.SessionId, default));
        Directory.Exists(stagePath).Should().BeFalse(); Directory.Exists(exportPath).Should().BeFalse();
        Directory.Exists(h.Paths.GetUploadSessionDirectory(session.SessionId)).Should().BeFalse();
        (await h.WithDb(db => db.MigrationStorageReservations.SingleAsync())).ReleasedAtUtc.Should().NotBeNull(); await h.Sweep();
    }

    [Fact]
    public async Task Ready_to_activate_staging_retained_until_job_expiry_even_if_upload_TTL_elapses()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync(); var job = await h.NewJobAsync(); var session = await h.StartAsync(job, MigrationEngineHarness.Bytes());
        var staging = Guid.NewGuid(); var stagePath = h.Paths.EnsureDirectoryExists(h.Paths.GetStagingDirectory(staging));
        var expires = h.Clock.GetUtcNow().AddDays(2).UtcDateTime;
        await h.WithDb(db => db.MigrationJobRecords.Where(j => j.Id == job).ExecuteUpdateAsync(s => s.SetProperty(j => j.State, (int)MigrationJobState.ReadyToActivate)
            .SetProperty(j => j.PreparedStagingId, staging).SetProperty(j => j.ExpiresAtUtc, expires)));
        h.Clock.Advance(TimeSpan.FromDays(1)); await h.Sweep(); Directory.Exists(stagePath).Should().BeTrue();
        (await h.WithJobs(s => s.GetAsync(job, default)))!.State.Should().Be(MigrationJobState.ReadyToActivate);
        h.Clock.Advance(TimeSpan.FromDays(1)); await h.Sweep(); Directory.Exists(stagePath).Should().BeFalse();
        (await h.WithJobs(s => s.GetAsync(job, default)))!.State.Should().Be(MigrationJobState.Expired);
    }

    [Fact]
    public async Task Available_export_survives_until_artifact_TTL_then_deletes_without_rewriting_completed_history()
    {
        await using var h = new MigrationEngineHarness(); await h.InitializeAsync(); var job = await h.NewJobAsync(MigrationDirection.Export);
        h.Paths.EnsureDirectoryExists(h.Paths.GetExportDirectory(job)); await File.WriteAllTextAsync(h.Paths.GetExportArtifactPath(job), "artifact");
        await h.WithDb(async db =>
        {
            await db.MigrationJobRecords.Where(j => j.Id == job).ExecuteUpdateAsync(s => s.SetProperty(j => j.State, (int)MigrationJobState.Completed));
            db.MigrationExportArtifactRecords.Add(new() { JobId = job, State = (int)MigrationExportArtifactState.Available, StorageKey = h.Paths.GetExportArtifactStorageKey(job),
                FileName = "library.nostos", ContentType = "application/vnd.nostos.portable+zip", SizeBytes = 8, CreatedAtUtc = h.Clock.GetUtcNow().UtcDateTime,
                ExpiresAtUtc = h.Clock.GetUtcNow().AddDays(1).UtcDateTime }); await db.SaveChangesAsync();
        });
        h.Clock.Advance(TimeSpan.FromDays(1) - TimeSpan.FromTicks(1)); await h.Sweep(); File.Exists(h.Paths.GetExportArtifactPath(job)).Should().BeTrue();
        h.Clock.Advance(TimeSpan.FromTicks(1)); await h.Sweep(); File.Exists(h.Paths.GetExportArtifactPath(job)).Should().BeFalse();
        (await h.WithDb(db => db.MigrationExportArtifactRecords.SingleAsync())).State.Should().Be((int)MigrationExportArtifactState.Deleted);
        (await h.WithJobs(s => s.GetAsync(job, default)))!.State.Should().Be(MigrationJobState.Completed); await h.Sweep();
    }

    [Fact]
    public async Task Abandoned_staging_hook_receives_cutoff_and_protected_nonterminal_ids()
    {
        var hook = new StagingHook(); await using var h = new MigrationEngineHarness(); h.Configure = s => s.AddSingleton<IMigrationStagingCleanup>(hook); await h.InitializeAsync();
        var job = await h.NewJobAsync(); var protectedId = new PortableStagingId(Guid.NewGuid()); var abandoned = new PortableStagingId(Guid.NewGuid());
        hook.Areas[protectedId] = h.Clock.GetUtcNow(); hook.Areas[abandoned] = h.Clock.GetUtcNow();
        await h.WithDb(db => db.MigrationJobRecords.Where(j => j.Id == job).ExecuteUpdateAsync(s => s.SetProperty(j => j.PreparedStagingId, protectedId.Value)
            .SetProperty(j => j.State, (int)MigrationJobState.ReadyToActivate).SetProperty(j => j.ExpiresAtUtc, h.Clock.GetUtcNow().AddDays(2).UtcDateTime)));
        h.Clock.Advance(TimeSpan.FromDays(1) - TimeSpan.FromTicks(1)); await h.Sweep(); hook.Areas.Should().HaveCount(2);
        h.Clock.Advance(TimeSpan.FromTicks(1)); await h.Sweep(); hook.Areas.Keys.Should().Equal(protectedId);
        hook.Protected.Should().Contain(protectedId); await h.Sweep(); hook.Areas.Should().ContainKey(protectedId);
    }

    [Fact]
    public async Task Transient_delete_failure_keeps_metadata_for_retry_and_releases_reservation()
    {
        var hook = new StagingHook { FailNextDelete = true };
        await using var h = new MigrationEngineHarness(); h.Configure = s => s.AddSingleton<IMigrationStagingCleanup>(hook); await h.InitializeAsync();
        var job = await h.NewJobAsync(); await h.StartAsync(job, MigrationEngineHarness.Bytes()); var id = new PortableStagingId(Guid.NewGuid());
        hook.Areas[id] = h.Clock.GetUtcNow(); h.Paths.EnsureDirectoryExists(h.Paths.GetStagingDirectory(id.Value));
        await h.WithDb(db => db.MigrationJobRecords.Where(j => j.Id == job).ExecuteUpdateAsync(s => s.SetProperty(j => j.PreparedStagingId, id.Value)));
        await h.WithJobs(s => s.CancelAsync(job, new(), default)); await h.Sweep();
        hook.Areas.Should().ContainKey(id); (await h.WithDb(db => db.MigrationJobRecords.SingleAsync())).PreparedStagingId.Should().Be(id.Value);
        (await h.WithDb(db => db.MigrationStorageReservations.SingleAsync())).ReleasedAtUtc.Should().NotBeNull();
        await h.Sweep(); hook.Areas.Should().BeEmpty(); Directory.Exists(h.Paths.GetStagingDirectory(id.Value)).Should().BeFalse();
        await h.Sweep(); hook.DeleteSuccesses.Should().Be(1);
    }

    [Fact]
    public async Task Legacy_import_scratch_sweep_removes_only_expired_generated_trees()
    {
        await using var h = new MigrationEngineHarness();
        await h.InitializeAsync();
        var scratchRoot = h.Provider.GetRequiredService<MigrationLegacyScratchSweep>().ScratchRoot;
        Directory.CreateDirectory(scratchRoot);
        var stale = h.Clock.GetUtcNow().AddHours(-25).UtcDateTime;

        // A dead tree from a previous process, including a leftover unheld lock
        // file: no live owner, all timestamps stale -> removed.
        var expired = Path.Combine(scratchRoot, "nostos-portable-import-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(Path.Combine(expired, "staging"));
        await File.WriteAllTextAsync(Path.Combine(expired, "archive.nostos"), "old");
        await File.WriteAllTextAsync(Path.Combine(expired, "import.lock"), string.Empty);
        AgeTreeUtc(expired, stale);

        // An old directory whose file was written recently (writing a file does
        // not update its parent) is still active work -> kept.
        var recentWrite = Path.Combine(scratchRoot, "nostos-portable-import-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(recentWrite);
        var recentFile = Path.Combine(recentWrite, "archive.part");
        await File.WriteAllTextAsync(recentFile, "still streaming");
        Directory.SetLastWriteTimeUtc(recentWrite, stale);

        // A tree whose lease is held is never a target, however old it looks.
        var leased = Path.Combine(scratchRoot, "nostos-portable-import-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(leased);
        await File.WriteAllTextAsync(Path.Combine(leased, "archive.part"), "held");
        using var lease = LegacyPortabilityScratchLease.Acquire(leased);
        AgeTreeUtc(leased, stale);

        var fresh = Path.Combine(scratchRoot, "nostos-portable-import-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(fresh);
        Directory.SetLastWriteTimeUtc(fresh, h.Clock.GetUtcNow().UtcDateTime);

        var unknown = Path.Combine(scratchRoot, "operator-data");
        Directory.CreateDirectory(unknown);
        var unknownFile = Path.Combine(unknown, "keep");
        await File.WriteAllTextAsync(unknownFile, "keep");
        Directory.SetLastWriteTimeUtc(unknown, h.Clock.GetUtcNow().AddHours(-100).UtcDateTime);

        var malformed = Path.Combine(scratchRoot, "nostos-portable-import-not-a-guid");
        Directory.CreateDirectory(malformed);
        Directory.SetLastWriteTimeUtc(malformed, h.Clock.GetUtcNow().AddHours(-100).UtcDateTime);

        var rootSentinel = Path.Combine(scratchRoot, "keep.txt");
        await File.WriteAllTextAsync(rootSentinel, "keep");

        await h.Sweep();

        Directory.Exists(expired).Should().BeFalse("a generated scratch tree with no live owner past the TTL is garbage");
        Directory.Exists(recentWrite).Should().BeTrue(
            "the newest write anywhere inside the tree is the age signal, not the directory timestamp");
        Directory.Exists(leased).Should().BeTrue("a held lease protects a scratch tree regardless of timestamps");
        Directory.Exists(fresh).Should().BeTrue("the full TTL grace protects a fresh scratch tree");
        Directory.Exists(unknown).Should().BeTrue("an unknown operator sibling is never a sweep target");
        File.Exists(unknownFile).Should().BeTrue();
        Directory.Exists(malformed).Should().BeTrue("a malformed generated name is not a sweep target");
        File.Exists(rootSentinel).Should().BeTrue("the scratch root itself is never a target");

        // Once the recent write ages out and the lease is released, the trees
        // become sweepable.
        File.SetLastWriteTimeUtc(recentFile, stale);
        lease.Dispose();
        await h.Sweep();
        Directory.Exists(recentWrite).Should().BeFalse();
        Directory.Exists(leased).Should().BeFalse();
    }

    [Fact]
    public async Task Legacy_import_scratch_sweep_never_follows_a_linked_descendant()
    {
        if (OperatingSystem.IsWindows()) return;
        await using var h = new MigrationEngineHarness();
        await h.InitializeAsync();
        var scratchRoot = h.Provider.GetRequiredService<MigrationLegacyScratchSweep>().ScratchRoot;
        Directory.CreateDirectory(scratchRoot);

        var outside = Path.Combine(h.DirectoryPath, "outside");
        Directory.CreateDirectory(outside);
        var sentinel = Path.Combine(outside, "sentinel");
        await File.WriteAllTextAsync(sentinel, "safe");

        var generated = Path.Combine(scratchRoot, "nostos-portable-import-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(generated);
        Directory.CreateSymbolicLink(Path.Combine(generated, "link"), outside);
        Directory.SetLastWriteTimeUtc(generated, h.Clock.GetUtcNow().AddHours(-25).UtcDateTime);

        await h.Sweep();

        File.Exists(sentinel).Should().BeTrue("a linked descendant must never be traversed");
        (await File.ReadAllTextAsync(sentinel)).Should().Be("safe");
        Directory.Delete(generated, recursive: true);
    }

    [Fact]
    public async Task Unreferenced_export_directory_without_a_job_row_is_removed_but_known_jobs_are_kept()
    {
        await using var h = new MigrationEngineHarness();
        await h.InitializeAsync();

        // A generated export directory whose job row does not exist, and whose
        // newest write is stale -> removed.
        var orphanJobId = Guid.NewGuid();
        var orphanDirectory = h.Paths.EnsureDirectoryExists(h.Paths.GetExportDirectory(orphanJobId));
        var orphanTemp = Path.Combine(orphanDirectory, "library.nostos.tmp");
        await File.WriteAllTextAsync(orphanTemp, "partial");
        var stale = h.Clock.GetUtcNow().AddHours(-100).UtcDateTime;
        Directory.SetLastWriteTimeUtc(orphanDirectory, stale);
        File.SetLastWriteTimeUtc(orphanTemp, stale);

        // A directory with an old timestamp but a recent write is still being
        // populated -> kept.
        var recentJobId = Guid.NewGuid();
        var recentDirectory = h.Paths.EnsureDirectoryExists(h.Paths.GetExportDirectory(recentJobId));
        var recentFile = Path.Combine(recentDirectory, "library.nostos.tmp");
        await File.WriteAllTextAsync(recentFile, "in-progress");
        Directory.SetLastWriteTimeUtc(recentDirectory, stale);

        // A known job's export directory must survive, even with a temp file.
        var knownJob = await h.NewJobAsync(MigrationDirection.Export);
        var knownDirectory = h.Paths.EnsureDirectoryExists(h.Paths.GetExportDirectory(knownJob));
        var knownTemp = Path.Combine(knownDirectory, "library.nostos.tmp");
        await File.WriteAllTextAsync(knownTemp, "in-progress");

        // An unknown sibling is untouched.
        var unknown = Path.Combine(h.Paths.GetExportsRoot(), "operator-data");
        Directory.CreateDirectory(unknown);
        var unknownFile = Path.Combine(unknown, "keep");
        await File.WriteAllTextAsync(unknownFile, "keep");

        await h.Sweep();

        Directory.Exists(orphanDirectory).Should().BeFalse("no job row owns the generated export directory");
        Directory.Exists(recentDirectory).Should().BeTrue(
            "the newest write inside the directory is the age signal");
        Directory.Exists(knownDirectory).Should().BeTrue("a known job owns its export directory");
        File.Exists(knownTemp).Should().BeTrue();
        Directory.Exists(unknown).Should().BeTrue("unknown siblings are never enumerated as targets");
        File.Exists(unknownFile).Should().BeTrue();

        File.SetLastWriteTimeUtc(recentFile, stale);
        await h.Sweep();
        Directory.Exists(recentDirectory).Should().BeFalse("once its newest write ages out it is garbage");
    }

    [Fact]
    public async Task Tombstoned_staging_area_is_swept_without_waiting_for_the_ttl()
    {
        await using var h = new MigrationEngineHarness();
        h.Configure = services =>
            services.AddScoped<IMigrationStagingCleanup, FilePortableImportStagingCleanup>();
        await h.InitializeAsync();

        var provider = new FilePortableImportStaging(h.Paths);
        var id = await provider.CreateAsync();
        var directory = h.Paths.GetStagingDirectory(id.Value);
        await File.WriteAllTextAsync(
            Path.Combine(directory, FilePortableImportStaging.DeletedMarkerFileName), "deleted");
        Directory.SetLastWriteTimeUtc(directory, h.Clock.GetUtcNow().UtcDateTime);

        await h.Sweep();

        Directory.Exists(directory).Should().BeFalse(
            "a durable deletion tombstone is already logically deleted and sweepable immediately");
        await h.Sweep();
    }

    [Fact]
    public async Task Abandoned_staging_with_a_recent_file_write_is_kept_until_the_newest_write_ages_out()
    {
        await using var h = new MigrationEngineHarness();
        h.Configure = services =>
            services.AddScoped<IMigrationStagingCleanup, FilePortableImportStagingCleanup>();
        await h.InitializeAsync();

        var provider = new FilePortableImportStaging(h.Paths);
        var id = await provider.CreateAsync();
        var directory = h.Paths.GetStagingDirectory(id.Value);
        var mediaFile = Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).First();
        var stale = h.Clock.GetUtcNow().AddHours(-100).UtcDateTime;
        AgeTreeUtc(directory, stale);

        // The directory looks old, but a file inside was written recently: the
        // area is still receiving bytes and must not be swept.
        File.SetLastWriteTimeUtc(mediaFile, h.Clock.GetUtcNow().UtcDateTime);
        await h.Sweep();
        Directory.Exists(directory).Should().BeTrue("a recent write inside the area keeps it alive");

        File.SetLastWriteTimeUtc(mediaFile, stale);
        await h.Sweep();
        Directory.Exists(directory).Should().BeFalse("once the newest write ages out the area is abandoned");
    }

    /// <summary>Sets the write time of a directory and every descendant to <paramref name="utc"/>.</summary>
    private static void AgeTreeUtc(string directory, DateTime utc)
    {
        foreach (var entry in Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories))
        {
            if (Directory.Exists(entry))
            {
                Directory.SetLastWriteTimeUtc(entry, utc);
            }
            else
            {
                File.SetLastWriteTimeUtc(entry, utc);
            }
        }

        Directory.SetLastWriteTimeUtc(directory, utc);
    }

    private sealed class StagingHook : IMigrationStagingCleanup
    {
        internal Dictionary<PortableStagingId, DateTimeOffset> Areas { get; } = new();
        internal IReadOnlySet<PortableStagingId> Protected { get; private set; } = new HashSet<PortableStagingId>();
        internal bool FailNextDelete { get; set; }
        internal int DeleteSuccesses { get; private set; }
        public Task CleanupAbandonedAsync(DateTimeOffset cutoffUtc, IReadOnlySet<PortableStagingId> protectedIds, CancellationToken ct)
        { Protected = protectedIds; foreach (var id in Areas.Where(a => a.Value <= cutoffUtc && !protectedIds.Contains(a.Key)).Select(a => a.Key).ToList()) Areas.Remove(id); return Task.CompletedTask; }
        public Task DeleteAsync(PortableStagingId id, CancellationToken ct)
        { if (FailNextDelete) { FailNextDelete = false; throw new IOException("Transient delete"); } if (Areas.Remove(id)) DeleteSuccesses++; return Task.CompletedTask; }
    }
}