using System.Net; using System.Reflection; using System.Text; using game.Account; using game.PlayerDataStorage; using Newtonsoft.Json; using Newtonsoft.Json.Linq; static class Tests { static readonly Uri Api = new("http://synthetic.invalid/"); static readonly string RunId = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N"); static readonly string Root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../runs", RunId)); static readonly List Results = new(); static int assertions, failures; static async Task Main() { Directory.CreateDirectory(Root); await Run("A1-ready-merged-detached-snapshot", ReadyRead); await Run("A2-validation-cancellation-and-reopen", RejectAndReopen); await Run("A3-initial-read-failure-releases-slot", InitialFailure); await Run("A3-busy-recovery-storage-errors", OpenErrors); await Run("A3-conflict-unknown-no-empty-save", NonReady); await Run("A3-close-failure-blocks-reopen", CloseFailure); await Run("A3-create-cleanup-failure-blocks-reopen", CreateCleanupFailure); await Run("A4-A6-A7-close-drain-queue-cancellation-stale-handle", LateClose); await Run("A5-repeat-open-close-single-owner", Repeat); await Run("A8-read-during-flush-preserves-batch", ReadDuringFlush); await Run("A8-epoch-invalidated-after-read", ReadEpoch); await Run("A8-stale-priority-over-not-ready", NonReadyPriority); await Run("A8-read-close-reentry-drains-lease", ReadCloseReentry); await Run("A3-write-unknown-retains-ticket-semantics", WriteUnknown); await Run("A9-login-session-dispose-or-relogin", SessionInvalidation); await Run("A10-pending-reopen-no-implicit-flush", PendingReopen); await Run("A11-default-transport-configuration", DefaultTransport); await Run("A2-close-during-initial-get", CloseDuringCreate); var report = new { runId = RunId, failures, assertions, accessedFormalServer = false, tests = Results }; var json = JsonConvert.SerializeObject(report, Formatting.Indented); foreach (var forbidden in new[] { "secret-fixture", "fixture-key", "sid=", "s101.local", @"C:\Users\" }) Check(!json.Contains(forbidden, StringComparison.OrdinalIgnoreCase), "sanitized"); await File.WriteAllTextAsync(Path.Combine(Root, "result.json"), json); Console.WriteLine($"RESULT tests={Results.Count} failed={failures} assertions={assertions} run={RunId}"); return failures == 0 ? 0 : 1; } static async Task Run(string name, Func test) { var before = assertions; try { await test(); Results.Add(new { name, passed = true, assertions = assertions - before }); Console.WriteLine("PASS " + name); } catch (Exception ex) { failures++; Results.Add(new { name, passed = false, error = ex.GetType().Name }); Console.WriteLine("FAIL " + name + " " + ex); } } static void Check(bool value, string label) { assertions++; if (!value) throw new InvalidOperationException(label); } static string Cache() { var path = Path.Combine(Root, "cache", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; } static TaskCompletionSource Gate() => new(TaskCreationOptions.RunContinuationsAsynchronously); static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; sealed class Handler : HttpMessageHandler { readonly Func> send; readonly Action dispose; internal Handler(Func> send, Action dispose = null) { this.send = send; this.dispose = dispose; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token); protected override void Dispose(bool disposing) { if (disposing) dispose?.Invoke(); base.Dispose(disposing); } } static async Task Login(string id) { var s = new S101AccountSession(Api, jar => new Handler((r, _) => { Check(r.RequestUri.Host == Api.Host && r.RequestUri.AbsolutePath == "/account/Login", "synthetic login only"); jar.Add(Api, new Cookie("sid", id)); return Task.FromResult(Json(HttpStatusCode.OK, "{\"state\":0,\"internalId\":1,\"newCreate\":false,\"createTime\":\"2026-01-01T00:00:00Z\",\"svrTime\":\"2026-01-01T00:00:00Z\"}")); })); Check((await s.LoginAsync(id)).Success, "synthetic login"); return s; } sealed class Fake { internal readonly Dictionary> Remote = new() { ["101"] = new(), ["102"] = new() }; internal int Gets, Writes, Active, MaxActive, Factories; internal bool FailGet, FailWrite, ThrowDispose; internal Func Before; internal PlayerKVGameSessionBridge Bridge => new(Create); internal HttpMessageHandler Create(CookieContainer jar) { Factories++; Active++; MaxActive = Math.Max(MaxActive, Active); var id = jar.GetCookies(Api)["sid"]?.Value; Check(id is "101" or "102", "fixed synthetic cookie"); return new Handler(async (r, ct) => { Check(r.Method == HttpMethod.Post && r.RequestUri.Host == Api.Host && r.RequestUri.Port == 80, "no formal transport"); var path = r.RequestUri.AbsolutePath; Check(path is "/playerdata/Get" or "/playerdata/Set" or "/playerdata/Delete", "KV path"); if (path.EndsWith("/Get")) Gets++; else Writes++; if (Before != null) await Before(id, r, ct); if ((path.EndsWith("/Get") && FailGet) || (!path.EndsWith("/Get") && FailWrite)) return Json(HttpStatusCode.InternalServerError, "{}"); var body = JObject.Parse(await r.Content.ReadAsStringAsync()); var remote = Remote[id]; if (path.EndsWith("/Get")) return Json(HttpStatusCode.OK, new JObject { ["data"] = JObject.FromObject(remote) }.ToString()); if (path.EndsWith("/Set")) foreach (var p in ((JObject)body["data"]).Properties()) remote[p.Name] = (string)p.Value; else foreach (var key in body["keys"].Values()) remote.Remove(key); return Json(HttpStatusCode.OK, "{}"); }, () => { Active--; if (ThrowDispose) throw new IOException("synthetic dispose fault"); }); } } static Task Open(PlayerKVGameSessionBridge b, S101AccountSession s, string root, long epoch = 1, Func current = null, CancellationToken token = default) => b.OpenAsync(s, root, s.PlayerId, Api, epoch, current ?? (_ => true), token); static PlayerKVGameSessionHandle Good(PlayerKVGameOpenResult r) { Check(r.Success && r.Handle != null, "open succeeded"); return r.Handle; } static async Task ReadyRead() { using var s = await Login("101"); var f = new Fake(); f.Remote["101"]["fixture-key-base"] = "secret-fixture-base"; var b = f.Bridge; var h = Good(await Open(b, s, Cache())); try { Check(h.Update("fixture-key-base", "secret-fixture-pending").Accepted, "pending"); Check(h.Update("fixture-key-base", "secret-fixture-overlay", false).Accepted, "local overlay"); var read = h.ReadLocalSnapshot(); Check(read.Success && read.Data["fixture-key-base"] == "secret-fixture-overlay" && read.OwnerInstanceId == h.OwnerInstanceId && read.PlayerId == "101" && read.Epoch == h.Epoch, "merged identity"); var rejected = false; try { ((IDictionary)read.Data).Add("fixture-key-injection", "secret-fixture"); } catch (NotSupportedException) { rejected = true; } Check(rejected, "immutable dictionary"); h.Update("fixture-key-base", "secret-fixture-new"); Check(read.Data["fixture-key-base"] == "secret-fixture-overlay" && h.ReadLocalSnapshot().Data["fixture-key-base"] == "secret-fixture-new", "detached"); h.Delete("fixture-key-base"); Check(!h.ReadLocalSnapshot().Data.ContainsKey("fixture-key-base") && f.Writes == 0, "delete overlay; no implicit send"); } finally { await b.CloseAsync(); } Check(s.IsAuthenticated, "bridge does not own login"); } static async Task RejectAndReopen() { using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache(); using var unauth = new S101AccountSession(Api); Check((await b.OpenAsync(unauth, root, "101", Api, 1, _ => true)).OwnerError == PlayerKVSessionOpenError.Unauthenticated, "unauth"); Check((await b.OpenAsync(s, root, "102", Api, 1, _ => true)).OwnerError == PlayerKVSessionOpenError.Stale, "id mismatch"); Check((await b.OpenAsync(s, root, "101", new Uri("http://other.invalid/"), 1, _ => true)).OwnerError == PlayerKVSessionOpenError.Stale, "API mismatch"); using var cancel = new CancellationTokenSource(); cancel.Cancel(); Check(!(await Open(b, s, root, token: cancel.Token)).Success && f.Factories == 0 && f.Writes == 0, "pre-cancel no handler"); Good(await Open(b, s, root)); await b.CloseAsync(); } static async Task InitialFailure() { using var s = await Login("101"); var f = new Fake { FailGet = true }; var b = f.Bridge; var root = Cache(); Check((await Open(b, s, root)).OwnerError == PlayerKVSessionOpenError.ReadFailed && f.Active == 0, "initial failure drained"); f.FailGet = false; Good(await Open(b, s, root)); await b.CloseAsync(); } static async Task OpenErrors() { using var s = await Login("101"); var f = new Fake(); var root = Cache(); var first = f.Bridge; Good(await Open(first, s, root)); var b = f.Bridge; Check((await Open(b, s, root)).OwnerError == PlayerKVSessionOpenError.Busy, "busy"); await first.CloseAsync(); Good(await Open(b, s, root)); await b.CloseAsync(); var broken = Cache(); var setup = PlayerKVSyncState.Open(broken, Api, "101"); Check(setup.Success, "corrupt setup"); setup.State.Reconcile(new Dictionary()); setup.State.Dispose(); File.WriteAllText(Directory.GetFiles(broken, "state.json", SearchOption.AllDirectories).Single(), "{"); Check((await Open(b, s, broken)).OwnerError == PlayerKVSessionOpenError.RecoveryRequired, "corrupt no snapshot"); var path = Path.Combine(Cache(), "file"); File.WriteAllText(path, "synthetic"); Check((await Open(b, s, path)).OwnerError == PlayerKVSessionOpenError.StorageError, "storage error no snapshot"); Good(await Open(b, s, Cache())); await b.CloseAsync(); } static void Seed(string root, bool unknown) { var state = PlayerKVSyncState.Open(root, Api, "101"); Check(state.Success, "seed"); using (state.State) { state.State.Reconcile(new Dictionary { ["fixture-key"] = "secret-fixture-base" }); state.State.Update("fixture-key", "secret-fixture-pending"); if (unknown) { state.State.BeginBatch(long.MaxValue, out var batch); state.State.FailBatch(batch.Id, true); } } } static async Task NonReady() { using var s = await Login("101"); foreach (var unknown in new[] { false, true }) { var root = Cache(); Seed(root, unknown); var f = new Fake(); f.Remote["101"]["fixture-key"] = "secret-fixture-other"; var b = f.Bridge; var h = Good(await Open(b, s, root)); Check(h.Status == (unknown ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.Conflict), "non-ready preserved"); var read = h.ReadLocalSnapshot(); Check(!read.Success && read.Error == PlayerKVLocalReadError.NotReady && read.Data == null, "no pretend empty save"); await b.CloseAsync(); } var fake = new Fake(); var bridge = fake.Bridge; var cache = Cache(); var handle = Good(await Open(bridge, s, cache)); var main = Directory.GetFiles(cache, "state.json", SearchOption.AllDirectories).Single(); Directory.CreateDirectory(main + ".tmp"); Check(!handle.Update("fixture-key", "secret-fixture").Accepted && handle.Status == PlayerKVSyncStatus.StorageError, "disk fault"); Check(handle.ReadLocalSnapshot().Error == PlayerKVLocalReadError.NotReady, "storage refuses read"); await bridge.CloseAsync(); } static async Task CloseFailure() { using var s = await Login("101"); var f = new Fake { ThrowDispose = true }; var b = f.Bridge; var root = Cache(); var h = Good(await Open(b, s, root)); var closing = b.CloseAsync(); try { await closing; Check(false, "close should fault"); } catch (IOException) { } Check(ReferenceEquals(closing, b.CloseAsync()) && h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing, "failed close retained"); Check((await Open(b, s, root)).Error == PlayerKVGameOpenError.CloseFailed && f.Factories == 1, "failed close blocks open"); var probe = PlayerKVSyncState.Open(root, Api, "101"); Check(!probe.Success && probe.Error == PlayerKVOpenError.Busy, "failed close does not pretend released"); } static async Task CreateCleanupFailure() { using var s = await Login("101"); var f = new Fake { FailGet = true, ThrowDispose = true }; var b = f.Bridge; var root = Cache(); Check((await Open(b, s, root)).Error == PlayerKVGameOpenError.CloseFailed, "create cleanup failure"); f.FailGet = false; Check((await Open(b, s, root)).Error == PlayerKVGameOpenError.CloseFailed && f.Factories == 1, "create failure latch"); } static async Task LateClose() { using var s = await Login("101"); using var s2 = await Login("102"); var f = new Fake(); var entered = Gate(); var release = Gate(); var cancellation = Gate(); f.Before = async (id, r, token) => { if (id == "101" && r.RequestUri.AbsolutePath.EndsWith("/Set")) { using var reg = token.Register(() => cancellation.TrySetResult(true)); entered.TrySetResult(true); await release.Task; } }; var b = f.Bridge; var root = Cache(); var old = Good(await Open(b, s, root)); var edit = old.Update("fixture-key", "secret-fixture"); var flush = old.FlushAsync(); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var close = b.CloseAsync(); Check(ReferenceEquals(close, b.CloseAsync()), "idempotent close"); using var queuedCancel = new CancellationTokenSource(); var cancelledOpen = Open(b, s2, root, 2, token: queuedCancel.Token); queuedCancel.Cancel(); Check((await cancelledOpen.WaitAsync(TimeSpan.FromSeconds(5))).Error == PlayerKVGameOpenError.Cancelled, "queued cancel returns promptly"); var next = Open(b, s2, root, 3); try { await cancellation.Task.WaitAsync(TimeSpan.FromSeconds(5)); Check(!close.IsCompleted && !next.IsCompleted && f.Factories == 1, "drain barrier"); Check(!old.Update("fixture-key", "secret-fixture-late").Accepted && old.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing, "revoked synchronously"); } finally { release.TrySetResult(true); } await close; await flush; var current = Good(await next); var writes = f.Writes; Check(!old.Update("fixture-key", "secret-fixture-late").Accepted && !old.Delete("fixture-key").Accepted, "old edits refused"); Check((await old.FlushAsync()).Blocked > 0 && (await old.WaitForTicketAsync(edit.Ticket)).Status == PlayerKVTicketStatus.Blocked, "old async calls refused"); Check(current.PlayerId == "102" && current.ReadLocalSnapshot().Data.Count == 0 && f.Writes == writes, "102 unaffected"); Check(f.MaxActive == 1, "single owner transport"); await b.CloseAsync(); } static async Task Repeat() { using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache(); var a = Open(b, s, root); var c = Open(b, s, root); var both = await Task.WhenAll(a, c); var old = Good(both[0]); var latest = Good(both[1]); Check(old.OwnerInstanceId != latest.OwnerInstanceId && !old.ReadLocalSnapshot().Success && latest.ReadLocalSnapshot().Success && f.MaxActive == 1, "repeat open serialized"); var x = b.CloseAsync(); var y = b.CloseAsync(); Check(ReferenceEquals(x, y), "same close task"); await x; } static async Task ReadDuringFlush() { using var s = await Login("101"); var f = new Fake(); f.Remote["101"]["fixture-key-base"] = "secret-fixture-base"; var entered = Gate(); var release = Gate(); f.Before = async (_, r, _) => { if (r.RequestUri.AbsolutePath.EndsWith("/Set")) { entered.TrySetResult(true); await release.Task; } }; var b = f.Bridge; var h = Good(await Open(b, s, Cache())); h.Update("fixture-key", "secret-fixture-pending"); var flush = h.FlushAsync(); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); try { h.Update("fixture-key", "secret-fixture-overlay", false); var writes = f.Writes; var read = h.ReadLocalSnapshot(); Check(read.Success && read.Data["fixture-key"] == "secret-fixture-overlay" && read.Data["fixture-key-base"] == "secret-fixture-base", "inflight merged view"); Check(f.Writes == writes && !flush.IsCompleted, "read does not dispatch or finish batch"); } finally { release.TrySetResult(true); } Check((await flush).Confirmed == 1, "original batch confirmed"); Check(f.Remote["101"]["fixture-key"] == "secret-fixture-pending" && h.ReadLocalSnapshot().Data["fixture-key"] == "secret-fixture-overlay", "read never altered batch"); await b.CloseAsync(); } static async Task ReadEpoch() { using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var armed = false; var checks = 0; var h = Good(await Open(b, s, Cache(), current: _ => !armed || ++checks < 2)); armed = true; Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing && checks == 2, "read revalidates after snapshot"); await b.CloseAsync(); } static async Task NonReadyPriority() { using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache(); Seed(root, true); var armed = false; var checks = 0; var h = Good(await Open(b, s, root, current: _ => !armed || ++checks < 2)); armed = true; Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing && checks == 2, "stale outranks NotReady exception"); await b.CloseAsync(); } static async Task ReadCloseReentry() { using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var armed = false; var checks = 0; Task close = null; var h = Good(await Open(b, s, Cache(), current: _ => { if (armed && ++checks == 2) { close = b.CloseAsync(); Check(!close.IsCompleted, "close must wait active read lease"); } return true; })); armed = true; Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing, "read cannot publish after reentrant close"); Check(close != null, "close boundary exercised"); await close; Check(f.Active == 0 && !h.ReadLocalSnapshot().Success, "read lease released for close"); } static async Task WriteUnknown() { using var s = await Login("101"); var f = new Fake { FailWrite = true }; var b = f.Bridge; var h = Good(await Open(b, s, Cache())); var edit = h.Update("fixture-key", "secret-fixture"); Check((await h.FlushAsync()).Unknown == 1, "structured unknown"); Check((await h.WaitForTicketAsync(edit.Ticket)).Status == PlayerKVTicketStatus.Unknown, "unknown ticket preserved"); Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.NotReady && h.ReadLocalSnapshot().Data == null, "unknown cannot masquerade as empty"); await h.FlushAsync(); Check(f.Writes == 1, "no unknown retry"); await b.CloseAsync(); } static async Task SessionInvalidation() { foreach (var dispose in new[] { false, true }) { using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var h = Good(await Open(b, s, Cache())); var edit = h.Update("fixture-key", "secret-fixture"); if (dispose) s.Dispose(); else Check((await s.LoginAsync("101")).Success, "replace cookie by relogin"); Check(!h.ReadLocalSnapshot().Success && !h.Update("fixture-key", "secret-fixture-late").Accepted, "session invalidates handle"); Check((await h.WaitForTicketAsync(edit.Ticket)).Status == PlayerKVTicketStatus.Blocked, "invalid session ticket denied"); await b.CloseAsync(); Check(f.Writes == 0 && f.Active == 0, "invalid session drains without writes"); } } static async Task PendingReopen() { using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache(); var h = Good(await Open(b, s, root)); h.Update("fixture-key", "secret-fixture-pending"); await b.CloseAsync(); Check(f.Writes == 0, "close never flushes"); using var next = await Login("101"); var reopened = Good(await Open(b, next, root, 2)); Check(reopened.ReadLocalSnapshot().Data["fixture-key"] == "secret-fixture-pending", "pending recovered"); Check((await reopened.FlushAsync()).Confirmed == 1, "recovered intent confirmed"); await b.CloseAsync(); } static Task DefaultTransport() { var b = new PlayerKVGameSessionBridge(); var factory = (Func)typeof(PlayerKVGameSessionBridge) .GetField("handlerFactory", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(b); var jar = new CookieContainer(); using var h = (HttpClientHandler)factory(jar); // Inspect configuration only, never send. Check(h.UseCookies && ReferenceEquals(h.CookieContainer, jar) && !h.AllowAutoRedirect, "production transport configuration"); return b.CloseAsync(); } static async Task CloseDuringCreate() { using var s = await Login("101"); var f = new Fake(); var enter = Gate(); var release = Gate(); f.Before = async (_, _, _) => { enter.TrySetResult(true); await release.Task; }; var b = f.Bridge; var root = Cache(); var opening = Open(b, s, root); await enter.Task.WaitAsync(TimeSpan.FromSeconds(5)); var close = b.CloseAsync(); try { Check(!opening.IsCompleted && !close.IsCompleted, "create drain cannot be detached"); } finally { release.TrySetResult(true); } Check(!(await opening).Success, "revoked initialization never published"); await close; f.Before = null; Good(await Open(b, s, root)); await b.CloseAsync(); } }