This commit is contained in:
@@ -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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user