using System.Net;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Nostos.Backend.Data.Models;
using Nostos.Backend.Services.Portability;
using Nostos.Backend.Services.Portability.Activation;
using Nostos.Backend.Services.Portability.Migration;
using Xunit;
namespace Nostos.Backend.Tests.Portability;
///
/// Slice 11: the orphan sweep's fenced cleanup claim against the real retry
/// route. The sweep holds the claim while blocked at its documented seam; a
/// browser retry arriving over HTTP must lose cleanly with the stable lease
/// conflict, change nothing, and the sweep must then complete the deletion. A
/// later retry succeeds normally, proving the claim was released.
///
[Collection(ActivationCoordinatorCollection.Name)]
public sealed class MigrationOrphanSweepHttpTests
{
[Fact]
public async Task Retry_over_http_loses_cleanly_to_a_held_sweep_claim()
{
await using var h = StartHost();
var now = h.Clock.GetUtcNow().UtcDateTime;
var jobId = Guid.NewGuid();
await h.WithDb(async db =>
{
db.MigrationJobRecords.Add(new MigrationJobRecord
{
Id = jobId,
Direction = (int)MigrationDirection.Import,
State = (int)MigrationJobState.Failed,
RecoveryStatus = (int)MigrationRecoveryStatus.NotRequired,
CreatedAtUtc = now.AddDays(-2),
UpdatedAtUtc = now.AddDays(-2),
IdempotencyKey = "sweep-http-" + jobId.ToString("N"),
CreationPayloadHash = new string('a', 64),
ExpiresAtUtc = now.AddDays(1),
AttemptNumber = 1,
FailureCode = MigrationActivationErrorCodes.Failed,
FailureMessage = "seeded failure",
Version = 1,
});
await db.SaveChangesAsync();
});
var paths = h.GetService();
var old = now.AddHours(-48);
Directory.CreateDirectory(Path.GetDirectoryName(paths.CandidateDatabase(jobId))!);
File.WriteAllText(paths.CandidateDatabase(jobId), "candidate");
File.SetLastWriteTimeUtc(paths.CandidateDatabase(jobId), old);
var media = paths.CandidateMedia(jobId);
Directory.CreateDirectory(media);
var mediaFile = Path.Combine(media, "book.epub");
File.WriteAllText(mediaFile, "media");
File.SetLastWriteTimeUtc(mediaFile, old);
Directory.SetLastWriteTimeUtc(media, old);
Directory.SetLastWriteTimeUtc(Path.GetDirectoryName(paths.CandidateDatabase(jobId))!, old);
var factory = h.GetService();
await using var scope = factory.CreateAsyncScope();
var sweep = scope.ServiceProvider.GetRequiredService();
var claimed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
sweep.AfterJobClaimForTesting = id =>
{
if (id != jobId)
{
return;
}
claimed.TrySetResult();
release.Task.GetAwaiter().GetResult();
};
var sweepRun = Task.Run(() => sweep.SweepAsync(default));
try
{
await claimed.Task.WaitAsync(TimeSpan.FromSeconds(30));
var retry = await h.PostJsonAsync(
$"/api/portability/migration/jobs/{jobId}/retry", new { });
retry.Status.Should().Be(HttpStatusCode.Conflict);
retry.Body.RootElement.GetProperty("error").GetString().Should().Be("migration_lease_conflict");
retry.Body.Dispose();
(await h.JobStatusAsync(jobId)).RootElement.GetProperty("job").GetProperty("state")
.GetString().Should().Be("Failed", "the losing retry must change nothing");
File.Exists(paths.CandidateDatabase(jobId)).Should().BeTrue(
"the sweep has not deleted anything while it is paused before deletion");
}
finally
{
release.TrySetResult();
}
var result = await sweepRun;
result.RemovedEntries.Should().BeGreaterThanOrEqualTo(1);
File.Exists(paths.CandidateDatabase(jobId)).Should().BeFalse("the sweep completes its claimed deletion");
Directory.Exists(media).Should().BeFalse();
// The claim is released after the sweep's deletion, so the next retry
// is an ordinary success.
var later = await h.PostJsonAsync(
$"/api/portability/migration/jobs/{jobId}/retry", new { });
later.Status.Should().Be(HttpStatusCode.OK);
later.Body.RootElement.GetProperty("job").GetProperty("state").GetString().Should().Be("Pending");
later.Body.Dispose();
}
private static MigrationHttpHarness StartHost()
{
var harness = new MigrationHttpHarness
{
UseRealPhaseHandlers = true,
ConfigureServices = services =>
services.AddHostedService(sp => sp.GetRequiredService()),
};
return harness.Start();
}
}