Files
ft/CodexOutput/upserver/KVGameBridgeTests/Program.cs

380 lines
23 KiB
C#

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<object> Results = new();
static int assertions, failures;
static async Task<int> 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<Task> 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<bool> 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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send;
readonly Action dispose;
internal Handler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send, Action dispose = null)
{ this.send = send; this.dispose = dispose; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token);
protected override void Dispose(bool disposing) { if (disposing) dispose?.Invoke(); base.Dispose(disposing); }
}
static async Task<S101AccountSession> 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<string, Dictionary<string, string>> Remote = new() { ["101"] = new(), ["102"] = new() };
internal int Gets, Writes, Active, MaxActive, Factories;
internal bool FailGet, FailWrite, ThrowDispose;
internal Func<string, HttpRequestMessage, CancellationToken, Task> 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<string>()) remote.Remove(key);
return Json(HttpStatusCode.OK, "{}");
}, () => { Active--; if (ThrowDispose) throw new IOException("synthetic dispose fault"); });
}
}
static Task<PlayerKVGameOpenResult> Open(PlayerKVGameSessionBridge b, S101AccountSession s, string root,
long epoch = 1, Func<long, bool> 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<string, string>)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<string, string>()); 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<string, string> { ["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<CookieContainer, HttpMessageHandler>)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();
}
}