test: cover runtime key, release formats and telemetry

adds ~60 new tests across RuntimeKey parsing, EnvVarKeyDelivery, the
legacy and m2pack formats, ReleaseFormatFactory dispatch, manifest
loader tolerance of unknown top-level fields, orchestrator wiring and
the ClientAppliedReporter (disabled-by-default, success, 5xx, timeout,
connection refused). The telemetry tests spin up an in-process
HttpListener helper — no new NuGet dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-14 21:10:48 +02:00
parent 6ad8e8db19
commit 0e95171e50
9 changed files with 818 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
using System.Net;
using System.Net.Http;
using Metin2Launcher.Telemetry;
using Xunit;
namespace Metin2Launcher.Tests;
public class ClientAppliedReporterTests
{
[Fact]
public void Disabled_when_template_is_empty()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, "");
Assert.False(r.IsEnabled);
}
[Fact]
public void Disabled_when_template_is_null()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, null);
Assert.False(r.IsEnabled);
}
[Fact]
public void Disabled_when_template_is_whitespace()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, " ");
Assert.False(r.IsEnabled);
}
[Fact]
public async Task ReportAsync_noop_when_disabled()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, "");
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public void Expand_substitutes_placeholders()
{
var s = ClientAppliedReporter.Expand("https://x/{format}/{version}", "m2pack", "2026.04.14-1");
Assert.Equal("https://x/m2pack/2026.04.14-1", s);
}
[Fact]
public void Expand_uri_escapes_values()
{
var s = ClientAppliedReporter.Expand("https://x/{version}", "m2pack", "v 1+beta");
Assert.Contains("v%201%2Bbeta", s);
}
[Fact]
public void Expand_leaves_unknown_placeholders()
{
var s = ClientAppliedReporter.Expand("https://x/{other}", "m2pack", "v1");
Assert.Equal("https://x/{other}", s);
}
[Fact]
public async Task ReportAsync_posts_json_on_success()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(HttpStatusCode.OK);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var r = new ClientAppliedReporter(http, url + "?format={format}&version={version}");
var ok = await r.ReportAsync("m2pack", "2026.04.14-1", CancellationToken.None);
Assert.True(ok);
var req = listener.LastRequest!;
Assert.Equal("POST", req.Method);
Assert.Contains("format=m2pack", req.RawUrl);
Assert.Contains("\"Format\":\"m2pack\"", req.Body);
Assert.Contains("\"Version\":\"2026.04.14-1\"", req.Body);
}
[Fact]
public async Task ReportAsync_returns_false_on_500()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(HttpStatusCode.InternalServerError);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var r = new ClientAppliedReporter(http, url);
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ReportAsync_swallows_connection_refused()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
// pick a port unlikely to be listening
var r = new ClientAppliedReporter(http, "http://127.0.0.1:1/");
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ReportAsync_times_out_after_five_seconds()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondAfter(TimeSpan.FromSeconds(10), HttpStatusCode.OK);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var r = new ClientAppliedReporter(http, url);
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
sw.Stop();
Assert.False(ok);
// reporter caps at 5s internally — allow slack for CI schedulers
Assert.InRange(sw.Elapsed.TotalSeconds, 3.0, 9.0);
}
}

View File

