using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Nostos.Backend.Data; using Nostos.Backend.Services.Portability; using Nostos.Backend.Services.Portability.Activation; using Xunit; namespace Nostos.Backend.Tests.Portability; /// /// Slice 9 happy paths: after a populated replacement, restoring the retained /// copy returns the original portable library and media byte-for-byte, keeps /// the CURRENT host operational state (the previous library's week-old host /// rows are never resurrected), advances the revision exactly once, and retains /// the replaced library as a new seven-day recovery copy so the restore is /// itself reversible. /// [Collection(ActivationCoordinatorCollection.Name)] public sealed class RecoveryRestoreHappyPathTests { [Fact] public async Task PopulatedActivationThenRestore_ReturnsTheOriginalLibrary_KeepsCurrentHostState_AndRetainsTheReplacedLibrary() { await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true); // A regenerable book-text cache of the original generation: the cutover // must clear it, and a restore must never bring it back as trustworthy // derived state. var staleBook = Guid.NewGuid().ToString("N"); var staleDerived = Path.Combine(bed.Paths.LiveMedia, staleBook, "derived", "stale"); Directory.CreateDirectory(staleDerived); await File.WriteAllTextAsync(Path.Combine(staleDerived, "chunk.json"), "stale"); (await bed.ActivateAsync(confirm: true)).Outcome.Should().Be(SelfHostedActivationOutcome.Completed); // Distinguish the current generation from the retained one: a portable // edit (new collection + revision bump) and a host-operational record. await bed.WritePortableRevisionBumpAsync(); await bed.WriteHostRecordAsync(); var before = bed.SnapshotLiveGeneration(); var beforeRevision = await bed.CurrentRevisionAsync(); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored, $"error code: {result.ErrorCode}; manifest: {bed.ReadRecoveryManifest()?.RestoreError}"); result.Status.Should().Be(MigrationRecoveryStatus.Restored); // The restored portable state and media are the retained original library. await bed.AssertRestoredPortableGenerationAsync(); // No derived cache of the previous generation is readable after the // restore, and the restore's resolved Committed journal is exactly what // the derived rebuild consumes to reschedule the new live generation. Directory.Exists(staleDerived).Should().BeFalse( "the previous generation's derived cache is never restored"); Directory.EnumerateDirectories(bed.Paths.LiveMedia, "derived", SearchOption.AllDirectories) .Should().BeEmpty("the restored media root starts without derived caches"); // The portable edit is gone; every host-operational table keeps the // CURRENT state except the revision and the replaced library's new // claim. Portable tables are asserted separately above. var after = bed.SnapshotLiveGeneration(); foreach (var table in before.Tables.Keys) { if (table is "LibraryStates" or "MigrationStorageReservations" || ActivationCoordinatorTestBed.PortableTableNames.Contains(table, StringComparer.Ordinal)) { continue; } after.Tables[table].Should().BeEquivalentTo( before.Tables[table], $"table {table} must keep the current host state"); } (await bed.CurrentRevisionAsync()).Should().Be( beforeRevision + 1, "one restore is one committed library-state mutation"); var beforeReservations = before.Tables["MigrationStorageReservations"].ToHashSet(); var afterReservations = after.Tables["MigrationStorageReservations"]; afterReservations.Count(row => beforeReservations.Contains(row)).Should().Be( beforeReservations.Count, "every pre-existing reservation row is unchanged"); var added = afterReservations.Except(beforeReservations).ToArray(); added.Should().HaveCount(1, "the replaced library holds exactly one new retention claim"); // The replaced library is retained as a new available recovery copy. var restored = bed.ReadRecoveryManifest()!; restored.Status.Should().Be(MigrationRecoveryStatus.Restored); restored.RestoreOperationId.Should().NotBeNull(); var restoreJournal = bed.Journals.ReadResolved(restored.RestoreOperationId!.Value); restoreJournal.Should().NotBeNull(); restoreJournal!.Phase.Should().Be(SelfHostedActivationPhase.Committed, "the derived rebuild treats the restore as a committed generation change"); restored.RestoredAtUtc.Should().NotBeNull(); var replacedId = restored.RestoreOperationId!.Value; added[0].Should().ContainEquivalentOf(replacedId.ToString(), "the new claim belongs to the restore operation"); var replaced = bed.ReadRecoveryManifest(replacedId); replaced.Should().NotBeNull("the replaced library must be a normal available recovery copy"); replaced!.Status.Should().Be(MigrationRecoveryStatus.Available); replaced.RestoreOperationId.Should().BeNull(); (replaced.ExpiresAtUtc - replaced.CreatedAtUtc).Should().Be( TimeSpan.FromDays(MigrationContractLimits.RecoveryRetentionDays)); var replacedDatabase = bed.Paths.PreviousDatabase(replacedId); new FileInfo(replacedDatabase).Length.Should().Be(replaced.DatabaseBytes); PortableArchiveTestSupport.Sha256Hex(File.ReadAllBytes(replacedDatabase)) .Should().Be(replaced.DatabaseSha256); ActivationBuildFixture.MediaSnapshot(bed.Paths.PreviousMedia(replacedId)) .Should().BeEquivalentTo(bed.ExpectedImportedMedia, "the replaced imported generation is retained byte-for-byte"); // The cutover journal committed and candidate artifacts were consumed. bed.Journals.ReadResolved(replacedId)!.Phase.Should().Be(SelfHostedActivationPhase.Committed); File.Exists(bed.Paths.CandidateDatabase(replacedId)).Should().BeFalse(); Directory.Exists(bed.Paths.CandidateMedia(replacedId)).Should().BeFalse(); File.Exists(bed.Paths.RestoreSourceDatabase(replacedId)).Should().BeFalse(); File.Exists(bed.Paths.RestorePayload(replacedId)).Should().BeFalse(); } [Fact] public async Task Restore_WithoutConfirmation_IsRefused_AndNothingChanges() { await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true); (await bed.ActivateAsync(confirm: true)).Outcome.Should().Be(SelfHostedActivationOutcome.Completed); var before = bed.SnapshotLiveGeneration(); var failure = await FluentActions .Awaiting(() => bed.RequestRestoreOnlyAsync(confirm: false)) .Should().ThrowAsync(); failure.Which.Code.Should().Be(MigrationActivationErrorCodes.ConfirmationRequired); bed.AssertGenerationEquals(before, "an unconfirmed restore must not touch the library"); bed.ReadRecoveryManifest()!.Status.Should().Be(MigrationRecoveryStatus.Available); } [Fact] public async Task Restore_WithAStaleRevision_IsRefused_AndNothingChanges() { await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true); (await bed.ActivateAsync(confirm: true)).Outcome.Should().Be(SelfHostedActivationOutcome.Completed); var before = bed.SnapshotLiveGeneration(); var failure = await FluentActions .Awaiting(() => bed.RequestRestoreOnlyAsync(revision: "stale-revision")) .Should().ThrowAsync(); failure.Which.Code.Should().Be(MigrationActivationErrorCodes.DestinationConflict); bed.AssertGenerationEquals(before, "a stale confirmation must not touch the library"); bed.ReadRecoveryManifest()!.Status.Should().Be(MigrationRecoveryStatus.Available); } [Fact] public async Task Restore_AfterTheLiveLibraryChangedAgain_IsRefused_AndTheInterveningWriteSurvives() { await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true); (await bed.ActivateAsync(confirm: true)).Outcome.Should().Be(SelfHostedActivationOutcome.Completed); // Claim with the current revision, then write before the worker runs. await bed.RequestRestoreOnlyAsync(); await bed.WritePortableRevisionBumpAsync(); var before = bed.SnapshotLiveGeneration(); var result = await bed.ResumeRestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Interrupted); result.ErrorCode.Should().Be(MigrationActivationErrorCodes.DestinationConflict); bed.AssertGenerationEquals(before, "the intervening write must survive"); bed.ReadRecoveryManifest()!.Status.Should().Be(MigrationRecoveryStatus.Available, "the copy returns to Available so the user can confirm a fresh revision"); } [Fact] public async Task ARepeatedRestoreRequest_AfterCompletion_IsAnIdempotentAlreadyRestored() { await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true); (await bed.ActivateAsync(confirm: true)).Outcome.Should().Be(SelfHostedActivationOutcome.Completed); (await bed.RestoreAsync()).Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored); var after = bed.SnapshotLiveGeneration(); var runs = bed.Host.GetRequiredService().StartedRestoreRunCount; var again = await bed.ResumeRestoreAsync(); again.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored, "a repeated request is an idempotent success, not a second run"); bed.Host.GetRequiredService().StartedRestoreRunCount .Should().Be(runs, "a repeated request must not start a second run"); bed.AssertGenerationEquals(after, "a repeated run must not restore twice"); } [Fact] public async Task RestoringTheNewRecoveryCopy_BringsTheReplacedImportedLibraryBack() { await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true); (await bed.ActivateAsync(confirm: true)).Outcome.Should().Be(SelfHostedActivationOutcome.Completed); await bed.WritePortableRevisionBumpAsync(); var importedTables = ActivationBuildFixture.DumpAllTables(bed.Paths.LiveDatabase); // First restore: the original library is back and the imported // generation is retained as a new available copy. (await bed.RestoreAsync()).Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored); var replacedId = bed.ReadRecoveryManifest()!.RestoreOperationId!.Value; var beforeSecond = await bed.CurrentRevisionAsync(); // Restoring that new copy brings the imported generation back. var result = await bed.RestoreAsync(recoveryId: replacedId); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored); bed.AssertPortableEquals(importedTables, "the replaced generation is restored"); ActivationBuildFixture.MediaSnapshot(bed.Paths.LiveMedia) .Should().BeEquivalentTo(bed.ExpectedImportedMedia, "the replaced generation's media is restored"); (await bed.CurrentRevisionAsync()).Should().Be(beforeSecond + 1); bed.ReadRecoveryManifest(replacedId)!.Status.Should().Be(MigrationRecoveryStatus.Restored, "the source copy is consumed by the restore"); // The library that was just replaced is retained again, so restore is // reversible in both directions. var newCopyId = bed.ReadRecoveryManifest(replacedId)!.RestoreOperationId!.Value; newCopyId.Should().NotBe(replacedId); var newCopy = bed.ReadRecoveryManifest(newCopyId); newCopy.Should().NotBeNull(); newCopy!.Status.Should().Be(MigrationRecoveryStatus.Available); ActivationBuildFixture.MediaSnapshot(bed.Paths.PreviousMedia(newCopyId)) .Should().BeEquivalentTo(bed.OriginalMedia, "the just-replaced original library is retained byte-for-byte"); } }