using System.Net; using System.Text; using game.PlayerDataStorage; using Newtonsoft.Json.Linq; var passed = 0; async Task Check(string name, Func action) { await action(); Console.WriteLine("PASS " + name); passed++; } void Assert(bool ok, string message) { if (!ok) throw new Exception(message); } async Task<(PlayerKVResult result, Probe handler)> Get(string body, int status = 200) { var handler = new Probe(body, status); using var http = new HttpClient(handler); var result = await new PlayerKVClient(http, new Uri("http://example.test/api")).GetAsync(); return (result, handler); } await Check("Get preserves string/empty/JSON values and full-read request", async () => { var (r, h) = await Get("{\"data\":{\"empty\":\"\",\"json\":\"{\\\"v\\\":1}\"}}"); Assert(r.Success && r.Data["empty"] == "" && r.Data["json"] == "{\"v\":1}", "Values changed"); Assert(h.Url == "http://example.test/api/playerdata/Get" && h.Body == "{}", "Wrong route/body"); }); await Check("Empty data is valid", async () => Assert((await Get("{\"data\":{}}")).result.Success, "Empty rejected")); foreach (var body in new[] { "", "null", "{}", "{\"data\":null}", "{\"data\":[]}", "{\"data\":{\"x\":1}}", "{\"data\":{\"x\":null}}", "not-json" }) await Check("Invalid Get response " + body, async () => { var (r, _) = await Get(body); Assert(!r.Success && r.Error == PlayerKVError.Protocol && r.Data == null, "Invalid response accepted"); }); await Check("Filtered read preserves keys", async () => { var h = new Probe("{\"data\":{}}"); using var http = new HttpClient(h); await new PlayerKVClient(http, new Uri("http://example.test/")).GetAsync(new[] { "missing", "Lv" }); Assert(JObject.Parse(h.Body)["keys"].Count() == 2, "Missing keys"); }); await Check("Set/Delete accept empty 200 and preserve payload", async () => { var h = new Probe(""); using var http = new HttpClient(h); var kv = new PlayerKVClient(http, new Uri("http://example.test/")); var r = await kv.SetAsync(new Dictionary { ["key"] = "{\"v\":1}" }); Assert(r.Success && h.Url.EndsWith("/Set") && (string)JObject.Parse(h.Body)["data"]["key"] == "{\"v\":1}", "Set failed"); Assert((await kv.DeleteAsync(new[] { "key" })).Success && h.Url.EndsWith("/Delete"), "Delete failed"); }); foreach (var status in new[] { 401, 403, 400, 500, 302 }) await Check("HTTP failure " + status, async () => { var (r, _) = await Get("", status); Assert(!r.Success && r.StatusCode == status && r.Error == (status == 401 || status == 403 ? PlayerKVError.Unauthorized : PlayerKVError.Http), "Wrong HTTP result"); }); await Check("Invalid write/key rejected before HTTP", async () => { var h = new Probe(""); using var http = new HttpClient(h); var kv = new PlayerKVClient(http, new Uri("http://example.test/")); Assert((await kv.SetAsync(null)).Error == PlayerKVError.Validation, "Null write"); Assert((await kv.SetAsync(new Dictionary { ["x"] = null })).Error == PlayerKVError.Validation, "Null value"); Assert((await kv.GetAsync(new[] { new string('a',129) })).Error == PlayerKVError.Validation, "Long key"); Assert((await kv.DeleteAsync(null)).Error == PlayerKVError.Validation && h.Calls == 0, "Unexpected request"); }); foreach (var mode in new[] { "network", "timeout", "cancel" }) await Check(mode + " failure", async () => { var h = new Probe("") { Mode = mode }; using var http = new HttpClient(h); using var cts = new CancellationTokenSource(); if(mode == "cancel") cts.Cancel(); var r = await new PlayerKVClient(http,new Uri("http://example.test/")).GetAsync(null,cts.Token); var expected = mode == "network" ? PlayerKVError.Network : mode == "timeout" ? PlayerKVError.Timeout : PlayerKVError.Cancelled; Assert(!r.Success && r.Error == expected, "Wrong exception mapping"); }); foreach (var body in new[] { "{\"data\":{}} {}", "{\"data\":{\"x\":\"a\",\"x\":\"b\"}}", "{\"data\":{},\"data\":{}}", "{\"data\":{\"x\":true}}" }) await Check("Reject ambiguous or non-string response", async () => { var (r, _) = await Get(body); Assert(r.Error == PlayerKVError.Protocol && r.Data == null, "Ambiguous data accepted"); }); await Check("Date-shaped values remain strings; key comparison is ordinal", async () => { var (r, _) = await Get("{\"data\":{\"A\":\"2026-09-22T00:00:00Z\",\"a\":\"different\"}}"); Assert(r.Success && r.Data["A"] == "2026-09-22T00:00:00Z" && r.Data["a"] == "different", "Value coercion"); try { ((IDictionary)r.Data).Add("mutated", "x"); throw new Exception("Mutable result"); } catch (NotSupportedException) { } }); await Check("Empty operations preserve server authentication and full-read semantics", async () => { var h = new Probe("",401); using var http = new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/")); Assert((await kv.GetAsync(Array.Empty())).Error==PlayerKVError.Unauthorized && h.Body=="{}", "Empty Get"); Assert((await kv.SetAsync(new Dictionary())).Error==PlayerKVError.Unauthorized && h.Body=="{\"data\":{}}", "Empty Set bypassed auth"); Assert((await kv.DeleteAsync(Array.Empty())).Error==PlayerKVError.Unauthorized && h.Body=="{\"keys\":[]}" && h.Calls==3, "Empty Delete bypassed auth"); }); await Check("128-char keys and empty strings allowed; empty/null/129-char keys rejected", async () => { var h=new Probe("");using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/")); Assert((await kv.SetAsync(new Dictionary{{new string('k',128),""}})).Success,"Valid boundary rejected"); foreach(var key in new[]{"",null,new string('k',129)}) { Assert((await kv.GetAsync(new[]{key})).Error==PlayerKVError.Validation,"Invalid read key"); Assert((await kv.DeleteAsync(new[]{key})).Error==PlayerKVError.Validation,"Invalid delete key"); } Assert(h.Calls==1,"Validation sent requests"); }); foreach(var action in new[]{"Get","Set","Delete"}) await Check(action+" payload is snapshotted before caller mutation",async()=>{ var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/api/")); var keys=new List{"original"};var data=new Dictionary{{"original","old"}}; var pending=action=="Get"?kv.GetAsync(keys):action=="Set"?kv.SetAsync(data):kv.DeleteAsync(keys); keys[0]="changed";data["original"]="new";data["added"]="extra"; h.Gate.SetResult(true);var r=await pending; Assert(r.Success && h.Body.Contains("original") && !h.Body.Contains("changed") && !h.Body.Contains("extra") && !h.Body.Contains("new"),"Mutable request leaked"); Assert(h.Url=="http://example.test/api/playerdata/"+action,"Wrong absolute route"); }); foreach(var action in new[]{"Set","Delete"}) foreach(var failure in new[]{"network","timeout","http"}) await Check(action+" "+failure+" is not retried and has unknown commit outcome",async()=>{ var h=new Probe("",failure=="http"?500:200){Mode=failure};using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/")); var r=action=="Set"?await kv.SetAsync(new Dictionary{{"x","y"}}):await kv.DeleteAsync(new[]{"x"}); Assert(!r.Success && r.MayHaveCommitted && r.Data==null && h.Calls==1,"Unsafe failure/retry semantics"); }); await Check("Pre-cancelled write never dispatches; in-flight cancellation may have committed",async()=>{ using var cts=new CancellationTokenSource();cts.Cancel();var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/")); var before=await kv.DeleteAsync(new[]{"x"},cts.Token); Assert(before.Error==PlayerKVError.Cancelled && !before.MayHaveCommitted && h.Calls==0,"Pre-cancel dispatch"); using var running=new CancellationTokenSource();var task=kv.SetAsync(new Dictionary{{"x","y"}},running.Token); running.Cancel();h.Gate.SetResult(true);var after=await task; Assert(after.Error==PlayerKVError.Cancelled && after.MayHaveCommitted && h.Calls==1,"Cancelled mutation reported success"); }); await Check("Cancelled read cannot publish a late successful result",async()=>{ using var cts=new CancellationTokenSource();var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/")); var task=kv.GetAsync(null,cts.Token);cts.Cancel();h.Gate.SetResult(true);var r=await task; Assert(r.Error==PlayerKVError.Cancelled && r.Data==null && !r.MayHaveCommitted,"Late read published"); }); await Check("Adapter preserves injected client configuration and ownership; disposes messages",async()=>{ var h=new DeferredProbe();using var http=new HttpClient(h){BaseAddress=new Uri("http://other.test/")};http.DefaultRequestHeaders.Add("X-Test","preserve"); var kv=new PlayerKVClient(http,new Uri("http://example.test/"));var task=kv.GetAsync();h.Gate.SetResult(true);await task; Assert(http.BaseAddress.AbsoluteUri=="http://other.test/" && http.DefaultRequestHeaders.GetValues("X-Test").Single()=="preserve","Shared configuration mutated"); Assert(h.ResponseBody.Disposed,"Response not disposed"); try{await h.Request.Content.ReadAsStringAsync();throw new Exception("Request not disposed");}catch(ObjectDisposedException){} using var stillAlive=await http.GetAsync("probe");Assert(stillAlive.IsSuccessStatusCode,"Injected client disposed"); }); await Check("Disposed owner transport returns cancellation without logging payload",async()=>{ var http=new HttpClient(new Probe(""));var kv=new PlayerKVClient(http,new Uri("http://example.test/"));http.Dispose(); Assert((await kv.GetAsync()).Error==PlayerKVError.Cancelled,"Disposed owner failed classification"); }); await Check("Reject invalid base URIs before any request",()=>{ using var http=new HttpClient(new Probe("")); foreach(var uri in new Uri[]{null,new Uri("relative",UriKind.Relative),new Uri("ftp://example.test/"),new Uri("http://example.test/?q=x"),new Uri("http://example.test/#x"),new Uri("http://user:secret@example.test/")}) { try{new PlayerKVClient(http,uri);throw new Exception("Invalid base allowed");}catch(ArgumentException){} } return Task.CompletedTask; }); Console.WriteLine($"Passed {passed} tests"); sealed class Probe : HttpMessageHandler { public string Url, Body, Mode; public int Calls; readonly string response; readonly int status; public Probe(string response, int status = 200) { this.response=response; this.status=status; } protected override async Task SendAsync(HttpRequestMessage req, CancellationToken token) { Calls++; Url=req.RequestUri.AbsoluteUri; Body=await req.Content.ReadAsStringAsync(); if(Mode == "network") throw new HttpRequestException("simulated"); if(Mode == "timeout" || Mode == "cancel") throw new TaskCanceledException(); return new HttpResponseMessage((HttpStatusCode)status) { Content=new StringContent(response,Encoding.UTF8,"application/json") }; } } sealed class DeferredProbe : HttpMessageHandler { public readonly TaskCompletionSource Gate=new(); public int Calls;public string Body,Url;public HttpRequestMessage Request;public TrackedContent ResponseBody; protected override async Task SendAsync(HttpRequestMessage request,CancellationToken token) { Calls++;Request=request;await Gate.Task; // deliberately returns even if the caller cancelled Url=request.RequestUri.AbsoluteUri;Body=request.Content==null?"":await request.Content.ReadAsStringAsync(); ResponseBody=new TrackedContent(Url.EndsWith("/Get")?"{\"data\":{}}":""); return new HttpResponseMessage(HttpStatusCode.OK){Content=ResponseBody}; } } sealed class TrackedContent(string body) : StringContent(body) { public bool Disposed; protected override void Dispose(bool disposing){Disposed=true;base.Dispose(disposing);} }