@@ -0,0 +1,68 @@
using System.Diagnostics;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
public class EnvVarDeliveryTests
{
private static RuntimeKey SampleKey() => new()
{
KeyId = "2026.04.14-1",
MasterKeyHex = new string('a', 64),
SignPubkeyHex = new string('b', 64),
};
[Fact]
public void Apply_sets_expected_env_vars()
{
var psi = new ProcessStartInfo { FileName = "nothing" };
new EnvVarKeyDelivery().Apply(psi, SampleKey());
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]);
Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]);
}
[Fact]
public void Apply_does_not_leak_into_current_process()
{
var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
var psi = new ProcessStartInfo { FileName = "nothing" };
new EnvVarKeyDelivery().Apply(psi, SampleKey());
var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
Assert.Equal(before, after); // still unset (or whatever it was)
}
[Fact]
public void Apply_overwrites_existing_values_on_psi()
{
var psi = new ProcessStartInfo { FileName = "nothing" };
psi.Environment[EnvVarKeyDelivery.KeyIdVar] = "stale";
new EnvVarKeyDelivery().Apply(psi, SampleKey());
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
}
[Fact]
public void Apply_rejects_null_args()
{
var d = new EnvVarKeyDelivery();
Assert.Throws<ArgumentNullException>(() => d.Apply(null!, SampleKey()));
Assert.Throws<ArgumentNullException>(() => d.Apply(new ProcessStartInfo(), null!));
}
[Fact]
public void SharedMemoryDelivery_is_stubbed()
{
var d = new SharedMemoryKeyDelivery();
Assert.Equal("shared-memory", d.Name);
Assert.Throws<NotSupportedException>(() =>
d.Apply(new ProcessStartInfo(), SampleKey()));
}
[Fact]
public void Delivery_name_is_env_var()
{
Assert.Equal("env-var", new EnvVarKeyDelivery().Name);
}
}

View File

@@ -1,4 +1,5 @@
using Metin2Launcher.GameLaunch;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
@@ -29,4 +30,46 @@ public class GameProcessTests
Assert.Empty(psi.ArgumentList);
}
}
[Fact]
public void BuildStartInfo_without_runtime_key_does_not_set_m2pack_env()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), runtimeKey: null);
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.MasterKeyVar));
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.SignPubkeyVar));
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.KeyIdVar));
}
[Fact]
public void BuildStartInfo_with_runtime_key_forwards_env_vars()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var rk = new RuntimeKey
{
KeyId = "2026.04.14-1",
MasterKeyHex = new string('a', 64),
SignPubkeyHex = new string('b', 64),
};
var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk);
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]);
Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]);
}
[Fact]
public void BuildStartInfo_runtime_key_does_not_pollute_current_process()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
var rk = new RuntimeKey
{
KeyId = "k",
MasterKeyHex = new string('c', 64),
SignPubkeyHex = new string('d', 64),
};
GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk);
var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
Assert.Equal(before, after);
}
}

View File

