using System.Reflection; using System.Text; using LANCommander.SDK.Enums; using LANCommander.SDK.Helpers; using LANCommander.SDK.Services; using SdkGame = LANCommander.SDK.Models.Game; using ManifestGame = LANCommander.SDK.Models.Manifest.Game; namespace LANCommander.SDK.Tests.Install; /// /// Simulates two independent users (separate authentication sessions / launcher databases) /// installing the same game into an overlapping install root. /// /// Scenario: /// - User A installs "Quake" with install root {root}\Games, landing in {root}\Games\Quake. /// - User B, on a completely separate GameClient session, chooses the same {root}\Games root. /// - User B's install should: /// 1. Resolve to (detect) User A's existing installation directory. /// 2. Validate the files already present in that directory. /// /// Both users are modelled as distinct instances. GameClient's /// directory resolution () and local file /// verification are file-system only for a main game, so no server/API is required. /// public class GameInstallationSharedDirectoryTests : IDisposable { private readonly string _tempDir; private readonly string _sharedRoot; private readonly Guid _quakeId = Guid.NewGuid(); // Files User A "installs" into the shared directory, relative to the install directory. private static readonly string[] GameFiles = { "quake.exe", "id1/pak0.pak", "id1/config.cfg", }; // User A's session/launcher. private readonly GameClient _userAClient = CreateClient(); // User B's session/launcher — a separate authentication session and launcher DB. private readonly GameClient _userBClient = CreateClient(); // Resolved install directory produced by User A's install ({sharedRoot}\Quake). private readonly string _userAInstallDirectory; public GameInstallationSharedDirectoryTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"lc-shared-install-tests-{Guid.NewGuid()}"); // Stands in for "C:\Games" — the install root both users select. _sharedRoot = Path.Combine(_tempDir, "Games"); Directory.CreateDirectory(_sharedRoot); // ── User A performs the initial install ────────────────────────────── _userAInstallDirectory = _userAClient.GetInstallDirectory(MakeQuake(), _sharedRoot) .GetAwaiter().GetResult(); InstallGameFiles(_userAInstallDirectory); WriteFileList(_userAInstallDirectory, _quakeId, GameFiles); ManifestHelper.Write( new ManifestGame { Id = _quakeId, Title = "Quake", Type = GameType.MainGame, Version = "1.0.0", }, _userAInstallDirectory); } public void Dispose() { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } // ── Detection ───────────────────────────────────────────────────────────── [Fact] public async Task UserB_ResolvesToUserAExistingInstallDirectory() { // User A installed to {sharedRoot}\Quake. Assert.Equal(Path.Combine(_sharedRoot, "Quake"), _userAInstallDirectory); // User B, on a separate session, selects the same shared root and should resolve // to the exact same directory User A already installed into. var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot); Assert.Equal(_userAInstallDirectory, userBInstallDirectory); Assert.True(Directory.Exists(userBInstallDirectory)); } [Fact] public async Task UserB_DetectsExistingInstallation_ViaManifest() { var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot); // The existing installation is detected through the manifest User A wrote. Assert.True(ManifestHelper.Exists(userBInstallDirectory, _quakeId)); } // ── Validation ──────────────────────────────────────────────────────────── [Fact] public async Task UserB_ValidatesExistingFiles_AllPresent() { var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot); var verified = await VerifyLocalFiles(_userBClient, userBInstallDirectory, _quakeId); // Every file User A installed is validated as present and can be skipped on re-download. Assert.Equal(GameFiles.Length, verified.Count); foreach (var file in GameFiles) Assert.Contains(file, verified); } [Fact] public async Task UserB_Validation_DetectsMissingFile() { var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot); // Simulate a partially-present/corrupted existing install by removing a file. File.Delete(Path.Combine(userBInstallDirectory, "id1/pak0.pak".Replace('/', Path.DirectorySeparatorChar))); var verified = await VerifyLocalFiles(_userBClient, userBInstallDirectory, _quakeId); // The missing file is not validated, so User B's install would re-fetch it. Assert.DoesNotContain("id1/pak0.pak", verified); Assert.Contains("quake.exe", verified); Assert.Equal(GameFiles.Length - 1, verified.Count); } // ── Helpers ──────────────────────────────────────────────────────────────── private SdkGame MakeQuake() => new() { Id = _quakeId, Title = "Quake", Type = GameType.MainGame, BaseGameId = Guid.Empty, }; /// /// Builds a GameClient whose dependencies are unused for main-game directory /// resolution and local file verification, so they are safe to leave null. /// private static GameClient CreateClient() => new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); private static void InstallGameFiles(string installDirectory) { foreach (var file in GameFiles) { var localPath = Path.Combine(installDirectory, file.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); File.WriteAllText(localPath, $"contents of {file}"); } } /// /// Writes a FileList.txt matching the production format ("path | CRC32HEX") /// that GameClient produces after an extraction. /// private static void WriteFileList(string installDirectory, Guid gameId, IEnumerable files) { var fileListPath = GameClient.GetMetadataFilePath(installDirectory, gameId, "FileList.txt"); Directory.CreateDirectory(Path.GetDirectoryName(fileListPath)!); var builder = new StringBuilder(); foreach (var file in files) builder.AppendLine($"{file} | DEADBEEF"); File.WriteAllText(fileListPath, builder.ToString()); } /// /// Invokes GameClient's private local-file verification (the VerifyFiles install task), /// which reads FileList.txt and returns entries confirmed present on disk. /// private static async Task> VerifyLocalFiles(GameClient client, string installDirectory, Guid gameId) { var method = typeof(GameClient).GetMethod( "VerifyLocalFilesAsync", BindingFlags.NonPublic | BindingFlags.Instance); Assert.NotNull(method); var task = (Task>)method!.Invoke( client, new object[] { installDirectory, gameId, CancellationToken.None })!; return await task; } }