using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Nostos.Backend.Data.Models;
using Nostos.Backend.Services;
using Nostos.Backend.Services.Portability;
using Nostos.Backend.Services.Portability.Activation;
using Nostos.Backend.Services.Portability.Migration;
using Xunit;
using static Nostos.Backend.Tests.Portability.PortableArchiveTestSupport;
namespace Nostos.Backend.Tests.Portability;
///
/// Slice 7 happy paths: a populated replacement keeps its recovery copy, an
/// empty destination activates without confirmation and retains nothing, and
/// admission refuses unconfirmed or stale requests without touching the live
/// library.
///
[Collection(ActivationCoordinatorCollection.Name)]
public sealed class ActivationCoordinatorHappyPathTests
{
[Fact]
public async Task PopulatedActivation_ServesVerifiedImport_KeepsHostState_AndRetainsRecoveryCopy()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true);
var result = await bed.ActivateAsync(confirm: true);
result.Outcome.Should().Be(SelfHostedActivationOutcome.Completed);
result.State.Should().Be(MigrationJobState.Completed);
result.RecoveryStatus.Should().Be(MigrationRecoveryStatus.Available);
var job = await bed.ReadJobAsync();
job.State.Should().Be((int)MigrationJobState.Completed);
job.RecoveryStatus.Should().Be((int)MigrationRecoveryStatus.Available);
(await bed.CurrentRevisionAsync()).Should().Be(
ActivationCoordinatorTemplate.AdvancedRevision,
"one activation is one committed library-state mutation");
await bed.AssertImportedGenerationAsync();
// Host operational rows survive into the activated database.
await using (var scope = bed.Host.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService();
(await db.BackupRecords.CountAsync()).Should().Be(1, "local backup history is host state");
(await db.MigrationStorageReservations.CountAsync()).Should().BeGreaterThanOrEqualTo(1);
var jobs = await db.MigrationJobRecords.AsNoTracking().ToArrayAsync();
jobs.Should().HaveCount(3, "all migration jobs, including unrelated ones, are carried host state");
jobs.Single(job => job.Id == bed.JobId).State.Should().Be((int)MigrationJobState.Completed);
jobs.Count(job => job.Direction == (int)MigrationDirection.Export)
.Should().Be(2, "unrelated export jobs survive activation unchanged");
}
// The real storage service reads the imported bytes through the real layout.
var assets = bed.Host.GetRequiredService();
await using (var opened = await assets.OpenBookFileAsync(bed.SourceIds.EpubBookId))
{
opened.Should().NotBeNull();
using var buffer = new MemoryStream();
await opened!.Content.CopyToAsync(buffer);
buffer.ToArray().Should().Equal("EPUB-CONTENT-PORTABLE"u8.ToArray());
}
(await assets.GetBookCoverInfoAsync(bed.SourceIds.EpubBookId, default)).Should().NotBeNull();
// Recovery copy: manifest matches its retained bytes exactly.
var manifests = new SelfHostedRecoveryManifestStore(bed.Paths);
var manifest = manifests.Read(bed.JobId);
manifest.Should().NotBeNull();
manifest!.Status.Should().Be(MigrationRecoveryStatus.Available);
manifest.DatabaseRetained.Should().BeTrue();
manifest.MediaRetained.Should().BeTrue();
(manifest.ExpiresAtUtc - manifest.CreatedAtUtc)
.Should().Be(TimeSpan.FromDays(MigrationContractLimits.RecoveryRetentionDays));
var previousDatabase = bed.Paths.PreviousDatabase(bed.JobId);
new FileInfo(previousDatabase).Length.Should().Be(manifest.DatabaseBytes);
Sha256Hex(File.ReadAllBytes(previousDatabase)).Should().Be(manifest.DatabaseSha256);
var retained = SweepRetainedMedia(bed.Paths.PreviousMedia(bed.JobId));
var expected = manifest.Media
.Select(descriptor => (descriptor.BookId, descriptor.Kind, descriptor.Extension,
descriptor.Bytes, descriptor.Sha256))
.OrderBy(item => item.BookId).ThenBy(item => item.Kind)
.ThenBy(item => item.Extension).ThenBy(item => item.Bytes)
.ToArray();
retained.Should().BeEquivalentTo(expected, options => options.WithStrictOrdering());
// Capacity accounting: the retained copy holds its own claimed reservation
// and the job's transfer claim is materialized up to the exact target.
await using (var db = bed.OpenDatabase())
{
var rows = await db.MigrationStorageReservations.AsNoTracking()
.Where(row => row.ClaimedJobId == bed.JobId)
.ToArrayAsync();
rows.Should().HaveCount(2, "the transfer claim and the retention claim both belong to the job");
var retention = rows.Single(row => row.Purpose == (int)MigrationSessionPurpose.RecoveryRetention);
retention.ReleasedAtUtc.Should().BeNull();
retention.ReservedBytes.Should().Be(manifest.DatabaseBytes + manifest.MediaBytes);
var transfer = rows.Single(row => row.Purpose == (int)MigrationSessionPurpose.Import);
transfer.MaterializedBytes.Should().Be(Math.Min(transfer.ReservedBytes, manifest.TotalBytes));
transfer.ReservedBytes.Should().BeGreaterThanOrEqualTo(transfer.MaterializedBytes);
}
// Candidate artifacts were consumed by the switch.
File.Exists(bed.Paths.CandidateDatabase(bed.JobId)).Should().BeFalse();
Directory.Exists(bed.Paths.CandidateMedia(bed.JobId)).Should().BeFalse();
// A repeated request after completion is an idempotent success.
var again = await bed.ActivateAsync(confirm: true);
again.Outcome.Should().Be(SelfHostedActivationOutcome.AlreadyCompleted);
(await bed.CurrentRevisionAsync()).Should().Be(ActivationCoordinatorTemplate.AdvancedRevision);
}
[Fact]
public async Task EmptyActivation_ActivatesWithoutConfirmation_AndRetainsNoRecoveryCopy()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: false);
// The production interface entry point carries no request object: an
// empty destination needs no confirmation.
await bed.ActivatePublicAsync();
var job = await bed.ReadJobAsync();
job.State.Should().Be((int)MigrationJobState.Completed);
job.RecoveryStatus.Should().Be((int)MigrationRecoveryStatus.NotRequired);
(await bed.CurrentRevisionAsync()).Should().Be(ActivationCoordinatorTemplate.AdvancedRevision);
await bed.AssertImportedGenerationAsync();
new SelfHostedRecoveryManifestStore(bed.Paths).Read(bed.JobId).Should().BeNull();
File.Exists(bed.Paths.PreviousDatabase(bed.JobId)).Should().BeFalse();
Directory.Exists(bed.Paths.PreviousMedia(bed.JobId)).Should().BeFalse(
"an empty library is only short-lived rollback scratch");
}
[Fact]
public async Task PublicEntryPoint_PopulatedDestinationWithoutConfirmation_IsRefused()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true);
var failure = await FluentActions
.Awaiting(() => bed.ActivatePublicAsync())
.Should().ThrowAsync();
failure.Which.Code.Should().Be(MigrationActivationErrorCodes.ConfirmationRequired);
(await bed.ReadJobAsync()).State.Should().Be((int)MigrationJobState.ReadyToActivate);
(await bed.CurrentRevisionAsync()).Should().Be(41);
bed.AssertOriginalGeneration();
}
[Fact]
public async Task StaleRequestRevision_IsRefused_WithoutChangingTheLibrary()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: false);
await using (var scope = bed.Host.CreateAsyncScope())
{
var coordinator = scope.ServiceProvider.GetRequiredService();
var failure = await FluentActions
.Awaiting(() => coordinator.ActivateAsync(
bed.JobId, new MigrationActivateRequest("stale-revision", true), default))
.Should().ThrowAsync();
failure.Which.Code.Should().Be(MigrationActivationErrorCodes.DestinationConflict);
}
(await bed.ReadJobAsync()).State.Should().Be((int)MigrationJobState.ReadyToActivate);
(await bed.CurrentRevisionAsync()).Should().Be(41);
bed.AssertOriginalGeneration();
}
internal static (Guid BookId, string Kind, string Extension, long Bytes, string Sha256)[] SweepRetainedMedia(
string root)
{
var result = new List<(Guid, string, string, long, string)>();
foreach (var directory in Directory.EnumerateDirectories(root))
{
var bookId = Guid.Parse(Path.GetFileName(directory));
foreach (var file in Directory.EnumerateFiles(directory))
{
var name = Path.GetFileName(file);
var stem = Path.GetFileNameWithoutExtension(name);
var kind = stem switch
{
"book" => "book",
"cover" => "cover",
_ when stem.StartsWith("cover-thumb-", StringComparison.OrdinalIgnoreCase) => "thumbnail",
_ => "other",
};
var bytes = File.ReadAllBytes(file);
result.Add((bookId, kind, Path.GetExtension(name).ToLowerInvariant(), bytes.LongLength,
Sha256Hex(bytes)));
}
}
return result
.OrderBy(item => item.Item1).ThenBy(item => item.Item2)
.ThenBy(item => item.Item3).ThenBy(item => item.Item4)
.ToArray();
}
}