@@ -0,0 +1,64 @@
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class LegacyJsonBlobFormatTests
{
private static ManifestDto SampleManifest() => new()
{
Version = "v1",
Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 },
Files =
{
new ManifestFile { Path = "a.pck", Sha256 = "h1", Size = 10 },
new ManifestFile { Path = "Metin2.exe", Sha256 = "h2", Size = 20, Platform = "windows" },
new ManifestFile { Path = "linux-bin", Sha256 = "h3", Size = 30, Platform = "linux" },
},
};
[Fact]
public void Name_is_legacy_json_blob()
{
Assert.Equal("legacy-json-blob", new LegacyJsonBlobFormat().Name);
}
[Fact]
public void FilterApplicable_windows_platform()
{
var format = new LegacyJsonBlobFormat();
var filtered = format.FilterApplicable(SampleManifest(), "windows");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path == "a.pck");
Assert.Contains(filtered, f => f.Path == "Metin2.exe");
}
[Fact]
public void FilterApplicable_linux_platform()
{
var format = new LegacyJsonBlobFormat();
var filtered = format.FilterApplicable(SampleManifest(), "linux");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path == "a.pck");
Assert.Contains(filtered, f => f.Path == "linux-bin");
}
[Fact]
public void OnApplied_is_noop()
{
var outcome = new ReleaseOutcome();
var tmp = Path.Combine(Path.GetTempPath(), "lg-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
new LegacyJsonBlobFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.Null(outcome.RuntimeKey);
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -0,0 +1,131 @@
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class M2PackFormatTests
{
private const string ValidHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
private static ManifestDto SampleManifest() => new()
{
Version = "2026.04.14-1",
Format = "m2pack",
Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 },
Files =
{
new ManifestFile { Path = "pack/item.m2p", Sha256 = "h1", Size = 1000 },
new ManifestFile { Path = "runtime-key.json", Sha256 = "h2", Size = 200 },
},
};
private static string WriteRuntimeKey(string dir, string keyId = "2026.04.14-1")
{
var path = Path.Combine(dir, M2PackFormat.RuntimeKeyFileName);
File.WriteAllText(path, $$"""
{
"key_id": "{{keyId}}",
"master_key_hex": "{{ValidHex}}",
"sign_pubkey_hex": "{{ValidHex}}"
}
""");
return path;
}
[Fact]
public void Name_is_m2pack()
{
Assert.Equal("m2pack", new M2PackFormat().Name);
}
[Fact]
public void FilterApplicable_includes_m2p_and_runtime_key()
{
var filtered = new M2PackFormat().FilterApplicable(SampleManifest(), "windows");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path.EndsWith(".m2p"));
Assert.Contains(filtered, f => f.Path == "runtime-key.json");
}
[Fact]
public void OnApplied_loads_runtime_key_when_present()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
WriteRuntimeKey(tmp);
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.NotNull(outcome.RuntimeKey);
Assert.Equal("2026.04.14-1", outcome.RuntimeKey!.KeyId);
Assert.Equal(ValidHex, outcome.RuntimeKey.MasterKeyHex);
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void OnApplied_tolerates_missing_runtime_key()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.Null(outcome.RuntimeKey); // warn-logged, not thrown
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void OnApplied_throws_on_malformed_runtime_key()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
File.WriteAllText(Path.Combine(tmp, M2PackFormat.RuntimeKeyFileName), "{ not valid }");
var outcome = new ReleaseOutcome();
Assert.ThrowsAny<Exception>(() =>
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome));
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void Launcher_never_opens_m2p_archive()
{
// Guard test: the format must not touch the .m2p file itself. We assert
// by constructing a manifest whose .m2p entry points at a file that
// does not exist on disk and would throw if opened — OnApplied must
// ignore it entirely and only touch runtime-key.json.
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
WriteRuntimeKey(tmp);
// Intentionally no pack/item.m2p on disk.
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.NotNull(outcome.RuntimeKey);
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -0,0 +1,86 @@
using System.Text;
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class ReleaseFormatFactoryTests
{
[Fact]
public void Resolves_legacy_by_default()
{
var f = ReleaseFormatFactory.Resolve("legacy-json-blob");
Assert.IsType<LegacyJsonBlobFormat>(f);
}
[Fact]
public void Resolves_m2pack()
{
var f = ReleaseFormatFactory.Resolve("m2pack");
Assert.IsType<M2PackFormat>(f);
}
[Fact]
public void Throws_on_unknown_format()
{
Assert.Throws<NotSupportedException>(() =>
ReleaseFormatFactory.Resolve("evil-format"));
}
[Fact]
public void Manifest_without_format_defaults_to_legacy()
{
var m = new ManifestDto { Version = "v1" };
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
[Fact]
public void Manifest_with_blank_format_defaults_to_legacy()
{
var m = new ManifestDto { Version = "v1", Format = " " };
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
[Fact]
public void Manifest_with_explicit_format_is_honoured()
{
var m = new ManifestDto { Version = "v1", Format = "m2pack" };
Assert.Equal("m2pack", m.EffectiveFormat);
}
[Fact]
public void Loader_tolerates_unknown_top_level_field_next_to_format()
{
const string json = """
{
"format": "m2pack",
"version": "2026.04.14-1",
"created_at": "2026-04-14T14:00:00Z",
"future_field": {"nested": 1},
"launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1},
"files": []
}
""";
var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json));
Assert.Equal("m2pack", m.EffectiveFormat);
Assert.Equal("2026.04.14-1", m.Version);
}
[Fact]
public void Loader_parses_manifest_without_format_as_legacy()
{
const string json = """
{
"version": "2026.04.14-1",
"created_at": "2026-04-14T14:00:00Z",
"launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1},
"files": []
}
""";
var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json));
Assert.Null(m.Format);
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
}

