LANCommander/LANCommander.Launcher.Services.Tests/Tests/ImportServiceTests.cs

218 lines
8.5 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using LANCommander.Launcher.Data;
using LANCommander.Launcher.Data.Models;
using LANCommander.Launcher.Services.Tests.Helpers;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Shouldly;
using Xunit;
using Game = LANCommander.Launcher.Data.Models.Game;
namespace LANCommander.Launcher.Services.Tests.Tests;
public class ImportServiceTests
{
private static DatabaseContext CreateContext() =>
new(
NullLoggerFactory.Instance,
new DbContextOptionsBuilder()
.UseInMemoryDatabase($"ImportServiceTests-{Guid.NewGuid()}")
.Options);
private static AuthenticationService CreateAuthService(Guid userId)
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId.ToString()) };
var jwt = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: "test",
audience: "test",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(5)));
var tokenProvider = new Mock<ITokenProvider>();
tokenProvider.Setup(p => p.GetToken()).Returns(new AuthToken { AccessToken = jwt });
// Only the token-reading path (GetUserId) is exercised, so the rest of the graph is null!.
return new AuthenticationService(
tokenProvider.Object,
settingsProvider: null!,
scopeFactory: null!,
connectionClient: null!,
authenticationClient: null!,
logger: NullLogger<AuthenticationService>.Instance);
}
private static ImportService CreateSubject(DatabaseContext context, Guid userId) =>
// Reconciliation only touches the database context, the logger, and the authentication
// service. The remaining ctor args are never dereferenced so we pass null! rather than
// build mocks for the concrete SDK clients (which expose non-virtual methods anyway).
new(
NullLogger<ImportService>.Instance,
importContextFactory: null!,
gameClient: null!,
toolClient: null!,
libraryClient: null!,
playSessionClient: null!,
dbContext: context,
gameService: null!,
authenticationService: CreateAuthService(userId));
private static async Task SeedLibraryAsync(DatabaseContext context, Guid userId, params Game[] games)
{
context.Libraries!.Add(new Library
{
UserId = userId,
Games = games.ToList(),
});
await context.SaveChangesAsync();
}
private static async Task SeedCachedGamesAsync(DatabaseContext context, params Game[] games)
{
// Games that exist in the local database but are not associated with any library, mirroring
// records left behind after they were dropped from the library on a previous import.
context.Games!.AddRange(games);
await context.SaveChangesAsync();
}
private static async Task<List<Guid>> GetLibraryGameIdsAsync(DatabaseContext context, Guid userId)
{
context.ChangeTracker.Clear();
var library = await context.Libraries!
.Include(l => l.Games)
.FirstAsync(l => l.UserId == userId);
return library.Games.Select(g => g.Id).ToList();
}
[Fact]
public async Task ReconcileLibraryMembership_removes_local_games_missing_from_remote_library()
{
var userId = Guid.NewGuid();
var keep = GameFactory.Make("Half-Life");
var stale = GameFactory.Make("Removed From Depot");
await using var context = CreateContext();
await SeedLibraryAsync(context, userId, keep, stale);
await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([keep.Id]);
var remaining = await GetLibraryGameIdsAsync(context, userId);
remaining.ShouldBe([keep.Id]);
}
[Fact]
public async Task ReconcileLibraryMembership_keeps_games_still_present_remotely()
{
var userId = Guid.NewGuid();
var first = GameFactory.Make("Half-Life");
var second = GameFactory.Make("Quake III Arena");
await using var context = CreateContext();
await SeedLibraryAsync(context, userId, first, second);
await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([first.Id, second.Id]);
var remaining = await GetLibraryGameIdsAsync(context, userId);
remaining.ShouldBe([first.Id, second.Id], ignoreOrder: true);
}
[Fact]
public async Task ReconcileLibraryMembership_skips_when_remote_library_is_empty()
{
// An empty remote list is ambiguous with a server-side failure, so the local
// library must be left untouched rather than wiped.
var userId = Guid.NewGuid();
var game = GameFactory.Make("Half-Life");
await using var context = CreateContext();
await SeedLibraryAsync(context, userId, game);
await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([]);
var remaining = await GetLibraryGameIdsAsync(context, userId);
remaining.ShouldBe([game.Id]);
}
[Fact]
public async Task ReconcileLibraryMembership_does_not_touch_other_users_libraries()
{
var userId = Guid.NewGuid();
var otherUserId = Guid.NewGuid();
var ownGame = GameFactory.Make("Half-Life");
var otherGame = GameFactory.Make("Quake III Arena");
await using var context = CreateContext();
await SeedLibraryAsync(context, userId, ownGame);
await SeedLibraryAsync(context, otherUserId, otherGame);
// Remote library for the current user no longer contains any games it shares with the
// other user, but the other user's library must remain intact.
await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([ownGame.Id]);
var otherRemaining = await GetLibraryGameIdsAsync(context, otherUserId);
otherRemaining.ShouldBe([otherGame.Id]);
}
[Fact]
public async Task ReconcileLibraryMembership_adds_cached_games_missing_from_library()
{
// Reproduces toggling "Enable User Libraries" off: the game was dropped from the library on
// a previous import but its record is still cached, so a subsequent full library must
// re-associate it instead of leaving it hidden.
var userId = Guid.NewGuid();
var inLibrary = GameFactory.Make("Half-Life");
var cachedOnly = GameFactory.Make("Quake III Arena");
await using var context = CreateContext();
await SeedLibraryAsync(context, userId, inLibrary);
await SeedCachedGamesAsync(context, cachedOnly);
await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([inLibrary.Id, cachedOnly.Id]);
var remaining = await GetLibraryGameIdsAsync(context, userId);
remaining.ShouldBe([inLibrary.Id, cachedOnly.Id], ignoreOrder: true);
}
[Fact]
public async Task ReconcileLibraryMembership_adds_and_removes_in_a_single_pass()
{
var userId = Guid.NewGuid();
var keep = GameFactory.Make("Half-Life");
var stale = GameFactory.Make("Removed From Depot");
var cachedOnly = GameFactory.Make("Quake III Arena");
await using var context = CreateContext();
await SeedLibraryAsync(context, userId, keep, stale);
await SeedCachedGamesAsync(context, cachedOnly);
await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([keep.Id, cachedOnly.Id]);
var remaining = await GetLibraryGameIdsAsync(context, userId);
remaining.ShouldBe([keep.Id, cachedOnly.Id], ignoreOrder: true);
}
[Fact]
public async Task ReconcileLibraryMembership_ignores_remote_games_not_cached_locally()
{
// A remote game whose record has not been imported yet cannot be added to the library here;
// reconciliation must simply leave it out rather than fail.
var userId = Guid.NewGuid();
var inLibrary = GameFactory.Make("Half-Life");
var notCachedRemoteId = Guid.NewGuid();
await using var context = CreateContext();
await SeedLibraryAsync(context, userId, inLibrary);
await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([inLibrary.Id, notCachedRemoteId]);
var remaining = await GetLibraryGameIdsAsync(context, userId);
remaining.ShouldBe([inLibrary.Id]);
}
}