using System.Text;
using System.Text.Json.Nodes;
using FluentAssertions;
using Nostos.Backend.Data.Models;
using Nostos.Backend.Services;
using Nostos.Backend.Services.Portability;
using Nostos.Backend.Services.Portability.Migration;
using static Nostos.Backend.Tests.Portability.PortableArchiveTestSupport;
namespace Nostos.Backend.Tests.Portability;
///
/// Real-exporter fixtures and archive mutation helpers for the Slice 9/10
/// engine tests. Every archive is produced by the production exporter from the
/// representative library and then (for legacy/invalid cases) rewritten at the
/// ZIP entry level with the data descriptor recomputed exactly.
///
internal static class MigrationArchiveJobTestSupport
{
internal const string ManifestPath = "manifest.json";
internal const string DataPath = "data/library.json";
internal static async Task ExportRepresentativeAsync()
{
await using var source = await LocalPortableTestLibrary.CreateAsync();
await PortableArchiveTestSupport.PopulateRepresentativeAsync(source.Db, source.Storage);
using var bytes = new MemoryStream();
await source.Portability().ExportAsync(bytes);
return bytes.ToArray();
}
///
/// Rewrites a current archive as the requested legacy data version by
/// removing the fields that version predates and recomputing the data
/// descriptor carried in the manifest.
///
internal static byte[] ToDataVersion(byte[] archive, int dataVersion)
{
var entries = ReadEntries(archive);
MutateJsonEntry(entries, ManifestPath, root =>
{
root["formatVersion"] = 1;
root["dataVersion"] = dataVersion;
});
MutateJsonEntry(entries, DataPath, root =>
{
root["version"] = dataVersion;
PortableArchiveTestSupport.AsPerEditionBookValues(root);
if (dataVersion < 5) root.Remove("readingListEntries");
if (dataVersion <= 2)
{
root.Remove("noteImportBookLinks");
}
if (dataVersion <= 1)
{
root.Remove("writingNotes");
}
});
RehashDataDescriptor(entries);
return BuildArchive(entries);
}
/// Replaces the relational payload with invalid JSON so preparation fails typed.
internal static byte[] CorruptData(byte[] archive)
{
var entries = ReadEntries(archive);
var index = entries.FindIndex(x => x.Name == DataPath);
entries[index] = new TestArchiveEntry(DataPath, Encoding.UTF8.GetBytes("{ this is not valid json"));
RehashDataDescriptor(entries);
return BuildArchive(entries);
}
internal static int CountStagingAreas(MigrationEngineHarness harness)
{
var root = harness.Paths.GetStagingRoot();
return Directory.Exists(root)
? Directory.EnumerateDirectories(root)
.Count(directory => Guid.TryParseExact(Path.GetFileName(directory), "N", out _))
: 0;
}
internal static string? SingleStagingDirectory(MigrationEngineHarness harness)
{
var root = harness.Paths.GetStagingRoot();
return Directory.Exists(root)
? Directory.EnumerateDirectories(root)
.Single(directory => Guid.TryParseExact(Path.GetFileName(directory), "N", out _))
: null;
}
internal static int CountExportFiles(MigrationEngineHarness harness, Guid jobId)
{
var directory = harness.Paths.GetExportDirectory(jobId);
return Directory.Exists(directory) ? Directory.EnumerateFiles(directory).Count() : 0;
}
///
/// Drives worker cycles until the job reaches .
/// One RunCycleAsync can yield at a load checkpoint (the worker's
/// admission gate, lease or a database busy timeout can bounce under CI
/// load), so tests must not assume a single call is enough. Every end-state
/// assertion still runs; only the number of cycles needed is made
/// load-independent.
///
internal static async Task RunToStateAsync(
MigrationEngineHarness harness,
Guid jobId,
MigrationJobState expected,
MigrationJobWorker? worker = null)
{
const int maxCycles = 20;
worker ??= harness.Worker;
for (var cycle = 0; cycle < maxCycles; cycle++)
{
await worker.RunCycleAsync(default);
var job = await harness.WithJobs(s => s.GetAsync(jobId, default));
if (job!.State == expected)
{
return;
}
}
var last = await harness.WithJobs(s => s.GetAsync(jobId, default));
last!.State.Should().Be(expected,
$"the worker must reach {expected} within {maxCycles} bounded cycles");
}
internal static FileStorageService CreateFileStorage(string root) =>
new(
new PortableTestWebHostEnvironment(root),
Microsoft.Extensions.Options.Options.Create(new Nostos.Backend.Configuration.FileStorageOptions
{
BooksRoot = Path.Combine(root, "books"),
}),
Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
/// Representative seeded library used by export tests.
internal static Task SeedRepresentativeAsync(
MigrationEngineHarness harness,
IBookAssetStorage storage) =>
harness.WithDb(db => PortableArchiveTestSupport.PopulateRepresentativeAsync(db, storage));
}