View File

@@ -0,0 +1,83 @@
using System.Text;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
public class RuntimeKeyTests
{
private const string ValidHex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
private static string SampleJson(
string keyId = "2026.04.14-1",
string master = ValidHex64,
string pub = ValidHex64)
=> $$"""
{
"key_id": "{{keyId}}",
"master_key_hex": "{{master}}",
"sign_pubkey_hex": "{{pub}}"
}
""";
[Fact]
public void Parse_happy_path()
{
var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson()));
Assert.Equal("2026.04.14-1", rk.KeyId);
Assert.Equal(ValidHex64, rk.MasterKeyHex);
Assert.Equal(ValidHex64, rk.SignPubkeyHex);
}
[Fact]
public void Parse_rejects_missing_key_id()
{
var json = SampleJson(keyId: "");
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Theory]
[InlineData("")]
[InlineData("tooshort")]
[InlineData("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")] // right len, wrong chars
public void Parse_rejects_bad_master_key(string master)
{
var json = SampleJson(master: master);
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Fact]
public void Parse_rejects_bad_pubkey()
{
var json = SampleJson(pub: new string('g', 64));
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Fact]
public void Parse_accepts_uppercase_hex()
{
var upper = ValidHex64.ToUpperInvariant();
var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson(master: upper, pub: upper)));
Assert.Equal(upper, rk.MasterKeyHex);
}
[Fact]
public void Load_reads_from_disk()
{
var tmp = Path.Combine(Path.GetTempPath(), "runtime-key-test-" + Guid.NewGuid() + ".json");
File.WriteAllText(tmp, SampleJson());
try
{
var rk = RuntimeKey.Load(tmp);
Assert.Equal("2026.04.14-1", rk.KeyId);
}
finally { File.Delete(tmp); }
}
[Fact]
public void Parse_rejects_malformed_json()
{
Assert.Throws<System.Text.Json.JsonException>(() =>
RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json")));
}
}

View File

