Publish Ludarium source
Public source validation / source (push) Successful in 2m16s

This commit is contained in:
Ludarium release export
2026-09-03 02:08:58 +02:00
commit df869819ce
339 changed files with 46455 additions and 0 deletions
@@ -0,0 +1,71 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// What a scan concludes about the files that were not there any more.
/// </summary>
/// <remarks>
/// Reconciliation compared the stored state against the name "Present" while every other query in
/// the store reads the enum's ordinal. The comparison matched no row, so a ROM deleted from the
/// archive stayed Present: it kept its Play control, counted towards the archive's size and never
/// appeared as missing. Only the real database can prove the ordinal contract, so it is proven here.
/// </remarks>
public sealed class ArtifactReconciliationTests
{
[Fact]
[Trait("Category", "Container")]
public async Task AFileThatLeftTheArchiveIsMarkedMissing()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_reconciliation_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Reconciliation fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-reconciliation-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var observed = "roms/nes/Kept.nes";
var removed = "roms/nes/Deleted.nes";
foreach (var path in new[] { observed, removed })
await store.UpsertArtifactAsync(
new(Guid.NewGuid(), library.Id, path, 24_592, DateTimeOffset.UtcNow.AddDays(-2),
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null,
DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddDays(-2), null, "nes"),
null, [], CancellationToken.None);
var at = DateTimeOffset.UtcNow;
var marked = await store.MarkMissingExceptAsync(library.Id, new HashSet<string>([observed]), at, CancellationToken.None);
Assert.Equal(1, marked);
var gone = await store.FindByPathAsync(library.Id, removed, CancellationToken.None);
// Reading it back through the store proves the written state is the shape the record expects.
Assert.Equal(ArtifactState.Missing, gone!.State);
Assert.Equal(2, gone.Version);
var kept = await store.FindByPathAsync(library.Id, observed, CancellationToken.None);
Assert.Equal(ArtifactState.Present, kept!.State);
Assert.Equal(1, kept.Version);
// The same ordinal contract is read by the storage summary, so the archive reports the loss.
var summary = await store.GetStorageSummaryAsync(CancellationToken.None);
var entry = Assert.Single(summary.Libraries, item => item.LibraryId == library.Id);
Assert.Equal(1, entry.Missing);
// A second scan that still cannot see the file leaves it alone rather than counting it twice.
Assert.Equal(0, await store.MarkMissingExceptAsync(library.Id, new HashSet<string>([observed]), at, CancellationToken.None));
}
}
@@ -0,0 +1,7 @@
namespace Ludarium.IntegrationTests;
public sealed class ContractTests
{
[Fact]
public void ApiAssemblyIsLoadable() => Assert.NotNull(typeof(Program).Assembly.EntryPoint);
}
@@ -0,0 +1,309 @@
using System.Globalization;
using System.IO.Compression;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
/// <summary>
/// CUE/BIN sets served to the browser player as one streamed archive.
/// </summary>
/// <remarks>
/// A CUE sheet is operator content read straight out of a read-only library, so the parser is the
/// boundary that decides which files Ludarium will open. An incomplete or escaping set must fail
/// closed rather than start a half-readable disc.
/// </remarks>
public sealed class DiscSetArchiveTests : IDisposable
{
private readonly string root = Directory.CreateTempSubdirectory("ludarium-cue-").FullName;
public void Dispose() => Directory.Delete(root, recursive: true);
[Fact]
public void ASingleTrackSheetResolvesToItsOneTrack()
{
var files = DiscSetArchive.ParseTrackFiles("""
FILE "Ludarium Fixture (Track 1).bin" BINARY
TRACK 01 MODE2/2352
INDEX 01 00:00:00
""");
Assert.Equal(["Ludarium Fixture (Track 1).bin"], files);
}
[Fact]
public void AMultiTrackSheetKeepsEveryDistinctTrackInOrder()
{
var files = DiscSetArchive.ParseTrackFiles("""
FILE "Disc (Track 1).bin" BINARY
TRACK 01 MODE2/2352
INDEX 01 00:00:00
FILE "Disc (Track 2).bin" BINARY
TRACK 02 AUDIO
INDEX 01 00:00:00
FILE "Disc (Track 2).bin" BINARY
TRACK 03 AUDIO
INDEX 01 02:00:00
""");
Assert.Equal(["Disc (Track 1).bin", "Disc (Track 2).bin"], files);
}
[Fact]
public void AnUnquotedTrackNameIsStillRead()
{
Assert.Equal(["game.bin"], DiscSetArchive.ParseTrackFiles("FILE game.bin BINARY\n TRACK 01 MODE1/2352"));
}
[Theory]
[InlineData("FILE \"../../etc/passwd\" BINARY")]
[InlineData("FILE \"sub/dir/track.bin\" BINARY")]
[InlineData("FILE \"C:\\\\windows\\\\system32\\\\a.bin\" BINARY")]
[InlineData("FILE \"payload.exe\" BINARY")]
[InlineData("FILE \"..\" BINARY")]
public void ASheetThatEscapesItsDirectoryOrNamesAnExecutableIsRejected(string line) =>
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles(line));
[Fact]
public void ASheetWithoutTracksIsRejected()
{
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles("REM GENRE Puzzle\nREM DATE 1996"));
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles("FILENAME whatever.bin BINARY"));
}
[Fact]
public void ASheetNamingMoreFilesThanOneDiscCanHoldIsRejected()
{
var lines = string.Join('\n', Enumerable.Range(0, DiscSetArchive.MaximumTrackFiles + 2)
.Select(index => $"FILE \"track{index:D2}.bin\" BINARY"));
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles(lines));
}
[Fact]
public async Task ACompleteSetResolvesWithItsTotalSize()
{
WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Track 2).bin", 2048);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin");
var set = await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
cue, 2L * 1024 * 1024 * 1024, CancellationToken.None);
Assert.NotNull(set);
Assert.Equal(2, set.MemberPaths.Count - 1);
Assert.Equal(cue + 4096 + 2048, set.TotalBytes);
Assert.All(set.MemberPaths, path => Assert.StartsWith("roms/psx/Game/", path, StringComparison.Ordinal));
}
[Fact]
public async Task AMissingTrackFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
cue, 2L * 1024 * 1024 * 1024, CancellationToken.None));
}
[Fact]
public async Task ASetLargerThanThePlatformLimitFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Track 1).bin", 8192);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
cue, 4096, CancellationToken.None));
}
[Fact]
public async Task AnOversizedSheetIsNotEvenRead()
{
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
DiscSetArchive.MaximumDescriptorBytes + 1, long.MaxValue, CancellationToken.None));
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.iso",
cue, long.MaxValue, CancellationToken.None));
}
[Fact]
public async Task TheStreamedArchiveContainsTheSheetAndEveryTrackVerbatim()
{
var first = WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Track 2).bin", 2048);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin");
var files = FileSystem();
var set = await DiscSetArchive.ResolveAsync(files, Library(), "roms/psx/Game/Game.cue", cue,
long.MaxValue, CancellationToken.None);
var opened = DiscSetArchive.Open(set!, files, Library(), CancellationToken.None);
Assert.Equal("Game.zip", opened.FileName);
// The reader is a live pipe, so the archive is buffered exactly as the player would receive it.
using var buffer = new MemoryStream();
await using (var content = opened.Content) await content.CopyToAsync(buffer);
buffer.Position = 0;
using var archive = new ZipArchive(buffer, ZipArchiveMode.Read);
Assert.Equal(["Game.cue", "Game (Track 1).bin", "Game (Track 2).bin"],
archive.Entries.Select(entry => entry.FullName));
Assert.Equal(4096, archive.Entries[1].Length);
Assert.Equal(2048, archive.Entries[2].Length);
await using var track = archive.Entries[1].Open();
var extracted = new byte[4096];
await track.ReadExactlyAsync(extracted);
Assert.Equal(first, extracted);
}
[Fact]
public void APlaylistKeepsEveryDistinctDiscInOrder()
{
var discs = DiscSetArchive.ParsePlaylist("""
# Ludarium multi-disc fixture
Game (Disc 1).cue
Game (Disc 2).cue
Game (Disc 2).cue
""");
Assert.Equal(["Game (Disc 1).cue", "Game (Disc 2).cue"], discs);
}
[Theory]
[InlineData("../../etc/passwd")]
[InlineData("discs/Game (Disc 1).cue")]
[InlineData("Game (Disc 1).m3u")]
[InlineData("payload.exe")]
public void APlaylistThatEscapesItsDirectoryOrNestsIsRejected(string line) =>
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParsePlaylist(line));
[Fact]
public void APlaylistWithoutDiscsIsRejected() =>
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParsePlaylist("# only a comment\n\n"));
[Fact]
public void APlaylistNamingMoreDiscsThanOneGameCanHoldIsRejected()
{
var lines = string.Join('\n', Enumerable.Range(0, DiscSetArchive.MaximumDiscs + 2)
.Select(index => $"Disc {index}.chd"));
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParsePlaylist(lines));
}
[Fact]
public async Task AMultiDiscPlaylistResolvesEveryDiscAndItsTracks()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Disc 2) (Track 1).bin", 2048);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
var set = await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u",
playlist, long.MaxValue, CancellationToken.None);
Assert.NotNull(set);
Assert.Equal(2, set.DiscCount);
// The playlist, both sheets and both tracks travel together or the game does not start.
Assert.Equal(5, set.MemberPaths.Count);
Assert.Equal("roms/psx/Game/Game.m3u", set.MemberPaths[0]);
}
[Fact]
public async Task APlaylistMissingOneDiscFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u",
playlist, long.MaxValue, CancellationToken.None));
}
[Fact]
public async Task APlaylistMissingOneTrackOfOneDiscFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u",
playlist, long.MaxValue, CancellationToken.None));
}
[Fact]
public async Task AMultiDiscArchiveCarriesThePlaylistAndEveryMember()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Disc 2) (Track 1).bin", 2048);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
var files = FileSystem();
var set = await DiscSetArchive.ResolveAsync(files, Library(), "roms/psx/Game/Game.m3u", playlist,
long.MaxValue, CancellationToken.None);
var opened = DiscSetArchive.Open(set!, files, Library(), CancellationToken.None);
using var buffer = new MemoryStream();
await using (var content = opened.Content) await content.CopyToAsync(buffer);
buffer.Position = 0;
using var archive = new ZipArchive(buffer, ZipArchiveMode.Read);
Assert.Equal("Game.zip", opened.FileName);
Assert.Equal(["Game.m3u", "Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin",
"Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin"],
archive.Entries.Select(entry => entry.FullName));
}
[Fact]
public void APlaylistIsRecognisedByExtensionAlone()
{
Assert.True(DiscSetArchive.IsPlaylist("roms/psx/Game/Game.M3U"));
Assert.True(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.m3u"));
Assert.True(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.cue"));
Assert.False(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.chd"));
}
private long WritePlaylist(string relativePath, params string[] discs)
{
var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, string.Concat(discs.Select(disc => disc + "\n")));
return new FileInfo(path).Length;
}
[Fact]
public void ACueSheetIsRecognisedByExtensionAlone()
{
Assert.True(DiscSetArchive.IsCueSheet("roms/psx/Game/Game.CUE"));
Assert.False(DiscSetArchive.IsCueSheet("roms/psx/Game/Game.bin"));
Assert.False(DiscSetArchive.IsCueSheet(null));
}
private static ReadOnlyLibraryFileSystem FileSystem() => new();
private LibraryRoot Library() => new(Guid.NewGuid(), "fixture", root, LibraryKind.Rom, true,
HashPolicy.Sha256, false, 1, true, true, IsReadOnly: true);
private byte[] WriteTrack(string relativePath, int size)
{
var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var content = Enumerable.Range(0, size).Select(index => (byte)(index % 251)).ToArray();
File.WriteAllBytes(path, content);
return content;
}
private long WriteCue(string relativePath, params string[] tracks)
{
var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var lines = tracks.Select((track, index) => string.Create(CultureInfo.InvariantCulture,
$"FILE \"{track}\" BINARY\n TRACK {index + 1:D2} {(index == 0 ? "MODE2/2352" : "AUDIO")}\n INDEX 01 00:00:00\n"));
File.WriteAllText(path, string.Concat(lines));
return new FileInfo(path).Length;
}
}
@@ -0,0 +1,72 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// The title a scan derives from a file name.
/// </summary>
/// <remarks>
/// The extensions stripped from a derived title were listed separately in SQL from the extensions
/// the identifier recognises, and the two drifted apart: a Mega Drive ROM was identified correctly
/// and then presented as "Sonic Adventure md". Both now read one vocabulary, and a catalog that
/// already carries the old spelling keeps its game rather than gaining a second one.
/// </remarks>
public sealed class DiscoveredGameTitleTests
{
[Fact]
[Trait("Category", "Container")]
public async Task ACartridgeExtensionDoesNotSurviveIntoTheTitle()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_title_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Title fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-title-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
// The catalog already holds this game under the spelling the old query produced, discovered by
// a scan rather than named by hand: a manually named game keeps the operator's title.
await using (var legacy = dataSource.CreateCommand("""
INSERT INTO games(id,title,version,data,origin)
VALUES(md5('ludarium:game:sonic adventure.md')::uuid, 'Sonic Adventure md', 1,
jsonb_build_object('id', md5('ludarium:game:sonic adventure.md')::uuid,
'title', 'Sonic Adventure md', 'createdAt', now(), 'version', 1, 'origin', 0), 0)
"""))
await legacy.ExecuteNonQueryAsync(CancellationToken.None);
foreach (var (path, platform) in new[]
{
("roms/genesis/Sonic Adventure.md", "genesis"),
("roms/mastersystem/Alex Quest.sms", "mastersystem"),
("roms/gamegear/Pocket Racer.gg", "gamegear"),
})
await store.UpsertArtifactAsync(
new(Guid.NewGuid(), library.Id, path, 65_536, DateTimeOffset.UtcNow.AddDays(-1),
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null,
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(-1), null, platform),
null, [], CancellationToken.None);
await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None);
var catalog = await store.SearchGamesAsync(new GameQuery { PageSize = 50 }, CancellationToken.None);
var titles = catalog.Items.Select(game => game.Title).OrderBy(title => title, StringComparer.Ordinal).ToArray();
Assert.Equal(["Alex Quest", "Pocket Racer", "Sonic Adventure"], titles);
// The pre-existing row was recognised as the same game, so no duplicate was left behind.
Assert.Equal(3, catalog.Total);
}
}
@@ -0,0 +1,47 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class FirmwareVaultFileStoreTests : IDisposable
{
private readonly string root = Path.Combine(Path.GetTempPath(), $"ludarium-firmware-{Guid.NewGuid():N}");
[Fact]
public async Task AppOwnedUploadIsHashedVerifiedAndDeletable()
{
var store = new FirmwareVaultFileStore(root);
var bytes = Enumerable.Range(0, 512).Select(value => (byte)(value % 251)).ToArray();
var asset = await store.SaveAsync(FirmwarePolicy.Get("psx", "bios"), Guid.NewGuid(), "personal.bin",
new MemoryStream(bytes), bytes.Length, CancellationToken.None);
Assert.Equal(bytes.Length, asset.Length);
Assert.Equal(64, asset.Sha256.Length);
await using (var opened = (await store.OpenAsync(asset, CancellationToken.None))!.Content)
{
using var copy = new MemoryStream();
await opened.CopyToAsync(copy);
Assert.Equal(bytes, copy.ToArray());
}
Assert.True(store.Delete(asset));
Assert.Null(await store.OpenAsync(asset, CancellationToken.None));
}
[Theory]
[InlineData("MZhost executable")]
[InlineData("#!/bin/sh")]
[InlineData("PK\u0003\u0004archive")]
public async Task HostExecutablesScriptsAndArchivesFailClosed(string text)
{
var store = new FirmwareVaultFileStore(root);
await Assert.ThrowsAsync<FirmwareValidationException>(() => store.SaveAsync(
FirmwarePolicy.Get("psx", "bios"), Guid.NewGuid(), "personal.bin",
new MemoryStream(Encoding.UTF8.GetBytes(text)), null, CancellationToken.None));
}
public void Dispose()
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
@@ -0,0 +1,45 @@
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class GameArtworkEnricherTests
{
[Theory]
[InlineData("Asterix and Obelix Slap Them All")]
[InlineData("Call of Duty - Black Ops 6")]
[InlineData("Destroy All Humans 2")]
[InlineData("Evil Genius 2 World Domination")]
[InlineData("Life is Strange Reunion Deluxe Edition v1 003 000")]
[InlineData("Life is Strange Reunion Deluxe Edition v1 003 000 PS5-UNLiMiTED")]
[InlineData("PRAGMATA")]
[InlineData("RollerCoaster Tycoon Adventures Deluxe")]
[InlineData("Two Point Museum")]
public void ProductionPs5TitlesHaveProvenanceBackedRetailFallbacks(string title)
{
var source = Assert.IsType<RetailArtworkSource>(GameArtworkEnricher.FindCuratedRetailArtwork(title, "ps5"));
Assert.Equal("WorldOfGames", source.Provider);
Assert.Equal("curated-retailer-platform-box-front", source.MatchMethod);
Assert.StartsWith("https://www.wog.ch/en/index.cfm/details/product/", source.ExternalId);
}
[Fact]
public void CuratedFallbackDoesNotCrossPlatformBoundaries() =>
Assert.Null(GameArtworkEnricher.FindCuratedRetailArtwork("PRAGMATA", "windows"));
[Fact]
public void PsxSerialFallbackRetainsItsArchivalProvenance()
{
var source = Assert.IsType<RetailArtworkSource>(GameArtworkEnricher.FindCuratedRetailArtwork("Disney's A Bug's Life", "psx"));
Assert.Equal("PSXDataCenter", source.Provider);
Assert.Equal("curated-serial-platform-box-front", source.MatchMethod);
Assert.EndsWith("SCUS-94288.html", source.ExternalId);
}
[Fact]
public void DigitalOnlyWindowsReleaseIsExplicitlyNotClaimedAsRetail()
{
var source = Assert.IsType<RetailArtworkSource>(GameArtworkEnricher.FindCuratedRetailArtwork("Movies Tycoon", "windows"));
Assert.Equal("SteamDigital", source.Provider);
Assert.Equal("verified-digital-only-storefront-front", source.MatchMethod);
}
}
@@ -0,0 +1,44 @@
using Ludarium.Infrastructure;
using Ludarium.Domain;
namespace Ludarium.IntegrationTests;
public sealed class GameArtworkStoreTests
{
[Fact]
public async Task StoresValidatedArtworkAndRejectsUnknownPayloads()
{
var root = Path.Combine(Path.GetTempPath(), "ludarium-artwork-tests", Guid.NewGuid().ToString("N"));
try
{
var store = new GameArtworkStore(root);
var gameId = Guid.NewGuid();
var png = new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4 };
await using var content = new MemoryStream(png);
Assert.Equal("image/png", await store.SaveAsync(gameId, content, png.Length, CancellationToken.None, "Libretro"));
Assert.Equal("Libretro", store.GetSource(gameId));
var described = Assert.IsType<GameArtwork>(await store.DescribeAsync(gameId, "Libretro", "exact-test", Confidence.High, CancellationToken.None));
var jpeg = new byte[] { 0xff, 0xd8, 0xff, 1, 2, 3, 4 };
await using (var replacement = new MemoryStream(jpeg))
await store.SaveAsync(gameId, replacement, jpeg.Length, CancellationToken.None, "Uploaded");
var preserved = Assert.IsType<StoredGameArtwork>(await store.OpenAsync(gameId, CancellationToken.None, described.Id, false));
Assert.Equal("image/png", preserved.ContentType);
await preserved.Content.DisposeAsync();
var saved = await store.OpenAsync(gameId, CancellationToken.None);
Assert.NotNull(saved);
Assert.Equal("image/jpeg", saved.ContentType);
Assert.Equal(jpeg.Length, saved.Length);
await saved.Content.DisposeAsync();
Assert.True(store.Delete(gameId));
Assert.False(store.Delete(gameId));
await using var invalid = new MemoryStream("not-an-image"u8.ToArray());
await Assert.ThrowsAsync<ArtworkValidationException>(() =>
store.SaveAsync(Guid.NewGuid(), invalid, invalid.Length, CancellationToken.None));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
}
@@ -0,0 +1,96 @@
using Ludarium.Infrastructure;
using System.Net;
using System.Text;
using System.Text.Json;
namespace Ludarium.IntegrationTests;
public sealed class GameCheatCatalogTests
{
[Fact]
public void ParsesBoundedDescriptionAndCodePairs()
{
const string content = """
cheats = 3
cheat0_desc = "Infinite lives"
cheat0_code = "1234:09"
cheat0_enable = false
cheat1_desc = "Unlock world"
cheat1_code = "ABCD+EFGH"
cheat2_desc = "Missing code is ignored"
""";
var cheats = GameCheatCatalog.Parse(content);
Assert.Equal(2, cheats.Count);
Assert.Equal(new GameCheat("Infinite lives", "1234:09"), cheats[0]);
Assert.Equal(new GameCheat("Unlock world", "ABCD+EFGH"), cheats[1]);
}
[Fact]
public async Task EmptyStatusDoesNotCauseAnExternalDownload()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-cheat-tests", Guid.NewGuid().ToString("N"));
var calls = 0;
try
{
using var client = new HttpClient(new StubHandler(_ =>
{
calls++;
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
}));
using var catalog = new GameCheatCatalog(directory, client);
var status = await catalog.StatusAsync(CancellationToken.None);
Assert.False(status.Cached);
Assert.Equal(0, status.IndexedFiles);
Assert.Equal(0, calls);
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task RefreshesPinnedIndexAndReturnsOnlyAnExactPlatformTitleMatch()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-cheat-tests", Guid.NewGuid().ToString("N"));
try
{
var tree = Enumerable.Range(0, 100).Select(index => new
{
path = $"cht/Nintendo - Nintendo Entertainment System/{(index == 0 ? "Fixture Game" : $"Other {index}")}.cht",
type = "blob",
size = 120,
sha = index.ToString("x40", System.Globalization.CultureInfo.InvariantCulture)
}).ToArray();
var indexJson = JsonSerializer.Serialize(new { sha = GameCheatCatalog.Commit, truncated = false, tree });
var handler = new StubHandler(request => request.RequestUri!.Host == "api.github.com"
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(indexJson) }
: new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("cheat0_desc = \"Infinite lives\"\ncheat0_code = \"1234:09\"")
});
using var catalog = new GameCheatCatalog(directory, new HttpClient(handler));
var result = await catalog.FindAsync("Fixture Game", "nes", CancellationToken.None);
Assert.True(result.Available);
Assert.Equal(GameCheatCatalog.Version, result.Version);
Assert.Equal(new GameCheat("Infinite lives", "1234:09"), Assert.Single(result.Cheats));
Assert.False((await catalog.FindAsync("Missing Game", "nes", CancellationToken.None)).Available);
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(response(request));
}
}
@@ -0,0 +1,156 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class GameDataVaultFileStoreTests
{
[Fact]
public async Task OpaqueRevisionIsHashedIdempotentDownloadableAndDeletable()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var gameId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var revisionId = Guid.NewGuid();
var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE\0slot=1\0progress=42");
var writes = await Task.WhenAll(
store.SaveAsync(gameId, entryId, revisionId, "slot-1.sav", new MemoryStream(payload),
payload.Length, "integration-test", CancellationToken.None),
store.SaveAsync(gameId, entryId, revisionId, "slot-1.sav", new MemoryStream(payload),
payload.Length, "integration-test", CancellationToken.None));
var revision = writes[0];
var retried = writes[1];
Assert.Equal(revision.Sha256, retried.Sha256);
Assert.Equal(64, revision.Sha256.Length);
Assert.Single(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories));
var opened = Assert.IsType<StoredGameDataRevision>(await store.OpenAsync(revision, CancellationToken.None));
await using (opened.Content)
{
using var copy = new MemoryStream();
await opened.Content.CopyToAsync(copy);
Assert.Equal(payload, copy.ToArray());
}
var conflictingPayload = Enumerable.Repeat((byte)0x5A, payload.Length).ToArray();
await Assert.ThrowsAsync<ResourceConflictException>(() => store.SaveAsync(gameId, entryId,
revisionId, "slot-1.sav", new MemoryStream(conflictingPayload), conflictingPayload.Length,
"integration-test", CancellationToken.None));
Assert.True(store.Delete(revision));
Assert.Null(await store.OpenAsync(revision, CancellationToken.None));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Theory]
[InlineData("MZsynthetic")]
[InlineData("#!/bin/sh")]
public async Task ExecutableAndScriptSignaturesAreRejected(string value)
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var payload = Encoding.ASCII.GetBytes(value);
await Assert.ThrowsAsync<GameDataVaultValidationException>(() => store.SaveAsync(Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(payload), payload.Length,
"integration-test", CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task SameLengthCorruptionFailsClosedOnOpen()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE-CONTENT");
var revision = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav",
new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None);
var path = Path.Combine(directory, revision.Location.Replace('/', Path.DirectorySeparatorChar));
await File.WriteAllBytesAsync(path, Enumerable.Repeat((byte)0x41, payload.Length).ToArray());
await Assert.ThrowsAsync<GameDataIntegrityException>(() => store.OpenAsync(revision, CancellationToken.None));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task DeclaredOversizeAndNativeExecutableSignaturesAreRejected()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
await Assert.ThrowsAsync<GameDataVaultValidationException>(() => store.SaveAsync(Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream([0x01]),
GameDataVaultFileStore.MaximumBytes + 1, "integration-test", CancellationToken.None));
byte[][] signatures =
[
[0x7F, 0x45, 0x4C, 0x46, 0x00],
[0xFE, 0xED, 0xFA, 0xCF, 0x00],
[0xCA, 0xFE, 0xBA, 0xBE, 0x00]
];
foreach (var signature in signatures)
await Assert.ThrowsAsync<GameDataVaultValidationException>(() => store.SaveAsync(Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(signature), signature.Length,
"integration-test", CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task ReconciliationRemovesOnlyAppOwnedIncompleteAndOrphanRevisionFiles()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE-CONTENT");
var revision = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav",
new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None);
var orphan = Path.Combine(directory, "orphan", "revision.bin");
var uploading = Path.Combine(directory, "orphan", "revision.uploading");
var unrelated = Path.Combine(directory, "orphan", "operator-note.txt");
Directory.CreateDirectory(Path.GetDirectoryName(orphan)!);
await File.WriteAllBytesAsync(orphan, [1, 2, 3]);
await File.WriteAllBytesAsync(uploading, [4, 5]);
await File.WriteAllTextAsync(unrelated, "retain");
var result = store.Reconcile(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { revision.Location });
Assert.Equal(1, result.RemovedUploads);
Assert.Equal(1, result.RemovedOrphans);
Assert.Equal(5, result.ReclaimedBytes);
Assert.True(File.Exists(Path.Combine(directory, revision.Location.Replace('/', Path.DirectorySeparatorChar))));
Assert.True(File.Exists(unrelated));
Assert.False(File.Exists(orphan));
Assert.False(File.Exists(uploading));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
}
@@ -0,0 +1,121 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// The library browser's query, executed by the real database.
/// </summary>
/// <remarks>
/// The library used to sort the forty-eight games already on screen, which reordered a page instead
/// of the collection, and it could not filter on the favourites, status, ratings or tags it lets an
/// operator set. Both are properties of the query, so both are proven here against PostgreSQL.
/// </remarks>
public sealed class GameLibraryQueryTests
{
[Fact]
[Trait("Category", "Container")]
public async Task LibraryFiltersAndOrderingAreAnsweredByTheDatabase()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_query_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Query fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-query-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var alpha = await store.CreateGameAsync("Alpha Adventure", "integration-test", CancellationToken.None);
var bravo = await store.CreateGameAsync("Bravo Blast", "integration-test", CancellationToken.None);
var charlie = await store.CreateGameAsync("Charlie Chase", "integration-test", CancellationToken.None);
await store.SaveGameUserStateAsync(alpha.Id,
new(true, GamePlayStatus.Playing, 5, null, 40m, null, 3, DateTimeOffset.UtcNow.AddDays(-1)),
0, "integration-test", CancellationToken.None);
await store.SaveGameUserStateAsync(bravo.Id,
new(false, GamePlayStatus.Completed, 3, null, 100m, null, 9, DateTimeOffset.UtcNow),
0, "integration-test", CancellationToken.None);
await store.AddGameTagAsync(charlie.Id, "Co-op", "integration-test", CancellationToken.None);
// --- filters ---------------------------------------------------------------------------
var favourites = await store.SearchGamesAsync(new GameQuery { Favorite = true }, CancellationToken.None);
Assert.Equal("Alpha Adventure", Assert.Single(favourites.Items).Title);
// The total describes the filtered population, not the whole catalog.
Assert.Equal(1, favourites.Total);
Assert.Equal(3, (await store.SearchGamesAsync(new GameQuery(), CancellationToken.None)).Total);
Assert.Equal("Alpha Adventure", Assert.Single((await store.SearchGamesAsync(
new GameQuery { Status = GamePlayStatus.Playing }, CancellationToken.None)).Items).Title);
Assert.Equal("Bravo Blast", Assert.Single((await store.SearchGamesAsync(
new GameQuery { Status = GamePlayStatus.Completed }, CancellationToken.None)).Items).Title);
Assert.Equal("Alpha Adventure", Assert.Single((await store.SearchGamesAsync(
new GameQuery { MinimumRating = 4 }, CancellationToken.None)).Items).Title);
Assert.Equal(2, (await store.SearchGamesAsync(new GameQuery { MinimumRating = 3 }, CancellationToken.None)).Total);
Assert.Equal("Charlie Chase", Assert.Single((await store.SearchGamesAsync(
new GameQuery { Tag = "Co-op" }, CancellationToken.None)).Items).Title);
var collection = await store.CreateCollectionAsync(new("Weekend picks", null, CollectionKind.Static, null, false),
"integration-test", CancellationToken.None);
await store.AddCollectionGameAsync(collection.Id, bravo.Id, "integration-test", CancellationToken.None);
Assert.Equal("Bravo Blast", Assert.Single((await store.SearchGamesAsync(
new GameQuery { CollectionId = collection.Id }, CancellationToken.None)).Items).Title);
// --- ordering --------------------------------------------------------------------------
var byRating = await store.SearchGamesAsync(new GameQuery { Sort = "rating" }, CancellationToken.None);
Assert.Equal(["Alpha Adventure", "Bravo Blast", "Charlie Chase"], byRating.Items.Select(item => item.Title));
var byPlayed = await store.SearchGamesAsync(new GameQuery { Sort = "played" }, CancellationToken.None);
Assert.Equal(["Bravo Blast", "Alpha Adventure", "Charlie Chase"], byPlayed.Items.Select(item => item.Title));
// A page boundary cannot change the answer, which page-local sorting could not guarantee.
var firstPage = await store.SearchGamesAsync(new GameQuery { Sort = "title", PageSize = 1 }, CancellationToken.None);
var secondPage = await store.SearchGamesAsync(new GameQuery { Sort = "title", PageSize = 1, Page = 2 }, CancellationToken.None);
Assert.Equal("Alpha Adventure", Assert.Single(firstPage.Items).Title);
Assert.Equal("Bravo Blast", Assert.Single(secondPage.Items).Title);
Assert.Equal(3, firstPage.Total);
// --- playable copies -------------------------------------------------------------------
Assert.Empty((await store.SearchGamesAsync(new GameQuery { PlayableOnly = true }, CancellationToken.None)).Items);
await LinkArtifactAsync(store, dataSource, library.Id, charlie.Id, "roms/snes/Charlie Chase.sfc", "snes");
await LinkArtifactAsync(store, dataSource, library.Id, bravo.Id, "roms/snes/Bravo Blast.txt", "snes");
// Only the allowlisted platform-and-container pair counts as a playable copy.
Assert.Equal("Charlie Chase", Assert.Single((await store.SearchGamesAsync(
new GameQuery { PlayableOnly = true }, CancellationToken.None)).Items).Title);
// --- scoped releases -------------------------------------------------------------------
await store.CreateReleaseAsync(alpha.Id, "Alpha Adventure", "snes", null, null, "integration-test", CancellationToken.None);
await store.CreateReleaseAsync(bravo.Id, "Bravo Blast", "snes", null, null, "integration-test", CancellationToken.None);
Assert.Equal("Alpha Adventure", Assert.Single(
await store.ListReleasesForGamesAsync([alpha.Id], CancellationToken.None)).Title);
Assert.Empty(await store.ListReleasesForGamesAsync([], CancellationToken.None));
Assert.Equal(2, (await store.ListReleasesAsync(null, CancellationToken.None)).Count);
}
private static async Task LinkArtifactAsync(PostgresStore store, NpgsqlDataSource dataSource,
Guid libraryId, Guid gameId, string relativePath, string platform)
{
var artifact = new Artifact(Guid.NewGuid(), libraryId, relativePath, 4 * 1024 * 1024,
DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, platform);
await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None);
await using var link = dataSource.CreateCommand("""
INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at)
VALUES($1,$2,'integration-fixture',now())
""");
link.Parameters.AddWithValue(gameId);
link.Parameters.AddWithValue(artifact.Id);
await link.ExecuteNonQueryAsync();
}
}
@@ -0,0 +1,50 @@
using System.Text;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class GameMediaStoreTests
{
[Fact]
public async Task AppOwnedManualIsBoundedHashedAndDeletable()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-media-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameMediaStore(directory);
var bytes = Encoding.ASCII.GetBytes("%PDF-1.7\n% deterministic synthetic fixture\n");
var media = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), GameMediaKind.Manual,
"Synthetic manual", new MemoryStream(bytes), bytes.Length, CancellationToken.None);
Assert.Equal("application/pdf", media.ContentType);
Assert.True(media.AppOwned);
Assert.Equal(64, media.Sha256?.Length);
var opened = Assert.IsType<StoredGameMedia>(await store.OpenAsync(media, CancellationToken.None));
await using (opened.Content) Assert.Equal(bytes.Length, opened.Length);
Assert.True(store.Delete(media));
Assert.Null(await store.OpenAsync(media, CancellationToken.None));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task ExecutableBytesAreNeverAcceptedAsMedia()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-media-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameMediaStore(directory);
var executable = new byte[] { 0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00 };
await Assert.ThrowsAsync<MediaValidationException>(() => store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(),
GameMediaKind.Manual, "Do not execute", new MemoryStream(executable), executable.Length, CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="SSH.NET" Version="2026.0.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Ludarium.Infrastructure\Ludarium.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Ludarium.Api\Ludarium.Api.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\fixtures\providers\**\*" Link="fixtures\providers\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using System.IO.Compression;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class N64ZipRomTests
{
[Theory]
[InlineData("game.z64", "80371240")]
[InlineData("game.v64", "37804012")]
[InlineData("game.n64", "40123780")]
public async Task SingleBoundedRomWithVerifiedMagicStreamsWithoutExtraction(string name, string magic)
{
await using var archive = Zip((name, Convert.FromHexString(magic + "01020304")));
Assert.True(await N64ZipRom.ValidateAsync(archive, CancellationToken.None));
archive.Position = 0;
var opened = await N64ZipRom.OpenAsync(archive, CancellationToken.None);
await using var content = opened.Content;
var bytes = new byte[8];
await content.ReadExactlyAsync(bytes);
Assert.Equal(name, opened.FileName);
Assert.Equal(8, opened.Length);
Assert.Equal(Convert.FromHexString(magic), bytes[..4]);
Assert.False(content.CanSeek);
}
[Fact]
public async Task MultipleRomEntriesAndInvalidMagicFailClosed()
{
await using var multiple = Zip(("one.z64", Convert.FromHexString("8037124001")),
("two.v64", Convert.FromHexString("3780401202")));
await using var invalid = Zip(("game.z64", Convert.FromHexString("0000000001")));
Assert.False(await N64ZipRom.ValidateAsync(multiple, CancellationToken.None));
Assert.False(await N64ZipRom.ValidateAsync(invalid, CancellationToken.None));
}
private static MemoryStream Zip(params (string Name, byte[] Content)[] entries)
{
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
foreach (var item in entries)
{
var entry = archive.CreateEntry(item.Name, CompressionLevel.SmallestSize);
using var content = entry.Open();
content.Write(item.Content);
}
stream.Position = 0;
return stream;
}
}
@@ -0,0 +1,213 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using System.Reflection;
namespace Ludarium.IntegrationTests;
public sealed class NativeBrowserPlayServiceTests
{
[Fact]
public async Task ExactReadOnlyAllowlistedMappingStartsNativeSessionAndCancelsIt()
{
var gameId = Guid.NewGuid();
var store = new MemoryPlayStore(Candidate(gameId));
var service = Service(store);
var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None);
var launch = await service.StartAsync(gameId, "tester", CancellationToken.None);
Assert.True(capability.Available);
Assert.Equal("Ludarium EmulatorJS", capability.Emulator);
Assert.StartsWith("/player.html?session=", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.Contains("#token=", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.DoesNotContain("firmware=", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.DoesNotContain(launch.Token, store.Sessions[launch.Session.Id].LaunchUrl, StringComparison.Ordinal);
Assert.Equal(64, launch.Token.Length);
Assert.Null(await service.AuthorizeAsync(launch.Session.Id, "wrong-token", CancellationToken.None));
Assert.Equal(launch.Session.Id, (await service.AuthorizeAsync(launch.Session.Id, launch.Token,
CancellationToken.None))?.Id);
await service.CancelAsync(launch.Session.Id, "tester", CancellationToken.None);
Assert.Equal(PlaySessionState.Cancelled, store.Sessions[launch.Session.Id].State);
}
[Fact]
public async Task SelectedPersonalPsxBiosIsAdvertisedWithoutExposingItsIdentity()
{
var gameId = Guid.NewGuid();
var store = new MemoryPlayStore(Candidate(gameId, "psx", "roms/psx/synthetic.chd"));
var firmware = new MemoryFirmwareStore(new FirmwareAsset(Guid.NewGuid(), "psx", "bios",
FirmwareAssetKind.Bios, "personal.bin", 512, new string('f', 64), "private/location", true,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
var service = new NativeBrowserPlayService(store, null!, null!, true, firmwareStore: firmware);
var launch = await service.StartAsync(gameId, "tester", CancellationToken.None);
Assert.Contains("&firmware=1#token=", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.DoesNotContain("personal.bin", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.DoesNotContain("private/location", launch.Session.LaunchUrl, StringComparison.Ordinal);
}
[Fact]
public async Task Ps2UsesTheNativePlayJsPageWithoutDeliveringTheStoredBios()
{
var gameId = Guid.NewGuid();
var store = new MemoryPlayStore(Candidate(gameId, "ps2", "roms/ps2/synthetic.iso"));
var firmware = new MemoryFirmwareStore(new FirmwareAsset(Guid.NewGuid(), "ps2", "bios",
FirmwareAssetKind.Bios, "personal.bin", 512, new string('f', 64), "private/location", true,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
var service = new NativeBrowserPlayService(store, null!, null!, true, firmwareStore: firmware);
var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None);
var launch = await service.StartAsync(gameId, "tester", CancellationToken.None);
Assert.True(capability.Available);
Assert.Equal("Ludarium Play!.js", capability.Emulator);
Assert.Equal("playjs", capability.Core);
Assert.False(capability.AutomaticRestore);
Assert.StartsWith("/ps2-player.html?session=", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.DoesNotContain("firmware=", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.DoesNotContain("personal.bin", launch.Session.LaunchUrl, StringComparison.Ordinal);
}
[Fact]
public async Task N64UsesItsDedicatedWasmPlayerAndVerifiedRestoreContract()
{
var gameId = Guid.NewGuid();
var store = new MemoryPlayStore(Candidate(gameId, "n64", "roms/n64/synthetic.v64"));
var service = new NativeBrowserPlayService(store, null!, null!, true);
var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None);
var launch = await service.StartAsync(gameId, "tester", CancellationToken.None);
Assert.True(capability.Available);
Assert.Equal("Ludarium N64Wasm", capability.Emulator);
Assert.Equal("n64wasm", capability.Core);
Assert.True(capability.AutomaticRestore);
Assert.StartsWith("/n64-player.html?session=", launch.Session.LaunchUrl, StringComparison.Ordinal);
Assert.Contains("&core=n64wasm", launch.Session.LaunchUrl, StringComparison.Ordinal);
}
[Fact]
public async Task MissingGameAndMissingBundledRuntimeFailClosed()
{
var missing = await Service(new MemoryPlayStore(null)).GetCapabilityAsync(Guid.NewGuid(), CancellationToken.None);
var gameId = Guid.NewGuid();
var unavailable = await new NativeBrowserPlayService(new MemoryPlayStore(Candidate(gameId)), null!, null!, false)
.GetCapabilityAsync(gameId, CancellationToken.None);
Assert.Equal(BrowserPlayState.MissingRom, missing.State);
Assert.Equal(BrowserPlayState.EmulatorUnavailable, unavailable.State);
}
[Fact]
public async Task MissingScanDerivedN64ZipFailsClosedWithoutBreakingCapabilityChecks()
{
var rootPath = Path.Combine(Path.GetTempPath(), "ludarium-browser-play-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(rootPath);
try
{
var gameId = Guid.NewGuid();
var candidate = Candidate(gameId, "n64", "roms/n64/missing.zip");
var library = new LibraryRoot(Guid.NewGuid(), "Read-only games", rootPath, LibraryKind.Rom, true,
HashPolicy.Sha256, false, 1, true, true, true, true);
var artifact = new Artifact(candidate.ArtifactId, library.Id, candidate.RelativePath, candidate.Size,
DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Rom, Confidence.High, null,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, "n64");
var catalog = DispatchProxy.Create<ILudariumStore, ArtifactDetailsStoreProxy>();
((ArtifactDetailsStoreProxy)(object)catalog).Details = new(artifact, library, [], [], [], [], []);
var service = new NativeBrowserPlayService(new MemoryPlayStore(candidate), catalog,
new ReadOnlyLibraryFileSystem(), true);
var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None);
var startError = await Assert.ThrowsAsync<ResourceConflictException>(() =>
service.StartAsync(gameId, "tester", CancellationToken.None));
Assert.False(capability.Available);
Assert.Equal(BrowserPlayState.MissingRom, capability.State);
Assert.Contains("no longer present or readable", capability.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("no longer present or readable", startError.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
Directory.Delete(rootPath, true);
}
}
[Fact]
public async Task ConcurrentSessionLimitFailsClosed()
{
var gameId = Guid.NewGuid();
var store = new MemoryPlayStore(Candidate(gameId));
var now = DateTimeOffset.UtcNow;
for (var index = 0; index < 2; index++)
{
var session = new BrowserPlaySession(Guid.NewGuid(), gameId, Guid.NewGuid(), PlaySessionState.Active,
"nes", "Ludarium EmulatorJS", "fceumm", "/player.html", "redacted", now, now.AddMinutes(30));
store.Sessions[session.Id] = session;
}
var error = await Assert.ThrowsAsync<ResourceConflictException>(() =>
Service(store).StartAsync(gameId, "tester", CancellationToken.None));
Assert.Contains("capacity", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(2, store.Sessions.Count);
}
private static NativeBrowserPlayService Service(MemoryPlayStore store) => new(store, null!, null!, true);
private static BrowserPlayCandidate Candidate(Guid gameId, string platform = "nes",
string relativePath = "roms/nes/synthetic.nes") =>
new(gameId, Guid.NewGuid(), "Synthetic", platform, relativePath, 16, null, true);
private sealed class MemoryFirmwareStore(FirmwareAsset? selected) : IFirmwareStore
{
public Task<IReadOnlyList<FirmwareAsset>> ListFirmwareAssetsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<FirmwareAsset>>(selected is null ? [] : [selected]);
public Task<FirmwareAsset?> GetFirmwareAssetAsync(Guid id, CancellationToken cancellationToken) =>
Task.FromResult(selected?.Id == id ? selected : null);
public Task<FirmwareAsset?> GetSelectedFirmwareAssetAsync(string platform, string slot,
CancellationToken cancellationToken) => Task.FromResult(selected is not null && selected.Selected &&
selected.Platform.Equals(platform, StringComparison.OrdinalIgnoreCase) &&
selected.Slot.Equals(slot, StringComparison.OrdinalIgnoreCase) ? selected : null);
public Task SaveFirmwareAssetAsync(FirmwareAsset asset, string actor, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task<IReadOnlyList<FirmwareAsset>> DeleteFirmwareAssetAsync(Guid id, long expectedVersion,
string actor, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<FirmwareAsset>>([]);
}
public class ArtifactDetailsStoreProxy : DispatchProxy
{
public ArtifactDetails? Details { get; set; }
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => targetMethod?.Name switch
{
nameof(ILudariumStore.GetArtifactDetailsAsync) => Task.FromResult(Details),
_ => throw new NotSupportedException(targetMethod?.Name)
};
}
private sealed class MemoryPlayStore(BrowserPlayCandidate? candidate) : IBrowserPlayStore
{
public Dictionary<Guid, BrowserPlaySession> Sessions { get; } = [];
public Task<BrowserPlayCandidate?> GetBrowserPlayCandidateAsync(Guid gameId, CancellationToken cancellationToken) =>
Task.FromResult(candidate?.GameId == gameId ? candidate : null);
public Task<int> CountActiveBrowserPlaySessionsAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
Task.FromResult(Sessions.Values.Count(session => session.ExpiresAt > now && session.State is
(PlaySessionState.Starting or PlaySessionState.Ready or PlaySessionState.Active)));
public Task SaveBrowserPlaySessionAsync(BrowserPlaySession session, string actor, CancellationToken cancellationToken)
{
Sessions[session.Id] = session;
return Task.CompletedTask;
}
public Task<BrowserPlaySession?> GetBrowserPlaySessionAsync(Guid sessionId, CancellationToken cancellationToken) =>
Task.FromResult(Sessions.GetValueOrDefault(sessionId));
public Task EndBrowserPlaySessionAsync(Guid sessionId, PlaySessionState state, string actor,
string? errorCode, string? errorMessage, CancellationToken cancellationToken)
{
if (Sessions.TryGetValue(sessionId, out var session)) Sessions[sessionId] = session with
{ State = state, ErrorCode = errorCode, ErrorMessage = errorMessage, EndedAt = DateTimeOffset.UtcNow };
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,56 @@
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class NativeProviderFixtureTests
{
private static readonly Game Sonic = new(Guid.Parse("d2e86fb8-2184-4d2d-bce7-cb3156f0b9b2"),
"Sonic the Hedgehog", DateTimeOffset.UnixEpoch);
private static readonly Release Genesis = new(Guid.Parse("dc2ad40a-0d30-4eeb-8f32-25a273c14833"),
Sonic.Id, Sonic.Title, "genesis", "world", null, DateTimeOffset.UnixEpoch);
[Fact]
public void IgdbFixtureProducesProvenancedMetadataAndArtwork()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
IgdbMetadataProvider.Parse(Read("igdb-game.json"), Sonic, Genesis));
Assert.Equal("1074", result.ExternalId);
Assert.Contains(result.Fields, item => item.Field == "developer" && item.Value == "Sonic Team");
Assert.Contains(result.Artwork, item => item.Url.StartsWith("https://images.igdb.com/", StringComparison.Ordinal));
}
[Fact]
public void MobyGamesFixtureRequiresAnExactTitleAndReturnsHttpsArtwork()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
MobyGamesMetadataProvider.Parse(Read("mobygames-game.json"), Sonic, Genesis));
Assert.Equal("6225", result.ExternalId);
Assert.All(result.Artwork, item => Assert.StartsWith("https://", item.Url, StringComparison.Ordinal));
Assert.Null(MobyGamesMetadataProvider.Parse(Read("mobygames-game.json"),
Sonic with { Title = "Sonic 2" }, Genesis));
}
[Fact]
public void ScreenScraperFixtureIsParsedWithoutExternalEntities()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
ScreenScraperMetadataProvider.Parse(Read("screenscraper-game.xml"), Sonic, Genesis));
Assert.Equal("1", result.ExternalId);
Assert.Contains(result.Fields, item => item.Field == "players" && item.Value == "2");
}
[Fact]
public void RetroAchievementsFixturePreservesUnlockStateAndDefinitions()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
RetroAchievementsMetadataProvider.ParseDetails(Read("retroachievements-game.json"), Sonic, Genesis, 1));
var progress = Assert.IsType<GameAchievementProgress>(result.AchievementProgress);
Assert.Equal(2, progress.Total);
Assert.Equal(1, progress.Earned);
Assert.Single(progress.Achievements, item => item.Unlocked);
}
private static string Read(string name) => File.ReadAllText(Path.Combine(AppContext.BaseDirectory,
"fixtures", "providers", name));
}
@@ -0,0 +1,619 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
public sealed class PostgresStoreTests
{
[Fact]
[Trait("Category", "Container")]
public async Task MigrationsPersistenceIdempotencyAndOptimisticConcurrencyWorkAgainstPostgres()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
await store.InitializeAsync(CancellationToken.None);
Assert.Equal(PostgresStore.CurrentSchemaVersion, await store.GetSchemaVersionAsync(CancellationToken.None));
var emptySnapshot = await Assert.ThrowsAsync<SnapshotPreconditionException>(() =>
store.CreateSnapshotAsync("empty baseline", CancellationToken.None));
Assert.Contains("at least one present file with a SHA-256 hash", emptySnapshot.Message);
Assert.Empty(await store.ListSnapshotsAsync(CancellationToken.None));
await using (var versions = dataSource.CreateCommand("SELECT array_agg(version ORDER BY version) FROM schema_versions"))
{
var appliedVersions = Assert.IsType<int[]>(await versions.ExecuteScalarAsync());
Assert.Collection(appliedVersions,
value => Assert.Equal(1, value), value => Assert.Equal(2, value),
value => Assert.Equal(3, value), value => Assert.Equal(4, value),
value => Assert.Equal(5, value), value => Assert.Equal(6, value),
value => Assert.Equal(7, value), value => Assert.Equal(8, value),
value => Assert.Equal(9, value), value => Assert.Equal(10, value),
value => Assert.Equal(11, value), value => Assert.Equal(12, value),
value => Assert.Equal(13, value), value => Assert.Equal(14, value),
value => Assert.Equal(15, value), value => Assert.Equal(16, value),
value => Assert.Equal(17, value), value => Assert.Equal(18, value),
value => Assert.Equal(19, value), value => Assert.Equal(20, value),
value => Assert.Equal(21, value), value => Assert.Equal(22, value),
value => Assert.Equal(23, value), value => Assert.Equal(24, value),
value => Assert.Equal(25, value), value => Assert.Equal(26, value),
value => Assert.Equal(27, value));
}
var firmware = new FirmwareAsset(Guid.NewGuid(), "psx", "bios", FirmwareAssetKind.Bios,
"personal.bin", 512, new string('f', 64), "psx/bios/personal.bin", true,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
await store.SaveFirmwareAssetAsync(firmware, "tester", CancellationToken.None);
Assert.Equal(firmware.Id, (await store.GetSelectedFirmwareAssetAsync("psx", "bios", CancellationToken.None))?.Id);
Assert.Contains(await store.ListFirmwareAssetsAsync(CancellationToken.None), item => item.Sha256 == firmware.Sha256);
var removedFirmware = await store.DeleteFirmwareAssetAsync(firmware.Id, firmware.Version, "tester", CancellationToken.None);
Assert.Single(removedFirmware);
Assert.Null(await store.GetSelectedFirmwareAssetAsync("psx", "bios", CancellationToken.None));
var library = LibraryRoot.Create("Synthetic", Path.GetFullPath(Path.Combine(Path.GetTempPath(),
"ludarium-integration-library")), LibraryKind.Mixed) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var firstScan = await store.CreateScanAsync(library.Id, ScanMode.Deep, "stable-request", CancellationToken.None);
var repeatedScan = await store.CreateScanAsync(library.Id, ScanMode.Deep, "stable-request", CancellationToken.None);
Assert.Equal(firstScan.Id, repeatedScan.Id);
var content = new ContentBlob(Guid.NewGuid(), 4, new string('a', 64), new string('b', 40), "1234abcd", DateTimeOffset.UtcNow);
var artifact = new Artifact(Guid.NewGuid(), library.Id, "roms/nes/Synthetic.nes", 4, DateTimeOffset.UtcNow,
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, content.Id,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "iNES", "nes", Sha256: content.Sha256);
var evidence = new Evidence("signature", "iNES", "test", "1", Confidence.Deterministic, DateTimeOffset.UtcNow);
await store.UpsertArtifactAsync(artifact, content, [evidence], CancellationToken.None);
await store.UpsertArtifactAsync(artifact with { Version = 2 }, content, [evidence], CancellationToken.None);
var persisted = await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None);
Assert.NotNull(persisted);
Assert.Single(persisted.Evidence);
Assert.Equal(content.Sha256, persisted.Artifact.Sha256);
var integritySnapshot = await store.CreateSnapshotAsync("hashed baseline", CancellationToken.None);
Assert.Single(integritySnapshot.Items, item => item.ArtifactId == artifact.Id && item.Sha256 == content.Sha256);
Assert.Contains(await store.ListSnapshotsAsync(CancellationToken.None), item => item.Id == integritySnapshot.Id);
var dolphinGameId = Guid.NewGuid();
var dolphinArtifact = artifact with
{
Id = Guid.NewGuid(),
RelativePath = "roms/wii/Synthetic Fixture.rvz",
Size = 2 * 1024 * 1024,
Platform = "wii",
Signature = "RVZ",
ContentBlobId = null,
Sha256 = null,
Version = 1
};
await store.UpsertArtifactAsync(dolphinArtifact, null, [new Evidence("extension", "RVZ", "test", "1",
Confidence.Deterministic, DateTimeOffset.UtcNow)], CancellationToken.None);
// A parameterised command carries exactly one statement: PostgreSQL rejects several in the
// extended query protocol, so the fixture rows are inserted separately.
await using (var insertGame = dataSource.CreateCommand("""
INSERT INTO games(id,title,version,data)
VALUES($1,$2,1,jsonb_build_object('id',$1,'title',$2,'createdAt',now(),'version',1))
"""))
{
insertGame.Parameters.AddWithValue(dolphinGameId);
insertGame.Parameters.AddWithValue("Synthetic Dolphin Fixture");
await insertGame.ExecuteNonQueryAsync();
}
await using (var link = dataSource.CreateCommand("""
INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at)
VALUES($1,$2,'integration-fixture',now())
"""))
{
link.Parameters.AddWithValue(dolphinGameId);
link.Parameters.AddWithValue(dolphinArtifact.Id);
await link.ExecuteNonQueryAsync();
}
var dolphinCandidate = Assert.Single(await store.ListDolphinLaunchCandidatesAsync(dolphinGameId, "wii",
CancellationToken.None));
Assert.Equal(dolphinArtifact.RelativePath, dolphinCandidate.RelativePath);
Assert.True(dolphinCandidate.SourceReadOnly);
Assert.Empty(await store.ListDolphinLaunchCandidatesAsync(dolphinGameId, "xbox", CancellationToken.None));
var titledArtifact = artifact with
{
Id = Guid.NewGuid(),
RelativePath = "roms/nes/Super.Game.64 (USA).nes",
ContentBlobId = null,
Sha256 = null,
Version = 1
};
await store.UpsertArtifactAsync(titledArtifact, null, [evidence], CancellationToken.None);
var legacyGameId = Guid.NewGuid();
await using (var legacy = dataSource.CreateCommand("INSERT INTO games(id,title,version,data) VALUES($1,$2,1,jsonb_build_object('id',$1,'title',$2,'createdAt',now(),'version',1))"))
{
legacy.Parameters.AddWithValue(legacyGameId);
legacy.Parameters.AddWithValue("0002 - Legacy Racer (USA) zip");
await legacy.ExecuteNonQueryAsync();
}
await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None);
var canonicalGame = Assert.Single((await store.SearchGamesAsync(new GameQuery { Search = "Super Game 64", Platform = "nes", Page = 1, PageSize = 25 }, CancellationToken.None)).Items);
Assert.Equal("Super Game 64", canonicalGame.Title);
Assert.Equal(GameOrigin.Scan, canonicalGame.Origin);
await Assert.ThrowsAsync<ResourceConflictException>(() => store.DeleteGameAsync(canonicalGame.Id,
canonicalGame.Version, "integration-test", CancellationToken.None));
var browserCandidate = Assert.IsType<BrowserPlayCandidate>(
await store.GetBrowserPlayCandidateAsync(canonicalGame.Id, CancellationToken.None));
Assert.Equal(titledArtifact.Id, browserCandidate.ArtifactId);
Assert.True(browserCandidate.SourceReadOnly);
var playSession = new BrowserPlaySession(Guid.NewGuid(), canonicalGame.Id, titledArtifact.Id,
PlaySessionState.Ready, "nes", "Ludarium EmulatorJS", "fceumm", "/player.html",
new string('d', 64), DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(30));
await store.SaveBrowserPlaySessionAsync(playSession, "integration-test", CancellationToken.None);
Assert.Equal(1, await store.CountActiveBrowserPlaySessionsAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Equal(playSession.Id, (await store.GetBrowserPlaySessionAsync(playSession.Id, CancellationToken.None))?.Id);
await store.EndBrowserPlaySessionAsync(playSession.Id, PlaySessionState.Cancelled, "integration-test",
"cancelled", "Synthetic cancellation.", CancellationToken.None);
Assert.Equal(PlaySessionState.Cancelled,
(await store.GetBrowserPlaySessionAsync(playSession.Id, CancellationToken.None))?.State);
await using (var playAudit = dataSource.CreateCommand(
"SELECT string_agg(data::text,' ') FROM audit_events WHERE data->>'action' LIKE 'BrowserPlay%'"))
{
var redactedAudit = Assert.IsType<string>(await playAudit.ExecuteScalarAsync());
Assert.DoesNotContain(titledArtifact.RelativePath, redactedAudit, StringComparison.Ordinal);
Assert.DoesNotContain(playSession.TokenHash, redactedAudit, StringComparison.Ordinal);
}
Assert.Equal("Legacy Racer", Assert.Single((await store.SearchGamesAsync(new GameQuery { Search = "Legacy Racer", Page = 1, PageSize = 25 }, CancellationToken.None)).Items).Title);
await store.UpsertArtifactAsync(titledArtifact with { Id = Guid.NewGuid(), RelativePath = "roms/nes/Super_Game_64 (USA).nes" }, null, [evidence], CancellationToken.None);
await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None);
Assert.Null(await store.GetBrowserPlayCandidateAsync(canonicalGame.Id, CancellationToken.None));
Assert.Single(await store.ListReleasesAsync(canonicalGame.Id, CancellationToken.None));
var libraryHealth = Assert.Single(await store.ListLibraryHealthAsync(CancellationToken.None), item => item.LibraryId == library.Id);
Assert.True(libraryHealth.Artifacts >= 2);
// The canonical NES game, the legacy racer and the linked Dolphin fixture all live in this library.
Assert.Equal(3, libraryHealth.Games);
Assert.Contains("nes", libraryHealth.DetectedPlatforms);
var scanRelease = Assert.Single(await store.ListReleasesAsync(canonicalGame.Id, CancellationToken.None));
Assert.Equal(ReleaseOrigin.Scan, scanRelease.Origin);
await Assert.ThrowsAsync<ResourceConflictException>(() => store.UpdateReleaseAsync(scanRelease.Id,
"Unsafe override", "nes", null, null, scanRelease.Version, "integration-test", CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(() => store.DeleteReleaseAsync(scanRelease.Id,
scanRelease.Version, "integration-test", CancellationToken.None));
var artwork = titledArtifact with
{
Id = Guid.NewGuid(),
RelativePath = "roms/nes/Super Game 64 (USA)/imgs/covers/cover1.jpg",
MediaType = MediaType.SupportFile,
State = ArtifactState.Ignored
};
await store.UpsertArtifactAsync(artwork, null, [], CancellationToken.None);
await store.MarkArtifactIgnoredAsync(artwork.Id, 2, evidence, CancellationToken.None);
Assert.Equal(artwork.Id, (await store.GetGameArtworkAsync(canonicalGame.Id, CancellationToken.None))?.Artifact.Id);
var game = await store.CreateGameAsync("Synthetic Game", "integration-test", CancellationToken.None);
Assert.Equal(GameOrigin.Manual, game.Origin);
Assert.Equal(game.Id, (await store.GetGameAsync(game.Id, CancellationToken.None))?.Id);
Assert.Null(await store.GetGameAsync(Guid.NewGuid(), CancellationToken.None));
var updated = await store.UpdateGameAsync(game.Id, "Synthetic Game Updated", game.Version, "integration-test", CancellationToken.None);
var gamePage = await store.SearchGamesAsync(new GameQuery { Search = "Updated", Page = 1, PageSize = 25 }, CancellationToken.None);
Assert.Equal(1, gamePage.Total);
Assert.Equal(updated.Id, Assert.Single(gamePage.Items).Id);
await Assert.ThrowsAsync<ConcurrencyException>(() =>
store.UpdateGameAsync(game.Id, "Stale Update", game.Version, "integration-test", CancellationToken.None));
var release = await store.CreateReleaseAsync(game.Id, "Synthetic Release", "nes", "world", null, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReleasesAsync(game.Id, CancellationToken.None), item => item.Id == release.Id);
Assert.Equal(ReleaseOrigin.Manual, release.Origin);
await using (var linkPlayableGame = dataSource.CreateCommand("""
INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at)
VALUES($1,$2,'integration-fixture',now()) ON CONFLICT DO NOTHING
"""))
{
linkPlayableGame.Parameters.AddWithValue(game.Id);
linkPlayableGame.Parameters.AddWithValue(artifact.Id);
await linkPlayableGame.ExecuteNonQueryAsync();
}
var unsupportedGame = await store.CreateGameAsync("Synthetic Future Console Game", "integration-test", CancellationToken.None);
await store.CreateReleaseAsync(unsupportedGame.Id, "Synthetic Future Console Release", "future-console", null, null,
"integration-test", CancellationToken.None);
var playability = await store.GetPlayabilitySummaryAsync(CancellationToken.None);
Assert.Equal(playability.Supported, playability.Ready + playability.Blocked);
Assert.True(playability.Ready >= 1);
Assert.True(playability.Unsupported >= 1);
Assert.Contains(playability.ByPlatform, item => item.Platform == "nes" && item.Ready >= 1);
var updatedRelease = await store.UpdateReleaseAsync(release.Id, "Synthetic Release Revised", "nes", "world", "rev 2",
release.Version, "integration-test", CancellationToken.None);
Assert.Equal("rev 2", updatedRelease.Revision);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UpdateReleaseAsync(release.Id, "Stale", "nes", null, null,
release.Version, "integration-test", CancellationToken.None));
var deletedRelease = await store.CreateReleaseAsync(game.Id, "Disposable Release", "nes", null, null,
"integration-test", CancellationToken.None);
await store.DeleteReleaseAsync(deletedRelease.Id, deletedRelease.Version, "integration-test", CancellationToken.None);
Assert.DoesNotContain(await store.ListReleasesAsync(game.Id, CancellationToken.None), item => item.Id == deletedRelease.Id);
var collection = await store.CreateCollectionAsync(new("Synthetic favorites", "A deterministic set",
CollectionKind.Static, null, true), "integration-test", CancellationToken.None);
await store.AddCollectionGameAsync(collection.Id, game.Id, "integration-test", CancellationToken.None);
Assert.Equal(game.Id, Assert.Single((await store.SearchCollectionGamesAsync(collection.Id, 1, 25, CancellationToken.None)).Items).Id);
var tag = await store.AddGameTagAsync(game.Id, "Co-Op", "integration-test", CancellationToken.None);
Assert.Equal("co-op", tag.NormalizedName);
Assert.Single(await store.ListTagsAsync(CancellationToken.None), item => item.NormalizedName == "co-op" && item.GameCount == 1);
var state = await store.SaveGameUserStateAsync(game.Id, new(true, GamePlayStatus.Playing, 9, 6, 42.5m,
"Co-op campaign", 3, DateTimeOffset.UtcNow), 0, "integration-test", CancellationToken.None);
Assert.Equal(1, state.Version);
var played = await store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None);
played = await store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None);
Assert.Equal(5, played.PlayCount);
Assert.Equal(GamePlayStatus.Playing, played.Status);
Assert.NotNull(played.LastPlayedAt);
var concurrentPlays = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ =>
store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None)));
Assert.Equal(9, (await store.GetGameUserStateAsync(game.Id, CancellationToken.None)).PlayCount);
Assert.Equal(4, concurrentPlays.Select(item => item.PlayCount).Distinct().Count());
var recent = Assert.Single(await store.ListRecentlyPlayedGamesAsync(6, CancellationToken.None));
Assert.Equal(game.Id, recent.Game.Id);
Assert.Equal(9, recent.State.PlayCount);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.SaveGameUserStateAsync(game.Id,
new(false, GamePlayStatus.Backlog, null, null, null, null, 0, null), 0, "integration-test", CancellationToken.None));
var smart = await store.CreateCollectionAsync(new("Currently playing", null, CollectionKind.Smart,
new CollectionRule(Tags: ["co-op"], Status: GamePlayStatus.Playing, Favorite: true), false),
"integration-test", CancellationToken.None);
Assert.Contains((await store.SearchCollectionGamesAsync(smart.Id, 1, 25, CancellationToken.None)).Items, item => item.Id == game.Id);
var relation = await store.AddGameRelationAsync(game.Id, new(GameRelationKind.Extra, "Soundtrack", null, null),
"integration-test", CancellationToken.None);
Assert.Contains(await store.ListGameRelationsAsync(game.Id, CancellationToken.None), item => item.Id == relation.Id);
var media = await store.AddExternalGameMediaAsync(game.Id, new(GameMediaKind.Manual, "Online manual",
"https://example.test/manual.pdf", "Synthetic", "manual-1"), "integration-test", CancellationToken.None);
Assert.True(media.Selected);
var achievementProgress = new GameAchievementProgress(game.Id, "Synthetic", "game-1", 1, 1,
DateTimeOffset.UtcNow, [new(Guid.NewGuid(), "achievement-1", "First step", null, 5, true, DateTimeOffset.UtcNow, null)]);
await store.UpsertAchievementProgressAsync(achievementProgress, CancellationToken.None);
Assert.Equal(1, (await store.GetAchievementProgressAsync(game.Id, CancellationToken.None))?.Earned);
var dataEntryId = Guid.NewGuid();
var firstDataRevision = new GameDataRevision(Guid.NewGuid(), dataEntryId, 0, "slot-1.sav",
"application/octet-stream", 16, new string('a', 64),
$"{game.Id:N}/{dataEntryId:N}/{Guid.NewGuid():N}.bin", "ManualUpload", "integration-test",
DateTimeOffset.UtcNow);
firstDataRevision = firstDataRevision with
{
Location = $"{game.Id:N}/{dataEntryId:N}/{firstDataRevision.Id:N}.bin"
};
var dataInput = new GameDataEntryInput(GameDataKind.Save, "Main campaign", "mGBA", "Steam Deck",
"Before the final dungeon");
var concurrentDataWrites = await Task.WhenAll(
store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput, 0, firstDataRevision,
"integration-test", CancellationToken.None),
store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput, 0, firstDataRevision,
"integration-test", CancellationToken.None));
var dataItem = concurrentDataWrites[0];
Assert.Equal(1, dataItem.Entry.Version);
Assert.Equal(dataItem.Entry.Id, concurrentDataWrites[1].Entry.Id);
var secondDataRevision = firstDataRevision with
{
Id = Guid.NewGuid(),
Length = 24,
Sha256 = new string('b', 64),
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
};
secondDataRevision = secondDataRevision with
{
Location = $"{game.Id:N}/{dataEntryId:N}/{secondDataRevision.Id:N}.bin"
};
dataItem = await store.SaveGameDataRevisionAsync(game.Id, dataEntryId,
new(GameDataKind.Save, "Main campaign", "mGBA", "Steam Deck", "After the final dungeon"),
dataItem.Entry.Version, secondDataRevision, "integration-test", CancellationToken.None);
Assert.Equal(2, dataItem.Entry.RevisionCount);
var reboundDataRevision = secondDataRevision with
{
Id = Guid.NewGuid(),
SourceId = "artifact-version-2",
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(2)
};
reboundDataRevision = reboundDataRevision with
{
Location = $"{game.Id:N}/{dataEntryId:N}/{reboundDataRevision.Id:N}.bin"
};
dataItem = await store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput,
dataItem.Entry.Version, reboundDataRevision, "integration-test", CancellationToken.None);
Assert.Equal(3, dataItem.Entry.RevisionCount);
Assert.Equal("artifact-version-2", dataItem.CurrentRevision.SourceId);
Assert.Equal(3, (await store.ListGameDataRevisionsAsync(dataEntryId, 1, 25, CancellationToken.None)).Total);
var revisionLocations = await store.ListGameDataRevisionLocationsAsync(CancellationToken.None);
Assert.Contains(firstDataRevision.Location, revisionLocations);
Assert.Contains(secondDataRevision.Location, revisionLocations);
dataItem = await store.UpdateGameDataAsync(game.Id, dataEntryId,
new("Main campaign", "mGBA", "Living room handheld", "Verified post-game save"),
dataItem.Entry.Version, "integration-test", CancellationToken.None);
Assert.Equal("Living room handheld", dataItem.Entry.Device);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UpdateGameDataAsync(game.Id, dataEntryId,
new("Stale", null, null, null), 1, "integration-test", CancellationToken.None));
var dataSummary = await store.GetGameDataSummaryAsync(game.Id, CancellationToken.None);
Assert.Equal(1, dataSummary.Saves);
Assert.Equal(3, dataSummary.Revisions);
Assert.Equal(64, dataSummary.Bytes);
var customPlatform = await store.CreatePlatformDefinitionAsync(new("synthetic-console", "Synthetic Console",
"Synthetic", ["synth"], true), "integration-test", CancellationToken.None);
var disabledPlatform = await store.UpdatePlatformDefinitionAsync(customPlatform.Id,
new(customPlatform.Id, customPlatform.Name, customPlatform.Category, customPlatform.Aliases, false),
customPlatform.Version, "integration-test", CancellationToken.None);
Assert.False(disabledPlatform.Enabled);
Assert.Contains((await store.SearchGamesAsync(new GameQuery { Platform = "nes", Page = 1, PageSize = 25 }, CancellationToken.None)).Items, item => item.Id == game.Id);
Assert.Empty((await store.SearchGamesAsync(new GameQuery { Platform = "ps5", Page = 1, PageSize = 25 }, CancellationToken.None)).Items);
var input = new WishlistInput(game.Id, game.Title, "nes", WishlistPriority.High, "Find a boxed copy", WishlistStatus.WaitingForSale, 25, 20, "EUR", "Synthetic Store", "https://example.test/game", "Boxed", "EU");
var wish = await store.CreateWishlistItemAsync(input, "integration-test", CancellationToken.None);
Assert.Equal(wish.Id, Assert.Single((await store.SearchWishlistAsync("boxed", "nes", WishlistPriority.High, WishlistStatus.WaitingForSale, "price", 1, 25, CancellationToken.None)).Items).Id);
Assert.Equal(1, (await store.GetWishlistSummaryAsync(CancellationToken.None)).PriceDrops);
await Assert.ThrowsAsync<ResourceConflictException>(() => store.CreateWishlistItemAsync(input, "integration-test", CancellationToken.None));
var updatedInput = input with { Priority = WishlistPriority.Low, Notes = "Wait for a sale", CurrentPrice = 30 };
var updatedWish = await store.UpdateWishlistItemAsync(wish.Id, updatedInput, wish.Version, "integration-test", CancellationToken.None);
Assert.Equal(WishlistPriority.Low, updatedWish.Priority);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UpdateWishlistItemAsync(wish.Id, input, wish.Version, "integration-test", CancellationToken.None));
await store.DeleteWishlistItemAsync(updatedWish.Id, updatedWish.Version, "integration-test", CancellationToken.None);
var futureWish = await store.CreateWishlistItemAsync(new(null, "Unique Future Game", "ps5", WishlistPriority.Normal, null), "integration-test", CancellationToken.None);
var uniqueFutureGame = await store.CreateGameAsync("Unique Future Game", "integration-test", CancellationToken.None);
Assert.Equal(1, await store.ReconcileWishlistAsync("integration-test", CancellationToken.None));
var reconciled = Assert.Single((await store.SearchWishlistAsync("Unique Future Game", null, null, WishlistStatus.Acquired, null, 1, 25, CancellationToken.None)).Items);
Assert.Equal(uniqueFutureGame.Id, reconciled.GameId);
await store.DeleteWishlistItemAsync(reconciled.Id, reconciled.Version, "integration-test", CancellationToken.None);
var ambiguousWish = await store.CreateWishlistItemAsync(new(null, "Ambiguous Future Game", null, WishlistPriority.Normal, null), "integration-test", CancellationToken.None);
await store.CreateGameAsync("Ambiguous Future Game", "integration-test", CancellationToken.None);
await store.CreateGameAsync("Ambiguous Future Game", "integration-test", CancellationToken.None);
Assert.Equal(0, await store.ReconcileWishlistAsync("integration-test", CancellationToken.None));
var stillUnlinked = Assert.Single((await store.SearchWishlistAsync("Ambiguous Future Game", null, null, WishlistStatus.Interested, null, 1, 25, CancellationToken.None)).Items);
Assert.Null(stillUnlinked.GameId);
await store.DeleteWishlistItemAsync(stillUnlinked.Id, stillUnlinked.Version, "integration-test", CancellationToken.None);
Assert.Empty((await store.SearchWishlistAsync(null, null, null, null, null, 1, 25, CancellationToken.None)).Items);
var visiblePage = await store.SearchGamesAsync(new GameQuery { Page = 1, PageSize = 200 }, CancellationToken.None);
var dashboard = await store.GetDashboardSummaryAsync(CancellationToken.None);
Assert.Equal(visiblePage.Total, dashboard.Games);
var dormant = LibraryRoot.Create("Dormant", Path.GetFullPath(Path.Combine(Path.GetTempPath(),
"ludarium-dormant-library")), LibraryKind.Mixed) with
{ Enabled = false, IsReadOnly = false, IsAvailable = false, HasEntries = false };
await store.UpsertLibraryAsync(dormant, CancellationToken.None);
Assert.Equal(dashboard.RootsRequiringAttention,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).RootsRequiringAttention);
var nesPlatform = Assert.Single(await store.ListPlatformsAsync(CancellationToken.None), item => item.Platform == "nes");
Assert.Equal((await store.SearchGamesAsync(new GameQuery { Platform = "nes", Page = 1, PageSize = 200 }, CancellationToken.None)).Total, nesPlatform.Games);
var hiddenSupportGame = await store.CreateGameAsync("Steam", "integration-test", CancellationToken.None);
Assert.Null(await store.GetGameAsync(hiddenSupportGame.Id, CancellationToken.None));
var supportAwareDashboard = await store.GetDashboardSummaryAsync(CancellationToken.None);
Assert.Equal(visiblePage.Total, supportAwareDashboard.Games);
Assert.Equal(1, supportAwareDashboard.ExcludedSupportGames);
var defaults = await store.GetOperationalSettingsAsync(CancellationToken.None);
var settings = await store.SaveOperationalSettingsAsync(defaults with { DefaultPageSize = 100 }, defaults.Version, CancellationToken.None);
Assert.Equal(100, settings.DefaultPageSize);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.SaveOperationalSettingsAsync(defaults, defaults.Version, CancellationToken.None));
var artworkRecord = new GameArtwork(Guid.NewGuid(), game.Id, ArtworkRole.VerifiedBoxFront,
ArtworkVerificationStatus.AutomaticallyVerified, "Synthetic", "fixture", "art-1", "exact-test",
Confidence.Deterministic, 600, 900, 0.6667m, 1234, new string('c', 64), 100m, DateTimeOffset.UtcNow);
await store.UpsertGameArtworkAsync(artworkRecord, CancellationToken.None);
Assert.Equal(artworkRecord.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id);
var alternateArtwork = artworkRecord with { Id = Guid.NewGuid(), Source = "Alternate", QualityScore = 82m, Selected = false };
await store.UpsertGameArtworkAsync(alternateArtwork, CancellationToken.None);
Assert.Equal(2, (await store.ListGameArtworkAsync(game.Id, CancellationToken.None)).Count);
await store.SelectGameArtworkAsync(game.Id, alternateArtwork.Id, CancellationToken.None);
Assert.Equal(alternateArtwork.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id);
await store.SelectGameArtworkAsync(game.Id, artworkRecord.Id, CancellationToken.None);
Assert.Equal(artworkRecord.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id);
Assert.Single(await store.ListGameArtworkAsync(game.Id, CancellationToken.None), item => item.Selected);
var ps5Artwork = artworkRecord with { Id = Guid.NewGuid(), Platform = "ps5" };
var windowsArtwork = artworkRecord with { Id = Guid.NewGuid(), Platform = "windows" };
await store.UpsertGameArtworkAsync(ps5Artwork, CancellationToken.None);
await store.UpsertGameArtworkAsync(windowsArtwork, CancellationToken.None);
Assert.Equal(ps5Artwork.Id,
(await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None, "ps5"))?.Id);
Assert.Equal(windowsArtwork.Id,
(await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None, "windows"))?.Id);
Assert.Equal(3, (await store.ListGameArtworkAsync(game.Id, CancellationToken.None)).Count(item => item.Selected));
await Assert.ThrowsAsync<KeyNotFoundException>(() => store.SelectGameArtworkAsync(game.Id, Guid.NewGuid(), CancellationToken.None));
Assert.DoesNotContain(await store.ListArtworkNeedingReviewAsync(CancellationToken.None), item => item.Id == artworkRecord.Id);
var disposableGame = await store.CreateGameAsync("Disposable Manual Game", "integration-test", CancellationToken.None);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.DeleteGameAsync(disposableGame.Id,
disposableGame.Version - 1, "integration-test", CancellationToken.None));
await store.CreateReleaseAsync(disposableGame.Id, "Disposable Version", "nes", null, null,
"integration-test", CancellationToken.None);
await store.AddGameTagAsync(disposableGame.Id, "Disposable", "integration-test", CancellationToken.None);
var retainedWish = await store.CreateWishlistItemAsync(new(disposableGame.Id, disposableGame.Title, "nes",
WishlistPriority.Normal, "Retain this intent"), "integration-test", CancellationToken.None);
var deletion = await store.DeleteGameAsync(disposableGame.Id, disposableGame.Version,
"integration-test", CancellationToken.None);
Assert.Equal(disposableGame.Id, deletion.Game.Id);
Assert.DoesNotContain(await store.ListGamesAsync(CancellationToken.None), item => item.Id == disposableGame.Id);
Assert.Empty(await store.ListReleasesAsync(disposableGame.Id, CancellationToken.None));
var standaloneWish = Assert.Single((await store.SearchWishlistAsync("Disposable Manual Game", null, null,
null, null, 1, 25, CancellationToken.None)).Items);
Assert.Equal(retainedWish.Id, standaloneWish.Id);
Assert.Null(standaloneWish.GameId);
Assert.Equal(retainedWish.Version + 1, standaloneWish.Version);
await store.DeleteWishlistItemAsync(standaloneWish.Id, standaloneWish.Version, "integration-test", CancellationToken.None);
var queuedJob = await store.EnqueueJobAsync("Synthetic", "{}", "synthetic-job", CancellationToken.None);
Assert.Equal(queuedJob.Id, (await store.EnqueueJobAsync("Synthetic", "{}", "synthetic-job", CancellationToken.None)).Id);
var leasedJob = Assert.IsType<BackgroundJob>(await store.LeaseJobAsync("integration-worker", TimeSpan.FromMinutes(1), CancellationToken.None));
Assert.Equal(JobState.Leased, leasedJob.State);
await store.UpdateJobAsync(leasedJob with { State = JobState.Completed, LeaseOwner = null, LeaseUntil = null }, CancellationToken.None);
Assert.Contains(await store.ListJobsAsync(CancellationToken.None), item => item.Id == queuedJob.Id && item.State == JobState.Completed);
var openReviewsBeforeLifecycle = (await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews;
var review = new ReviewItem(Guid.NewGuid(), "Synthetic ambiguity", Severity.Notice, ReviewState.Open,
"{}", DateTimeOffset.UtcNow);
await store.AddReviewAsync(review, CancellationToken.None);
Assert.Equal(openReviewsBeforeLifecycle + 1,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews);
var reviewGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.ReviewIds.Contains(review.Id));
var filteredReviewGroups = await store.SearchReviewGroupsAsync(new ReviewGroupQuery
{
State = ReviewState.Open,
Search = "Synthetic ambiguity",
Severity = Severity.Notice,
Order = ReviewGroupOrder.Newest,
Page = 1,
PageSize = 1
}, CancellationToken.None);
Assert.Contains(filteredReviewGroups.Items, group => group.ReviewIds.Contains(review.Id));
Assert.True(filteredReviewGroups.Total >= 1);
Assert.Equal(0, (await store.SearchReviewGroupsAsync(new ReviewGroupQuery
{
State = ReviewState.Open,
Search = "no-such-review-group",
Page = 1,
PageSize = 50
}, CancellationToken.None)).Total);
var operation = await store.ResolveReviewGroupAsync(reviewGroup.ReviewIds, (int)reviewGroup.Count,
"accepted exact test group", "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved);
await store.UndoReviewOperationAsync(operation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open);
var deferredOperation = await store.DeferReviewGroupAsync(reviewGroup.ReviewIds, (int)reviewGroup.Count,
"deferred exact test group", "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Deferred);
Assert.Equal(openReviewsBeforeLifecycle,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews);
var deferredGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Deferred, CancellationToken.None),
group => group.ReviewIds.Contains(review.Id));
var reopenedOperation = await store.ReopenReviewGroupAsync(deferredGroup.ReviewIds, (int)deferredGroup.Count, ReviewState.Deferred,
"reopened exact test group", "integration-test", CancellationToken.None);
var reopenedReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id);
Assert.Equal(ReviewState.Open, reopenedReview.State);
Assert.Null(reopenedReview.Resolution);
Assert.Equal(openReviewsBeforeLifecycle + 1,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews);
var resolvedOperation = await store.ResolveReviewGroupAsync(reopenedOperation.Before.Select(item => item.Id).ToArray(),
reopenedOperation.Before.Count, "resolved exact test group", "integration-test", CancellationToken.None);
var resolvedGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Resolved, CancellationToken.None),
group => group.ReviewIds.Contains(review.Id));
Assert.Equal("resolved exact test group", resolvedGroup.Resolution);
Assert.NotNull(resolvedGroup.ResolvedAt);
var reopenedResolvedOperation = await store.ReopenReviewGroupAsync(resolvedGroup.ReviewIds, (int)resolvedGroup.Count, ReviewState.Resolved,
"reopened resolved test group", "integration-test", CancellationToken.None);
var reopenedResolvedReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id);
Assert.Equal(ReviewState.Open, reopenedResolvedReview.State);
Assert.Null(reopenedResolvedReview.Resolution);
Assert.Null(reopenedResolvedReview.ResolvedAt);
await store.UndoReviewOperationAsync(reopenedResolvedOperation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved);
await store.UndoReviewOperationAsync(resolvedOperation.Id, "integration-test", CancellationToken.None);
await store.UndoReviewOperationAsync(reopenedOperation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Deferred);
await store.UndoReviewOperationAsync(deferredOperation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open);
var artifactReview = new ReviewItem(Guid.NewGuid(), "Unknown content", Severity.Notice, ReviewState.Open,
System.Text.Json.JsonSerializer.Serialize(new { artifact.Id, artifact.RelativePath }), DateTimeOffset.UtcNow);
await store.AddReviewAsync(artifactReview, CancellationToken.None);
var artifactGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.ReviewIds.Contains(artifactReview.Id));
var supportOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count,
ReviewBulkAction.ClassifySupportContent, null, null, "integration-test", CancellationToken.None);
var classifiedSupport = Assert.IsType<ArtifactDetails>(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None));
Assert.Equal(ArtifactState.Ignored, classifiedSupport.Artifact.State);
Assert.Equal(MediaType.SupportFile, classifiedSupport.Artifact.MediaType);
Assert.Contains(classifiedSupport.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "support-content");
await store.UndoReviewOperationAsync(supportOperation.Id, "integration-test", CancellationToken.None);
var restoredSupportArtifact = Assert.IsType<ArtifactDetails>(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None));
Assert.Equal(ArtifactState.Present, restoredSupportArtifact.Artifact.State);
Assert.DoesNotContain(restoredSupportArtifact.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "support-content");
var platformOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count,
ReviewBulkAction.AcceptPlatform, "ps5", null, "integration-test", CancellationToken.None);
Assert.Equal("ps5", (await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None))!.Artifact.Platform);
await store.UndoReviewOperationAsync(platformOperation.Id, "integration-test", CancellationToken.None);
var restoredPlatformArtifact = Assert.IsType<ArtifactDetails>(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None));
Assert.Equal("nes", restoredPlatformArtifact.Artifact.Platform);
Assert.DoesNotContain(restoredPlatformArtifact.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "platform:ps5");
var exclusionOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count,
ReviewBulkAction.ExcludeFolderPattern, null, artifactGroup.PathPattern, "integration-test", CancellationToken.None);
Assert.Contains("roms/nes/**", (await store.GetLibraryAsync(library.Id, CancellationToken.None))!.Exclusions!);
await store.UndoReviewOperationAsync(exclusionOperation.Id, "integration-test", CancellationToken.None);
Assert.Empty((await store.GetLibraryAsync(library.Id, CancellationToken.None))!.Exclusions ?? []);
var resolution = await store.ResolveReviewAsync(review.Id, "accepted", "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved);
await store.UndoResolutionAsync(resolution.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open);
var staleUndoReview = new ReviewItem(Guid.NewGuid(), "Synthetic stale undo", Severity.Notice, ReviewState.Open,
"{}", DateTimeOffset.UtcNow);
await store.AddReviewAsync(staleUndoReview, CancellationToken.None);
var staleUndoGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.ReviewIds.Contains(staleUndoReview.Id));
var staleUndoOperation = await store.DeferReviewGroupAsync(staleUndoGroup.ReviewIds, (int)staleUndoGroup.Count,
"deferred before a later action", "integration-test", CancellationToken.None);
await store.SetReviewStateAsync(staleUndoReview.Id, ReviewState.Open, "integration-test", CancellationToken.None);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UndoReviewOperationAsync(staleUndoOperation.Id,
"integration-test", CancellationToken.None));
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == staleUndoReview.Id && item.State == ReviewState.Open);
var mixedReason = $"Mixed resolution {Guid.NewGuid():N}";
await store.AddReviewAsync(new ReviewItem(Guid.NewGuid(), mixedReason, Severity.Notice, ReviewState.Resolved,
"{}", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "one recorded outcome"), CancellationToken.None);
await store.AddReviewAsync(new ReviewItem(Guid.NewGuid(), mixedReason, Severity.Notice, ReviewState.Resolved,
"{}", DateTimeOffset.UtcNow), CancellationToken.None);
var mixedGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Resolved, CancellationToken.None),
group => group.Reason == mixedReason);
Assert.Null(mixedGroup.Resolution);
Assert.Null(mixedGroup.ResolvedAt);
var catalogSource = new CatalogSource(Guid.NewGuid(), "Synthetic CRC catalog", "logiqx", "1",
null, "Ready", DateTimeOffset.UtcNow, 1);
var catalogEntry = new CatalogEntry(Guid.NewGuid(), catalogSource.Id, "Synthetic", artifact.RelativePath,
artifact.Size, null, null, content.Crc32, "synthetic-crc");
await store.UpsertCatalogSourceAsync(catalogSource, CancellationToken.None);
await store.UpsertCatalogEntriesAsync([catalogEntry], CancellationToken.None);
Assert.Equal(1, await store.MatchCatalogAsync(catalogSource.Id, CancellationToken.None));
var catalogReviewGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.Reason == "Catalog ambiguity" && group.ReviewIds.Count == 1);
await store.DeferReviewGroupAsync(catalogReviewGroup.ReviewIds, (int)catalogReviewGroup.Count,
"deferred catalog ambiguity", "integration-test", CancellationToken.None);
Assert.Equal(1, await store.MatchCatalogAsync(catalogSource.Id, CancellationToken.None));
var deferredCatalogReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None),
item => item.Reason == "Catalog ambiguity" && item.State == ReviewState.Deferred);
Assert.Equal("deferred catalog ambiguity", deferredCatalogReview.Resolution);
await using var restartedDataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var restartedStore = new PostgresStore(restartedDataSource);
await restartedStore.InitializeAsync(CancellationToken.None);
Assert.Contains(await restartedStore.ListLibrariesAsync(CancellationToken.None), item => item.Id == library.Id);
Assert.Contains(await restartedStore.ListGamesAsync(CancellationToken.None), item => item.Id == updated.Id && item.Title == updated.Title);
Assert.Equal(2, (await restartedStore.ListClaimsAsync("Game", game.Id, CancellationToken.None)).Count);
Assert.Equal(100, (await restartedStore.GetOperationalSettingsAsync(CancellationToken.None)).DefaultPageSize);
Assert.True((await restartedStore.GetGameUserStateAsync(game.Id, CancellationToken.None)).Favorite);
Assert.Contains(await restartedStore.ListCollectionsAsync(CancellationToken.None), item => item.Id == collection.Id && item.GameCount == 1);
Assert.Contains(await restartedStore.ListPlatformDefinitionsAsync(CancellationToken.None), item => item.Id == customPlatform.Id && !item.Enabled);
var restartedDataItem = Assert.Single(await restartedStore.ListGameDataAsync(game.Id, CancellationToken.None));
Assert.Equal(dataItem.Entry.Version, restartedDataItem.Entry.Version);
var deletedDataRevisions = await restartedStore.DeleteGameDataAsync(game.Id, dataEntryId,
restartedDataItem.Entry.Version, "integration-test", CancellationToken.None);
Assert.Equal(3, deletedDataRevisions.Count);
Assert.Empty(await restartedStore.ListGameDataAsync(game.Id, CancellationToken.None));
await using (var scale = dataSource.CreateCommand("""
INSERT INTO artifacts(id,library_id,relative_path,size,data,updated_at)
SELECT gen_random_uuid(), $1,
CASE WHEN value % 1000 = 0 THEN 'roms/nes/needle-' ELSE 'roms/nes/synthetic-' END || lpad(value::text, 6, '0') || '.nes',
32768, '{}'::jsonb, now()
FROM generate_series(1,250000) AS value
"""))
{
scale.CommandTimeout = 120;
scale.Parameters.AddWithValue(library.Id);
Assert.Equal(250_000, await scale.ExecuteNonQueryAsync());
}
await using (var analyze = dataSource.CreateCommand("EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM artifacts WHERE relative_path ILIKE '%needle%' ORDER BY relative_path LIMIT 50"))
await using (var reader = await ExecuteScaleQueryAsync(analyze))
{
var plan = new List<string>();
while (await reader.ReadAsync()) plan.Add(reader.GetString(0));
Assert.Contains(plan, line => line.Contains("ix_artifacts_path_trgm", StringComparison.Ordinal));
Assert.Contains(plan, line => line.Contains("Execution Time", StringComparison.Ordinal));
}
}
private static Task<NpgsqlDataReader> ExecuteScaleQueryAsync(NpgsqlCommand command)
{
command.CommandTimeout = 120;
return command.ExecuteReaderAsync();
}
}
@@ -0,0 +1,40 @@
using System.Net;
using System.Text;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class RawgGameDiscoveryServiceTests
{
[Fact]
public async Task MissingKeyReportsAnHonestDisabledCapabilityWithoutNetworkAccess()
{
using var client = new HttpClient(new StubHandler(_ => throw new InvalidOperationException("Network should not be used.")));
var response = await new RawgGameDiscoveryService(client, null).SearchAsync("Metroid", CancellationToken.None);
Assert.False(response.Configured);
Assert.Empty(response.Results);
Assert.Contains("RAWG_API_KEY", response.Message);
}
[Fact]
public async Task ConfiguredSearchNormalizesKnownPlatformsAndRetainsProviderIdentity()
{
const string json = """{"results":[{"id":42,"name":"Synthetic Quest","released":"2027-01-02","background_image":"https://images.example/42.jpg","slug":"synthetic-quest","platforms":[{"platform":{"slug":"playstation5"}}]}]}""";
using var client = new HttpClient(new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
}));
var response = await new RawgGameDiscoveryService(client, "synthetic-key").SearchAsync("Synthetic", CancellationToken.None);
var item = Assert.Single(response.Results);
Assert.True(response.Configured);
Assert.Equal("RAWG", item.Provider);
Assert.Equal("42", item.ExternalId);
Assert.Equal("ps5", item.Platform);
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(response(request));
}
}
@@ -0,0 +1,73 @@
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class ReadOnlyLibraryFileSystemTests : IDisposable
{
private readonly string root = Path.Combine(Path.GetTempPath(), $"ludarium-exclusions-{Guid.NewGuid():N}");
[Fact]
public async Task EnumerationAppliesBoundedFolderAndFileGlobExclusions()
{
Directory.CreateDirectory(Path.Combine(root, "roms", "nes"));
Directory.CreateDirectory(Path.Combine(root, "tools", "emulator", "assets"));
await File.WriteAllBytesAsync(Path.Combine(root, "roms", "nes", "game.nes"), [1]);
await File.WriteAllBytesAsync(Path.Combine(root, "roms", "nes", "readme.txt"), [2]);
await File.WriteAllBytesAsync(Path.Combine(root, "tools", "emulator", "emu.exe"), [3]);
await File.WriteAllBytesAsync(Path.Combine(root, "tools", "emulator", "assets", "icon.png"), [4]);
var library = LibraryRoot.Create("Synthetic", root, LibraryKind.Mixed) with
{
Exclusions = ["tools/**", "roms/*/*.txt"]
};
var observed = new List<string>();
await foreach (var file in new ReadOnlyLibraryFileSystem().EnumerateAsync(library, CancellationToken.None))
observed.Add(file.RelativePath);
Assert.Equal(["roms/nes/game.nes"], observed);
}
[Fact]
public void VerificationReportsWhetherTheMountedSourceContainsEntries()
{
Directory.CreateDirectory(root);
var fileSystem = new ReadOnlyLibraryFileSystem();
Assert.False(fileSystem.Verify(root).HasEntries);
Directory.CreateDirectory(Path.Combine(root, "collection"));
Assert.True(fileSystem.Verify(root).HasEntries);
}
[Fact]
public void ResolutionRejectsAFileReachedThroughASymbolicLink()
{
Directory.CreateDirectory(root);
var outside = Path.Combine(Path.GetTempPath(), $"ludarium-outside-{Guid.NewGuid():N}.bin");
var link = Path.Combine(root, "linked.bin");
File.WriteAllText(outside, "not library content");
try
{
try { File.CreateSymbolicLink(link, outside); }
catch (Exception exception) when (exception is PlatformNotSupportedException or UnauthorizedAccessException or IOException)
{
return;
}
var library = LibraryRoot.Create("Synthetic", root, LibraryKind.Mixed);
Assert.Throws<InvalidOperationException>(() =>
new ReadOnlyLibraryFileSystem().ResolveContainedPath(library, "linked.bin"));
}
finally
{
if (File.Exists(link)) File.Delete(link);
if (File.Exists(outside)) File.Delete(outside);
}
}
public void Dispose()
{
if (Directory.Exists(root)) Directory.Delete(root, true);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,23 @@
using Ludarium.Application;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class RetailCoverProfileCoverageTests
{
[Fact]
public void EveryCanonicalBuiltInPlatformHasExactlyOneArtworkQualityProfile()
{
var canonical = PostgresStore.BuiltInPlatforms
.Select(platform => platform.Id)
.Order(StringComparer.Ordinal)
.ToArray();
var profiles = ArtworkQuality.RetailProfileRatios.Keys
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(68, canonical.Length);
Assert.Equal(canonical.Length, canonical.Distinct(StringComparer.Ordinal).Count());
Assert.Equal(canonical, profiles);
}
}
@@ -0,0 +1,58 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// What resolving a review leaves behind in the record.
/// </summary>
/// <remarks>
/// Ignoring non-game content wrote the state's name into the record, while the record stores the
/// enum's ordinal. Every row it touched became undeserialisable, and the review list reads them all,
/// so a single ignored file was enough to fail the whole Attention workspace from then on.
/// </remarks>
public sealed class ReviewResolutionTests
{
[Fact]
[Trait("Category", "Container")]
public async Task AResolvedReviewCanStillBeRead()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_review_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Review fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-review-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var artifact = new Artifact(Guid.NewGuid(), library.Id, "roms/nes/readme.txt", 12,
DateTimeOffset.UtcNow.AddDays(-1), ArtifactState.Present, MediaType.Unknown, Confidence.Low, null,
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(-1), null, null);
await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None);
await store.AddReviewAsync(new(Guid.NewGuid(), "Unknown content", Severity.Warning, ReviewState.Open,
$"{{\"artifactId\":\"{artifact.Id}\"}}", DateTimeOffset.UtcNow), CancellationToken.None);
await store.MarkArtifactIgnoredAsync(artifact.Id, artifact.Version + 1,
new("content.role", "operator", "review", "1", Confidence.Deterministic, DateTimeOffset.UtcNow),
CancellationToken.None);
// Reading the list is the part that used to throw, for every review the catalog holds.
var reviews = await store.ListReviewsAsync(CancellationToken.None);
var resolved = Assert.Single(reviews);
Assert.Equal(ReviewState.Resolved, resolved.State);
Assert.Equal("Ignored non-game content", resolved.Resolution);
}
}
@@ -0,0 +1,339 @@
using System.Security.Cryptography;
using System.IO.Compression;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class SafeScanTests
{
[Fact]
public async Task CueWithExactEcmSidecarIsReviewableButNotReportedMissing()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllTextAsync(Path.Combine(path, "disc.cue"), "FILE \"disc.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n");
await File.WriteAllBytesAsync(Path.Combine(path, "disc.bin.ecm"), "ECM\0synthetic-fixture"u8.ToArray());
var before = Manifest(path);
var store = new MemoryStore();
var root = LibraryRoot.Create("PSX", path, LibraryKind.DiscImage);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "ecm-sidecar", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var bundle = Assert.Single(await store.ListBundlesAsync(CancellationToken.None));
Assert.Equal(BundleState.CompleteWithWarnings, bundle.State);
Assert.Contains(bundle.Members, member => member.Role == "encoded track");
Assert.DoesNotContain(bundle.Findings, finding => finding.Contains("not found", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(await store.ListReviewsAsync(CancellationToken.None), item => item.Reason == "Incomplete bundle");
Assert.Equal(before, Manifest(path));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task DormantLibraryCannotBeScanned()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "game.nes"), [0x4e, 0x45, 0x53, 0x1a]);
var store = new MemoryStore();
var root = LibraryRoot.Create("Dormant", path, LibraryKind.Rom) with { Enabled = false };
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "dormant", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var completed = await store.GetScanAsync(scan.Id, CancellationToken.None);
Assert.Equal(ScanState.Failed, completed?.State);
Assert.Contains("dormant", completed?.Error, StringComparison.OrdinalIgnoreCase);
Assert.Empty((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task DeepScanPreservesSourceAndBuildsReviewableWindowsBundle()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "setup.exe"), "MZ-invalid-static-fixture"u8.ToArray());
await File.WriteAllBytesAsync(Path.Combine(path, "setup-1.bin"), "payload-one"u8.ToArray());
await File.WriteAllBytesAsync(Path.Combine(path, "setup-3.bin"), "payload-three"u8.ToArray());
await File.WriteAllBytesAsync(Path.Combine(path, "sample.nes"), [0x4e, 0x45, 0x53, 0x1a, 0, 0, 0, 0]);
Directory.CreateDirectory(Path.Combine(path, "roms", "Tools", "Switch"));
await File.WriteAllTextAsync(Path.Combine(path, "roms", "Tools", "Switch", "prod.keys"), "synthetic-key-list");
var before = Manifest(path);
var store = new MemoryStore(); var root = LibraryRoot.Create("Synthetic", path, LibraryKind.Mixed, hashPolicy: HashPolicy.CatalogCompatible);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "safe-scan", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
Assert.Equal(before, Manifest(path));
Assert.Equal(ScanState.Completed, (await store.GetScanAsync(scan.Id, CancellationToken.None))?.State);
var artifacts = await store.SearchArtifactsAsync(null, 1, 50, CancellationToken.None);
Assert.Equal(5, artifacts.Total);
Assert.Contains(artifacts.Items, item => item.State == ArtifactState.Ignored && item.MediaType == MediaType.SupportFile);
var bundle = Assert.Single(await store.ListBundlesAsync(CancellationToken.None));
Assert.Equal(BundleKind.WindowsInstaller, bundle.Kind); Assert.Equal(BundleState.Incomplete, bundle.State);
Assert.Contains(bundle.Findings, x => x.Contains("payload 2", StringComparison.Ordinal));
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), x => x.Reason == "Incomplete bundle");
Assert.Equal(["ArtworkEnrichment", "MetadataEnrichment"], (await store.ListJobsAsync(CancellationToken.None)).Select(job => job.Kind).Order().ToArray());
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task VerifiedRenameRetainsArtifactIdentityAndUnavailableRootDoesNotRemoveIt()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "before.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]);
var store = new MemoryStore(); var root = LibraryRoot.Create("Moves", path, LibraryKind.Rom); await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var first = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "first", CancellationToken.None); await coordinator.ExecuteAsync(first.Id, CancellationToken.None);
var original = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
File.Move(Path.Combine(path, "before.nes"), Path.Combine(path, "after.nes"));
var second = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "second", CancellationToken.None); await coordinator.ExecuteAsync(second.Id, CancellationToken.None);
var moved = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
Assert.Equal(original.Id, moved.Id); Assert.Equal("after.nes", moved.RelativePath); Assert.Equal(ArtifactState.Present, moved.State);
Assert.Equal(1, (await store.GetScanAsync(second.Id, CancellationToken.None))?.MovedItems);
Directory.Move(path, path + "-offline");
var failed = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "offline", CancellationToken.None); await coordinator.ExecuteAsync(failed.Id, CancellationToken.None);
Assert.Equal(ArtifactState.Present, Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items).State);
Directory.Move(path + "-offline", path);
}
finally { if (Directory.Exists(path)) Directory.Delete(path, true); if (Directory.Exists(path + "-offline")) Directory.Delete(path + "-offline", true); }
}
[Fact]
public async Task EmptyMountedSourceDoesNotEraseAnExistingCatalog()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "preserved.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]);
var store = new MemoryStore();
var root = LibraryRoot.Create("Protected", path, LibraryKind.Rom);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var initial = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "initial", CancellationToken.None);
await coordinator.ExecuteAsync(initial.Id, CancellationToken.None);
File.Delete(Path.Combine(path, "preserved.nes"));
var empty = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "empty", CancellationToken.None);
await coordinator.ExecuteAsync(empty.Id, CancellationToken.None);
var result = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
Assert.Equal(ArtifactState.Present, result.State);
Assert.Equal(ScanState.Failed, (await store.GetScanAsync(empty.Id, CancellationToken.None))?.State);
Assert.Contains("source is empty", (await store.GetScanAsync(empty.Id, CancellationToken.None))?.Error, StringComparison.OrdinalIgnoreCase);
Assert.False((await store.GetLibraryAsync(root.Id, CancellationToken.None))?.HasEntries);
}
finally { if (Directory.Exists(path)) Directory.Delete(path, true); }
}
[Fact]
public async Task IntegrityScanHashesOnDemandLibrariesWithoutMutatingTheSource()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "identity.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]);
var before = Manifest(path);
var store = new MemoryStore(); var root = LibraryRoot.Create("On demand", path, LibraryKind.Rom, hashPolicy: HashPolicy.OnDemand);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Integrity, "integrity", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var artifact = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
Assert.NotNull(artifact.Sha256);
Assert.Equal(before, Manifest(path));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, true); }
}
[Fact]
public async Task DeepScanRecordsZipMembersWithoutChangingTheArchive()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
var archivePath = Path.Combine(path, "collection.zip");
await using (var output = File.Create(archivePath))
using (var archive = new ZipArchive(output, ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("game/demo.nes");
await using var member = entry.Open();
await member.WriteAsync(new byte[] { 0x4e, 0x45, 0x53, 0x1a });
}
var before = Manifest(path);
var store = new MemoryStore();
var root = LibraryRoot.Create("Archives", path, LibraryKind.Mixed, inspectArchives: true);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "zip", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var artifact = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
var memberRecord = Assert.Single(await store.ListArchiveMembersAsync(artifact.Id, CancellationToken.None));
Assert.Equal("game/demo.nes", memberRecord.Path);
Assert.Equal(before, Manifest(path));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task InventoryExportsAreVersionedEscapedAndCancellationSafe()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
var store = new MemoryStore();
var artifact = new Artifact(Guid.NewGuid(), Guid.NewGuid(), "quoted,\nname.nes", 4, DateTimeOffset.UtcNow,
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, Guid.NewGuid(), DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow, "iNES", "nes", Sha256: new string('a', 64));
await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None);
var service = new InventoryExportService(store, path);
foreach (var format in new[] { "json", "csv", "sha256" })
{
var result = await service.CreateAsync(format, CancellationToken.None);
var content = await File.ReadAllTextAsync(Path.Combine(path, result.FileName));
Assert.Contains(ReleaseIdentity.Version, content, StringComparison.Ordinal);
Assert.Contains(format == "json" ? "schemaVersion" : "ludarium-schema-version", content, StringComparison.Ordinal);
if (format == "sha256") Assert.Contains("quoted,\\nname.nes", content, StringComparison.Ordinal);
Assert.Equal(1, result.Count);
}
var cancelledRoot = Path.Combine(path, "cancelled");
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new InventoryExportService(store, cancelledRoot).CreateAsync("json", cancellation.Token));
Assert.Empty(Directory.GetFiles(cancelledRoot));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
private static string Manifest(string root) => string.Join('|', Directory.GetFiles(root).Order().Select(x => Path.GetFileName(x) + ":" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x)))));
private sealed class MemoryStore : ILudariumStore
{
private readonly Dictionary<Guid, LibraryRoot> libraries = []; private readonly Dictionary<Guid, ScanRun> scans = []; private readonly Dictionary<Guid, Artifact> artifacts = []; private readonly Dictionary<Guid, Bundle> bundles = []; private readonly Dictionary<Guid, ReviewItem> reviews = []; private readonly Dictionary<Guid, IReadOnlyList<ArchiveMember>> archiveMembers = []; private readonly List<Finding> findings = []; private readonly List<BackgroundJob> jobs = [];
private OperationalSettings settings = OperationalSettings.Defaults;
public Task InitializeAsync(CancellationToken c) => Task.CompletedTask;
public Task<int> GetSchemaVersionAsync(CancellationToken c) => Task.FromResult(PostgresStore.CurrentSchemaVersion);
public Task<IReadOnlyList<LibraryRoot>> ListLibrariesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<LibraryRoot>>(libraries.Values.ToArray());
public Task<LibraryRoot?> GetLibraryAsync(Guid id, CancellationToken c) => Task.FromResult(libraries.GetValueOrDefault(id));
public Task<IReadOnlyList<LibraryHealthSummary>> ListLibraryHealthAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<LibraryHealthSummary>>([]);
public Task UpsertLibraryAsync(LibraryRoot r, CancellationToken c) { libraries[r.Id] = r; return Task.CompletedTask; }
public Task<ScanRun> CreateScanAsync(Guid libraryId, ScanMode mode, string? key, CancellationToken c) { var existing = scans.Values.FirstOrDefault(x => x.Checkpoint == "key:" + key); if (existing is not null) return Task.FromResult(existing); var s = new ScanRun(Guid.NewGuid(), libraryId, mode, ScanState.Queued, "Queued", 0, null, 0, null, "key:" + key, false, DateTimeOffset.UtcNow); scans[s.Id] = s; return Task.FromResult(s); }
public Task<IReadOnlyList<ScanRun>> ListScansAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ScanRun>>(scans.Values.ToArray());
public Task<ScanRun?> GetScanAsync(Guid id, CancellationToken c) => Task.FromResult(scans.GetValueOrDefault(id));
public Task UpdateScanAsync(ScanRun s, CancellationToken c) { scans[s.Id] = s; return Task.CompletedTask; }
public Task UpsertArtifactAsync(Artifact a, ContentBlob? b, IReadOnlyList<Evidence> e, CancellationToken c) { artifacts[a.Id] = a; return Task.CompletedTask; }
public Task MarkArtifactIgnoredAsync(Guid id, long version, Evidence evidence, CancellationToken c) { artifacts[id] = artifacts[id] with { State = ArtifactState.Ignored, MediaType = MediaType.SupportFile, Platform = null, Version = version }; return Task.CompletedTask; }
public Task<ArtifactOverride?> GetArtifactOverrideAsync(Guid artifactId, CancellationToken c) => Task.FromResult<ArtifactOverride?>(null);
public Task<Artifact?> FindByPathAsync(Guid l, string p, CancellationToken c) => Task.FromResult(artifacts.Values.FirstOrDefault(x => x.LibraryId == l && x.RelativePath.Equals(p, StringComparison.OrdinalIgnoreCase)));
public Task<Artifact?> FindByHashAsync(Guid l, long z, string h, CancellationToken c) => Task.FromResult(artifacts.Values.FirstOrDefault(x => x.LibraryId == l && x.Size == z && x.Sha256 == h));
public Task<long> MarkMissingExceptAsync(Guid l, IReadOnlySet<string> paths, DateTimeOffset at, CancellationToken c) { var missing = artifacts.Values.Where(x => x.LibraryId == l && x.State == ArtifactState.Present && !paths.Contains(x.RelativePath)).ToArray(); foreach (var a in missing) artifacts[a.Id] = a with { State = ArtifactState.Missing }; return Task.FromResult((long)missing.Length); }
public Task<Page<Artifact>> SearchArtifactsAsync(string? q, int p, int z, CancellationToken c) { var x = artifacts.Values.Where(a => q is null || a.RelativePath.Contains(q, StringComparison.OrdinalIgnoreCase)).ToArray(); return Task.FromResult(new Page<Artifact>(x, p, z, x.Length)); }
public Task<ArtifactDetails?> GetArtifactDetailsAsync(Guid id, CancellationToken c) => Task.FromResult<ArtifactDetails?>(null);
public Task<DashboardSummary> GetDashboardSummaryAsync(CancellationToken c) => Task.FromResult(new DashboardSummary(libraries.Count, artifacts.Count, artifacts.Count, 0, 0, bundles.Count, artifacts.Values.Sum(x => x.Size), artifacts.Values.LongCount(x => x.Sha256 is not null), artifacts.Values.LongCount(x => x.MediaType != MediaType.Unknown), artifacts.Values.LongCount(x => x.MediaType == MediaType.Unknown), artifacts.Values.LongCount(x => x.State == ArtifactState.Missing), 0, 0, reviews.Values.LongCount(x => x.State != ReviewState.Resolved), findings.LongCount(x => x.Severity == Severity.Critical), scans.Values.LongCount(x => x.State is not (ScanState.Completed or ScanState.Failed or ScanState.Cancelled)), 0));
public Task<StorageSummary> GetStorageSummaryAsync(CancellationToken c)
{
var summaries = libraries.Values.Select(library =>
{
var stored = artifacts.Values.Where(item => item.LibraryId == library.Id).ToArray();
return new LibraryStorageSummary(library.Id, library.Name, stored.LongLength,
stored.Where(item => item.State == ArtifactState.Present).Sum(item => item.Size),
stored.LongCount(item => item.State == ArtifactState.Missing));
}).ToArray();
return Task.FromResult(new StorageSummary(artifacts.Values.Sum(x => x.Size), 0, [], summaries));
}
public Task<HealthSummary> GetHealthSummaryAsync(CancellationToken c) => Task.FromResult(new HealthSummary(0, 0, 0, 0, 0, 0, findings));
public Task<IReadOnlyList<Artifact>> GetDuplicatesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Artifact>>(artifacts.Values.GroupBy(x => x.Sha256).Where(x => x.Key is not null && x.Count() > 1).SelectMany(x => x).ToArray());
public Task ReplaceArchiveMembersAsync(Guid artifactId, IReadOnlyList<ArchiveMember> members, CancellationToken c) { archiveMembers[artifactId] = members; return Task.CompletedTask; }
public Task<IReadOnlyList<ArchiveMember>> ListArchiveMembersAsync(Guid artifactId, CancellationToken c) => Task.FromResult(archiveMembers.GetValueOrDefault(artifactId) ?? []);
public Task AddBundleAsync(Bundle b, CancellationToken c) { bundles[b.Id] = b; return Task.CompletedTask; }
public Task<IReadOnlyList<Bundle>> ListBundlesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Bundle>>(bundles.Values.ToArray());
public Task AddReviewAsync(ReviewItem r, CancellationToken c) { reviews.TryAdd(r.Id, r); return Task.CompletedTask; }
public Task<IReadOnlyList<ReviewItem>> ListReviewsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ReviewItem>>(reviews.Values.ToArray());
public Task<Page<ReviewItem>> SearchReviewsAsync(ReviewState? state, string? reason, int p, int z, CancellationToken c) { var x = reviews.Values.Where(r => (state is null || r.State == state) && (reason is null || r.Reason == reason)).ToArray(); return Task.FromResult(new Page<ReviewItem>(x, p, z, x.Length)); }
public Task<ReviewItem> SetReviewStateAsync(Guid id, ReviewState state, string actor, CancellationToken c) { reviews[id] = reviews[id] with { State = state }; return Task.FromResult(reviews[id]); }
public Task<ManualResolution> ResolveReviewAsync(Guid id, string r, string a, CancellationToken c) { var before = reviews[id]; reviews[id] = before with { State = ReviewState.Resolved, Resolution = r }; return Task.FromResult(new ManualResolution(Guid.NewGuid(), id, r, a, System.Text.Json.JsonSerializer.Serialize(before), System.Text.Json.JsonSerializer.Serialize(reviews[id]), DateTimeOffset.UtcNow)); }
public Task UndoResolutionAsync(Guid id, string actor, CancellationToken c) => Task.CompletedTask;
public Task AddClaimAsync(MetadataClaim claim, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<MetadataClaim>> ListClaimsAsync(string entityType, Guid entityId, CancellationToken c) => Task.FromResult<IReadOnlyList<MetadataClaim>>([]);
public Task<Game> CreateGameAsync(string title, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<GameDeletionResult> DeleteGameAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<Game> UpdateGameAsync(Guid id, string title, long version, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<Game?> GetGameAsync(Guid id, CancellationToken c) => Task.FromResult<Game?>(null);
public Task<IReadOnlyList<Game>> ListGamesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Game>>([]);
public Task<Page<Game>> SearchGamesAsync(GameQuery query, CancellationToken c) =>
Task.FromResult(new Page<Game>([], query.Page, query.PageSize, 0));
public Task<IReadOnlyList<Release>> ListReleasesForGamesAsync(IReadOnlyList<Guid> gameIds, CancellationToken c) =>
Task.FromResult<IReadOnlyList<Release>>([]);
public Task<Page<WishlistItem>> SearchWishlistAsync(string? q, string? platform, WishlistPriority? priority, WishlistStatus? status, string? sort, int page, int pageSize, CancellationToken c) => Task.FromResult(new Page<WishlistItem>([], page, pageSize, 0));
public Task<WishlistSummary> GetWishlistSummaryAsync(CancellationToken c) => Task.FromResult(new WishlistSummary(0, 0, 0, 0, 0));
public Task<WishlistItem> CreateWishlistItemAsync(WishlistInput input, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<WishlistItem> UpdateWishlistItemAsync(Guid id, WishlistInput input, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task DeleteWishlistItemAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => Task.CompletedTask;
public Task<int> ReconcileWishlistAsync(string actor, CancellationToken c) => Task.FromResult(0);
public Task<IReadOnlyList<ReviewGroup>> ListReviewGroupsAsync(ReviewState state, CancellationToken c) => Task.FromResult<IReadOnlyList<ReviewGroup>>([]);
public Task<ReviewOperation> ResolveReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string resolution, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<ReviewOperation> ApplyReviewGroupActionAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewBulkAction action, string? platform, string? pathPattern, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<ReviewOperation> DeferReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string reason, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<ReviewOperation> ReopenReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewState sourceState, string reason, string actor, CancellationToken c) => throw new NotSupportedException();
public Task UndoReviewOperationAsync(Guid id, string actor, CancellationToken c) => Task.CompletedTask;
public Task<int> SynchronizeDiscoveredGamesAsync(Guid libraryId, CancellationToken c) => Task.FromResult(0);
public Task UpsertGameArtworkAsync(GameArtwork artwork, CancellationToken c) => Task.CompletedTask;
public Task<GameArtwork?> GetSelectedGameArtworkAsync(Guid gameId, CancellationToken c, string? platform = null) => Task.FromResult<GameArtwork?>(null);
public Task<IReadOnlyList<GameArtwork>> ListGameArtworkAsync(Guid gameId, CancellationToken c) => Task.FromResult<IReadOnlyList<GameArtwork>>([]);
public Task SelectGameArtworkAsync(Guid gameId, Guid artworkId, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<GameArtwork>> ListArtworkNeedingReviewAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<GameArtwork>>([]);
public Task<IReadOnlyList<ArtworkReviewItem>> ListArtworkReviewItemsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ArtworkReviewItem>>([]);
public Task<BackgroundJob> EnqueueJobAsync(string kind, string payload, string? key, CancellationToken c)
{
var now = DateTimeOffset.UtcNow;
var job = new BackgroundJob(Guid.NewGuid(), kind, JobState.Queued, payload, 0, null, 0, 3, now, now, now);
jobs.Add(job);
return Task.FromResult(job);
}
public Task<BackgroundJob?> LeaseJobAsync(string worker, TimeSpan lease, CancellationToken c) => Task.FromResult<BackgroundJob?>(null);
public Task UpdateJobAsync(BackgroundJob job, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<BackgroundJob>> ListJobsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<BackgroundJob>>(jobs);
public Task<BackgroundJob?> GetJobAsync(Guid id, CancellationToken c) => Task.FromResult<BackgroundJob?>(null);
public Task RequestJobCancellationAsync(Guid id, CancellationToken c) => Task.CompletedTask;
public Task<BackgroundJob> RetryJobAsync(Guid id, CancellationToken c) => throw new NotSupportedException();
public Task UpsertProviderSnapshotAsync(ProviderSnapshot snapshot, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<ProviderSnapshot>> ListProviderSnapshotsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ProviderSnapshot>>([]);
public Task<IReadOnlyList<PlatformSummary>> ListPlatformsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<PlatformSummary>>([]);
public Task<Release> CreateReleaseAsync(Guid gameId, string title, string? platform, string? region, string? revision, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<Release> UpdateReleaseAsync(Guid id, string title, string? platform, string? region, string? revision, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task DeleteReleaseAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<IReadOnlyList<Release>> ListReleasesAsync(Guid? gameId, CancellationToken c) => Task.FromResult<IReadOnlyList<Release>>([]);
public Task<ArtifactDetails?> GetGameArtworkAsync(Guid gameId, CancellationToken c) => Task.FromResult<ArtifactDetails?>(null);
public Task<IReadOnlyList<Finding>> ListFindingsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Finding>>(findings); public Task AddFindingAsync(Finding f, CancellationToken c) { findings.Add(f); return Task.CompletedTask; }
public Task<IntegritySnapshot> CreateSnapshotAsync(string n, CancellationToken c) => throw new NotSupportedException(); public Task<IReadOnlyList<IntegritySnapshot>> ListSnapshotsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<IntegritySnapshot>>([]); public Task<IntegritySnapshot?> GetSnapshotAsync(Guid id, CancellationToken c) => Task.FromResult<IntegritySnapshot?>(null);
public Task<OperationalSettings> GetOperationalSettingsAsync(CancellationToken c) => Task.FromResult(settings);
public Task<OperationalSettings> SaveOperationalSettingsAsync(OperationalSettings value, long expectedVersion, CancellationToken c) { settings = value with { Version = expectedVersion + 1 }; return Task.FromResult(settings); }
}
}
@@ -0,0 +1,57 @@
using System.IO.Compression;
using System.Reflection;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class SupportBundleServiceTests
{
[Fact]
public async Task BundleRedactsPathsErrorsAndFindingDiagnosticsByConstruction()
{
const string secret = "super-secret-token";
var now = DateTimeOffset.UtcNow;
var libraryId = Guid.NewGuid();
var store = DispatchProxy.Create<ILudariumStore, SupportStoreProxy>();
var proxy = (SupportStoreProxy)(object)store;
proxy.Libraries = [new(libraryId, "Games", $"/mnt/{secret}", LibraryKind.Mixed, true, HashPolicy.Sha256, false, 1, true, true, true, true, now)];
proxy.Scans = [new(Guid.NewGuid(), libraryId, ScanMode.Deep, ScanState.Failed, "failed", 1, 1, 10, 10, null, false, now, now, now, $"Host=database;Password={secret}")];
proxy.Findings = [new(Guid.NewGuid(), "Integrity", Severity.Critical, $"API key {secret} found at /mnt/private/game.rom", null, null, now)];
var directory = Path.Combine(Path.GetTempPath(), "ludarium-support-tests", Guid.NewGuid().ToString("N"));
try
{
var result = await new SupportBundleService(store, directory).CreateAsync(CancellationToken.None);
using var archive = ZipFile.OpenRead(Path.Combine(directory, result.FileName));
var text = string.Join('\n', archive.Entries.Select(entry =>
{
using var reader = new StreamReader(entry.Open());
return reader.ReadToEnd();
}));
Assert.DoesNotContain(secret, text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("/mnt/private", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Password=", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("[redacted]", text, StringComparison.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
public class SupportStoreProxy : DispatchProxy
{
public IReadOnlyList<LibraryRoot> Libraries { get; set; } = [];
public IReadOnlyList<ScanRun> Scans { get; set; } = [];
public IReadOnlyList<Finding> Findings { get; set; } = [];
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => targetMethod?.Name switch
{
nameof(ILudariumStore.ListLibrariesAsync) => Task.FromResult(Libraries),
nameof(ILudariumStore.ListScansAsync) => Task.FromResult(Scans),
nameof(ILudariumStore.ListFindingsAsync) => Task.FromResult(Findings),
_ => throw new NotSupportedException(targetMethod?.Name)
};
}
}
@@ -0,0 +1,54 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class SwitchRuntimeProvisionerTests
{
[Fact]
public async Task VerifiedPersonalKeysAreAtomicallyProvisionedOutsideTheSourceLibrary()
{
var root = Path.Combine(Path.GetTempPath(), $"ludarium-switch-{Guid.NewGuid():N}");
var vaultRoot = Path.Combine(root, "vault");
var runtimeRoot = Path.Combine(root, "eden-keys");
try
{
var vault = new FirmwareVaultFileStore(vaultRoot);
var provisioner = new SwitchRuntimeProvisioner(runtimeRoot);
var requirement = FirmwarePolicy.Get("switch", "prod-keys");
const string fixture = "synthetic-switch-provisioning-fixture\n";
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(fixture));
var asset = await vault.SaveAsync(requirement, Guid.NewGuid(), "prod.keys", input,
input.Length, CancellationToken.None);
await provisioner.ProvisionAsync(asset, vault, CancellationToken.None);
var target = Path.Combine(runtimeRoot, "prod.keys");
Assert.True(provisioner.Configured);
Assert.Equal(fixture, await File.ReadAllTextAsync(target));
if (!OperatingSystem.IsWindows())
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(target));
Assert.Empty(Directory.GetFiles(runtimeRoot, "*.uploading"));
provisioner.Remove(asset);
Assert.False(File.Exists(target));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
[Fact]
public async Task DisabledProvisionerNeverCopiesKeys()
{
var provisioner = new SwitchRuntimeProvisioner(null);
var asset = new Ludarium.Domain.FirmwareAsset(Guid.NewGuid(), "switch", "prod-keys",
Ludarium.Domain.FirmwareAssetKind.KeySet, "prod.keys", 1, new string('0', 64), "missing", true,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
await provisioner.ProvisionAsync(asset, null!, CancellationToken.None);
Assert.False(provisioner.Configured);
}
}
@@ -0,0 +1,281 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.8.1, )",
"resolved": "18.8.1",
"contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==",
"dependencies": {
"Microsoft.CodeCoverage": "18.8.1",
"Microsoft.TestPlatform.TestHost": "18.8.1"
}
},
"SSH.NET": {
"type": "Direct",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Testcontainers.PostgreSql": {
"type": "Direct",
"requested": "[4.13.0, )",
"resolved": "4.13.0",
"contentHash": "2ow4AE8drI9iA9Fr4ycAPusXPB1lJfQyyNONSMLE/XqLpm8VuNAh3pK38fOjkOWtTrnD03s4hAIdl4036Ik69A==",
"dependencies": {
"Testcontainers": "4.13.0"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.5, )",
"resolved": "3.1.5",
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"Docker.DotNet.Enhanced": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3",
"Docker.DotNet.Enhanced.LegacyHttp": "4.3.3",
"Docker.DotNet.Enhanced.NPipe": "4.3.3",
"Docker.DotNet.Enhanced.NativeHttp": "4.3.3",
"Docker.DotNet.Enhanced.Unix": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Docker.DotNet.Enhanced.Handler.Abstractions": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Docker.DotNet.Enhanced.LegacyHttp": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.NativeHttp": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.NPipe": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.Unix": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.X509": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Transitive",
"resolved": "10.0.11",
"contentHash": "R/1EATnPLU+gRfB6lwVkMcymmyAY5ppBBdRN/5lhNEiT3xP1sWccuSFkU/f1lQvN/WgRq5Vn8AhCdE3fqgsL/w==",
"dependencies": {
"Microsoft.OpenApi": "[2.7.5, 3.0.0)"
}
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.8.1",
"contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
}
},
"Microsoft.OpenApi": {
"type": "Transitive",
"resolved": "2.11.0",
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.8.1",
"contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.8.1",
"contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.8.1"
}
},
"Npgsql": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"OpenMcdf": {
"type": "Transitive",
"resolved": "3.2.0",
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
},
"SharpCompress": {
"type": "Transitive",
"resolved": "0.50.4",
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
},
"SharpZipLib": {
"type": "Transitive",
"resolved": "1.4.2",
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "10.0.11",
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
},
"Testcontainers": {
"type": "Transitive",
"resolved": "4.13.0",
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
"dependencies": {
"Docker.DotNet.Enhanced": "4.3.3",
"Docker.DotNet.Enhanced.X509": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"SSH.NET": "2025.1.0",
"SharpZipLib": "1.4.2"
}
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"Yarp.ReverseProxy": {
"type": "Transitive",
"resolved": "2.3.0",
"contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==",
"dependencies": {
"System.IO.Hashing": "8.0.0"
}
},
"ludarium.api": {
"type": "Project",
"dependencies": {
"Ludarium.Application": "[1.0.0, )",
"Ludarium.Infrastructure": "[1.0.0, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.11, )",
"Microsoft.OpenApi": "[2.11.0, )",
"Yarp.ReverseProxy": "[2.3.0, )"
}
},
"ludarium.application": {
"type": "Project",
"dependencies": {
"Ludarium.Domain": "[1.0.0, )",
"OpenMcdf": "[3.2.0, )",
"SharpCompress": "[0.50.4, )",
"System.IO.Hashing": "[10.0.11, )"
}
},
"ludarium.domain": {
"type": "Project"
},
"ludarium.infrastructure": {
"type": "Project",
"dependencies": {
"Ludarium.Application": "[1.0.0, )",
"Ludarium.Domain": "[1.0.0, )",
"Npgsql": "[10.0.3, )"
}
}
}
}
}