using System.Security.Cryptography; using Launcher.Next.Application; using Launcher.Next.Core; using Launcher.Next.Infrastructure; using Xunit; namespace Launcher.Next.Tests; public sealed class PackageResolutionUseCaseTests { private static PackageRequirement Requirement(string version, bool offline = true, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot", string roster = "project-a") => new("P_Rec_Annot", product, year, architecture, channel, version, roster, offline); private static PackageManifest Manifest(string version, byte[] bytes, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot") => new(1, "P_Rec_Annot", version, product, year, architecture, "PRecAnnot.Plant3D2026.dll", [new("PRecAnnot.Plant3D2026.dll", bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)))], new("netload", "PHP_REC_ANNOT"), RolloutChannel: channel); [Fact] public async Task OnlineSuccess_StagesBindsAndRecordsOnlineEvidence() { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1, 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var manifest = Manifest("1.0.0", bytes); var store = new LocalPackageStore(root, new Sha256FileHasher()); try { var result = await UseCase(new(new CatalogPackage(manifest, source))).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.True(result.IsResolved); Assert.True(result.Online); Assert.False(result.Selected!.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "ONLINE_PACKAGE_SELECTED"); Assert.False(result.Selected.LocalPackageRoot.StartsWith("\\\\", StringComparison.Ordinal)); } finally { Directory.Delete(root, true); } PackageResolutionUseCase UseCase(FakeCatalog catalog) => new(catalog, store, store, store, new Telemetry()); } [Fact] public async Task ControlledOfflineRecovery_CorruptB_SelectsVerifiedAAndEvidence() { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher()); try { var a = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), a); await store.SynchronizeAsync(Manifest("1.0.0", a), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None); var b = new byte[] { 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), b); await store.SynchronizeAsync(Manifest("2.0.0", b), source, CancellationToken.None); await store.BindAsync(Requirement("2.0.0"), "2.0.0", CancellationToken.None); await File.WriteAllBytesAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2.0.0", "PRecAnnot.Plant3D2026.dll"), new byte[] { 9 }); var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.True(result.IsResolved); Assert.False(result.Online); Assert.Equal("1.0.0", result.Selected!.Version); Assert.True(result.Selected.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "PREVIOUS_LKG_SELECTED"); Assert.Contains(result.Evidence, x => x.Code == "CATALOG_UNAVAILABLE"); } finally { Directory.Delete(root, true); } } [Fact] public async Task UnavailableCatalog_WithFallbackDisabled_BlocksWithoutSelectingPrevious() { var store = new LocalPackageStore(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), new Sha256FileHasher()); var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0", false), CancellationToken.None); Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "OFFLINE_FALLBACK_DISABLED"); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE"); } [Fact] public async Task OnlineProductMismatch_IsRejectedAndBlocked() { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher()); try { var result = await new PackageResolutionUseCase(new FakeCatalog(new CatalogPackage(Manifest("1.0.0", bytes, "Revit"), source)), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE"); } finally { Directory.Delete(root, true); } } [Theory] [InlineData("Revit", 2026, "x64", "pilot", "1.0.0", "project-a")] [InlineData("Plant3D", 2025, "x64", "pilot", "1.0.0", "project-a")] [InlineData("Plant3D", 2026, "x86", "pilot", "1.0.0", "project-a")] [InlineData("Plant3D", 2026, "x64", "stable", "1.0.0", "project-a")] [InlineData("Plant3D", 2026, "x64", "pilot", "2.0.0", "project-a")] [InlineData("Plant3D", 2026, "x64", "pilot", "1.0.0", "project-b")] public async Task CachedPackage_RejectsIncompatibleRequirement(string product, int year, string architecture, string channel, string version, string roster) { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher()); try { await store.SynchronizeAsync(Manifest("1.0.0", bytes), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None); Assert.Null(await store.ResolveAsync(Requirement(version, true, product, year, architecture, channel, roster), CancellationToken.None)); } finally { Directory.Delete(root, true); } } private sealed class FakeCatalog(CatalogPackage? package) : IPackageCatalogPackageAcquirer { public Task AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken) => Task.FromResult(package); } private sealed class Telemetry : ILauncherTelemetry { public void Write(string eventType, IReadOnlyDictionary? fields = null) { } } }