380 lines
23 KiB
C#
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();
|
|
}
|
|
} |