@@ -0,0 +1,107 @@
using System.Net;
using System.Text;
namespace Metin2Launcher.Tests;
/// <summary>
/// Minimal in-process HTTP server backed by <see cref="HttpListener"/> for
/// telemetry / orchestrator tests. Dependency-free — no TestServer, no
/// WireMock, no new NuGet package.
/// </summary>
public sealed class TestHttpListener : IDisposable
{
private readonly HttpListener _listener = new();
private CancellationTokenSource _cts = new();
private Task? _loop;
private HttpStatusCode _status = HttpStatusCode.OK;
private TimeSpan _delay = TimeSpan.Zero;
private Func<HttpListenerRequest, byte[]>? _bodyFactory;
public CapturedRequest? LastRequest { get; private set; }
public string Start()
{
int port = GetFreeTcpPort();
var prefix = $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(prefix);
_listener.Start();
_loop = Task.Run(LoopAsync);
return prefix;
}
public void RespondWith(HttpStatusCode status) => _status = status;
public void RespondAfter(TimeSpan delay, HttpStatusCode status)
{
_delay = delay;
_status = status;
}
public void RespondBody(Func<HttpListenerRequest, byte[]> factory) => _bodyFactory = factory;
private async Task LoopAsync()
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync().ConfigureAwait(false); }
catch { return; }
try
{
string body;
using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
body = await r.ReadToEndAsync().ConfigureAwait(false);
LastRequest = new CapturedRequest
{
Method = ctx.Request.HttpMethod,
RawUrl = ctx.Request.RawUrl ?? "",
Body = body,
};
if (_delay > TimeSpan.Zero)
{
try { await Task.Delay(_delay, _cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
ctx.Response.StatusCode = (int)_status;
if (_bodyFactory != null)
{
var payload = _bodyFactory(ctx.Request);
await ctx.Response.OutputStream.WriteAsync(payload, _cts.Token).ConfigureAwait(false);
}
ctx.Response.Close();
}
catch
{
try { ctx.Response.Abort(); } catch { }
}
}
}
private static int GetFreeTcpPort()
{
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
public void Dispose()
{
try { _cts.Cancel(); } catch { }
try { _listener.Stop(); _listener.Close(); } catch { }
try { _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts.Dispose();
}
public sealed class CapturedRequest
{
public string Method { get; set; } = "";
public string RawUrl { get; set; } = "";
public string Body { get; set; } = "";
}
}

View File

@@ -0,0 +1,112 @@
using System.Text;
using System.Text.Json;
using Metin2Launcher.Config;
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Metin2Launcher.Orchestration;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
/// <summary>
/// Exercises the <see cref="UpdateOrchestrator"/>'s format-dispatch wiring.
///
/// The orchestrator short-circuits to a signature exception when the fetched
/// manifest isn't signed by the hardcoded <see cref="LauncherConfig.PublicKeyHex"/>.
/// We can't realistically override that constant in a test without widening
/// the surface area, so these tests focus on:
///
/// 1. The unit-level pieces the orchestrator relies on (factory, effective
/// format, file filtering, post-apply hook) — covered in sibling test
/// classes and asserted here for regression safety.
/// 2. End-to-end "manifest fetched but signature mismatches" showing the
/// orchestrator really reaches the verify stage against a synthetic
/// HTTP endpoint served by <see cref="TestHttpListener"/>.
/// </summary>
public class UpdateOrchestratorFormatDispatchTests
{
[Fact]
public async Task Bad_signature_throws_before_format_dispatch()
{
using var listener = new TestHttpListener();
var url = listener.Start();
var manifest = new ManifestDto
{
Format = "m2pack",
Version = "v1",
CreatedAt = "2026-04-14T00:00:00Z",
Launcher = new ManifestLauncherEntry { Path = "L.exe", Sha256 = "dead", Size = 1 },
};
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest));
var badSig = new byte[64]; // deliberately wrong signature
listener.RespondBody(req => req.RawUrl!.EndsWith(".sig") ? badSig : body);
var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid());
Directory.CreateDirectory(clientRoot);
try
{
using var http = new HttpClient();
var settings = new LauncherSettings
{
DevMode = true,
ManifestUrlOverride = url + "manifest.json",
};
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
await Assert.ThrowsAsync<ManifestSignatureException>(async () =>
await orchestrator.RunAsync(new Progress<UpdateProgress>(_ => { }), CancellationToken.None));
}
finally { Directory.Delete(clientRoot, true); }
}
[Fact]
public async Task Manifest_fetch_failure_returns_offline_fallback()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(System.Net.HttpStatusCode.NotFound);
var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid());
Directory.CreateDirectory(clientRoot);
try
{
using var http = new HttpClient();
var settings = new LauncherSettings
{
DevMode = true,
ManifestUrlOverride = url + "manifest.json",
};
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
var result = await orchestrator.RunAsync(
new Progress<UpdateProgress>(_ => { }),
CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(LauncherState.OfflineFallback, result.FinalState);
Assert.Null(result.Format);
Assert.Null(result.RuntimeKey);
}
finally { Directory.Delete(clientRoot, true); }
}
[Fact]
public void Result_carries_format_and_key_slots()
{
// Contract test: the orchestrator Result exposes Format and RuntimeKey
// so the launcher can thread the runtime key into GameProcess.Launch.
var r = new UpdateOrchestrator.Result(true, LauncherState.UpToDate, null);
Assert.Null(r.Format);
Assert.Null(r.RuntimeKey);
}
[Fact]
public void Factory_falls_back_through_effective_format_on_empty_string()
{
// A manifest with format="" should resolve via EffectiveFormat -> legacy.
var m = new ManifestDto { Version = "v1", Format = "" };
var f = ReleaseFormatFactory.Resolve(m.EffectiveFormat);
Assert.IsType<LegacyJsonBlobFormat>(f);
}
}