using System.Net; using System.Text.Json; using System.Text.RegularExpressions; using game.Account; using game.PlayerDataStorage; const string Api = "http://s101.local:18080/"; var folder = Path.GetFullPath("CodexOutput/upserver/KVLiveTests"); Directory.CreateDirectory(folder); var cleanupOnly = args.Length == 2 && args[0] == "--cleanup"; if (!cleanupOnly && (args.Length != 1 || args[0] != "--run-s101")) throw new ArgumentException("Use --run-s101 or --cleanup ."); var prefix = "codex_kv_s2_" + DateTime.UtcNow.ToString("yyyyMMdd") + "_" + Guid.NewGuid().ToString("N"); var manifestPath = cleanupOnly ? Path.GetFullPath(args[1]) : Path.Combine(folder, prefix + ".manifest.json"); var manifest = cleanupOnly ? JsonSerializer.Deserialize(File.ReadAllText(manifestPath)) : new Manifest { Api = Api, Prefix = prefix, Keys = new[] { prefix + "_shared", prefix + "_empty", prefix + "_json", prefix + "_missing" } }; if (manifest.Api != Api || !Regex.IsMatch(manifest.Prefix, "^codex_kv_s2_[0-9]{8}_[0-9a-f]{32}$") || !manifest.Accounts.SequenceEqual(new[] { "101", "102" }) || !manifest.Keys.SequenceEqual(new[] { "_shared", "_empty", "_json", "_missing" }.Select(s => manifest.Prefix + s))) throw new ArgumentException("Manifest outside authorized account/key scope."); void Save() => File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true })); Save(); // Written before any possible mutation; records exact cleanup scope, never cookies/values. var passed = new List(); bool failed = false; void Check(bool ok, string name) { if (!ok) throw new InvalidOperationException(name); passed.Add(name); Console.WriteLine("PASS " + name); } void Success(PlayerKVResult result, string name) { if (result.MayHaveCommitted) { manifest.HadUnknownWrite = true; Save(); } if (!result.Success) throw new InvalidOperationException(name + ": " + result.Error + "/" + result.StatusCode); } Dictionary Business(IReadOnlyDictionary data) => data.Where(x => !manifest.Keys.Contains(x.Key)).ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); bool Equal(IReadOnlyDictionary a, IReadOnlyDictionary b) => a.Count == b.Count && a.All(x => b.TryGetValue(x.Key, out var value) && value == x.Value); var baseline = new Dictionary>(); try { if (!cleanupOnly) { Console.WriteLine("Endpoint=" + Api + "; accounts=101,102; fixture=" + manifest.Prefix); using (var anonymousHttp = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }) { Timeout = TimeSpan.FromSeconds(15) }) { var anonymous = new PlayerKVClient(anonymousHttp, new Uri(Api)); var get = await anonymous.GetAsync(manifest.Keys); var set = await anonymous.SetAsync(new Dictionary()); // no payload even if server auth is broken var del = await anonymous.DeleteAsync(Array.Empty()); Check(get.Error == PlayerKVError.Unauthorized && set.Error == PlayerKVError.Unauthorized && del.Error == PlayerKVError.Unauthorized, "No Cookie rejected for Get/empty Set/empty Delete"); } using var first = await OwnedSession.Open("101", Api); using var second = await OwnedSession.Open("102", Api); foreach (var owner in new[] { first, second }) { var before = await owner.Kv.GetAsync(manifest.Keys, owner.Token); Success(before, "Collision preflight"); Check(before.Data.Count == 0, owner.Id + " unique fixture keys absent before writes"); var all = await owner.Kv.GetAsync(null, owner.Token); Success(all, "Initial full Get"); baseline[owner.Id] = Business(all.Data); // in memory only; never output real player data } manifest.CleanupAllowed = true; Save(); var shared = manifest.Keys[0]; var empty = manifest.Keys[1]; var json = manifest.Keys[2]; var missing = manifest.Keys[3]; var data101 = new Dictionary { [shared] = "101-v1", [empty] = "", [json] = "{\"fixture\":1}" }; var data102 = new Dictionary { [shared] = "102-v1", [empty] = "", [json] = "{\"fixture\":2}" }; var write = await first.Kv.SetAsync(data101, first.Token); Success(write, "101 Set"); Check(write.StatusCode == 200, "101 initial Set returns 200"); var read = await first.Kv.GetAsync(manifest.Keys, first.Token); Success(read, "101 filtered Get"); Check(Equal(read.Data, data101), "101 strings/empty/JSON round-trip; missing key omitted"); Success(await second.Kv.SetAsync(data102, second.Token), "102 Set"); var other = await second.Kv.GetAsync(manifest.Keys, second.Token); Success(other, "102 Get"); read = await first.Kv.GetAsync(manifest.Keys, first.Token); Success(read, "101 isolation Get"); Check(Equal(other.Data, data102) && Equal(read.Data, data101), "Same key names isolated between 101 and 102"); Success(await first.Kv.SetAsync(new Dictionary { [shared] = "101-v2" }, first.Token), "101 overwrite"); data101[shared] = "101-v2"; read = await first.Kv.GetAsync(manifest.Keys, first.Token); Success(read, "101 read overwrite"); Check(Equal(read.Data, data101), "Overwrite affects only requested key"); var allNull = await first.Kv.GetAsync(null, first.Token); Success(allNull, "Full Get null"); var allEmpty = await first.Kv.GetAsync(Array.Empty(), first.Token); Success(allEmpty, "Full Get empty"); Check(Equal(allNull.Data, allEmpty.Data) && data101.All(x => allNull.Data.TryGetValue(x.Key, out var v) && v == x.Value), "Null/empty key list both return full dictionary"); Success(await first.Kv.SetAsync(new Dictionary(), first.Token), "Empty Set"); Success(await first.Kv.DeleteAsync(Array.Empty(), first.Token), "Empty Delete"); Success(await first.Kv.DeleteAsync(new[] { missing }, first.Token), "Delete missing"); read = await first.Kv.GetAsync(manifest.Keys, first.Token); Success(read, "No-op verification"); Check(Equal(read.Data, data101), "Empty mutations and deleting missing key preserve fixture"); foreach (var id in manifest.Accounts) { using var relogin = await OwnedSession.Open(id, Api); var persisted = await relogin.Kv.GetAsync(manifest.Keys, relogin.Token); Success(persisted, "Relogin Get"); Check(Equal(persisted.Data, id == "101" ? data101 : data102), id + " persisted across new login/Cookie/HttpClient"); } Success(await first.Kv.DeleteAsync(manifest.Keys, first.Token), "101 Delete"); read = await first.Kv.GetAsync(manifest.Keys, first.Token); Success(read, "101 after Delete"); other = await second.Kv.GetAsync(manifest.Keys, second.Token); Success(other, "102 after other account Delete"); Check(read.Data.Count == 0 && Equal(other.Data, data102), "Delete removes only authenticated account fixture"); } } catch (Exception ex) { failed = true; // Exceptions from transport may contain URLs, but never print server body/cookie/request values. Console.WriteLine("FAIL " + ex.GetType().Name + ": " + (ex is InvalidOperationException ? ex.Message : "transport or test execution failed")); } finally { if (manifest.CleanupAllowed) { foreach (var id in manifest.Accounts) { manifest.CleanedAccounts.Remove(id); Save(); // Re-verify this attempt; never reuse old cleanup evidence. try { using var clean = await OwnedSession.Open(id, Api); Success(await clean.Kv.DeleteAsync(manifest.Keys, clean.Token), "Cleanup Delete " + id); var remaining = await clean.Kv.GetAsync(manifest.Keys, clean.Token); Success(remaining, "Cleanup readback " + id); Check(remaining.Data.Count == 0, id + " cleanup verified: all fixture keys absent"); manifest.CleanedAccounts.Add(id); Save(); if (baseline.TryGetValue(id, out var original)) { var after = await clean.Kv.GetAsync(null, clean.Token); Success(after, "Business readback"); Check(Equal(Business(after.Data), original), id + " existing non-fixture KV unchanged (values not logged)"); } } catch (Exception ex) { failed = true; Console.WriteLine("CLEANUP FAILED account=" + id + "; " + ex.GetType().Name + "; retain manifest for recovery"); } } } manifest.Completed = !failed && !manifest.HadUnknownWrite && manifest.Accounts.All(manifest.CleanedAccounts.Contains); Save(); File.WriteAllText(Path.Combine(folder, "Latest-result.json"), JsonSerializer.Serialize(new { api = Api, manifest = Path.GetFileName(manifestPath), completed = manifest.Completed, cleanupVerified = manifest.Accounts.All(manifest.CleanedAccounts.Contains), manifest.HadUnknownWrite, passedCount = passed.Count, passed }, new JsonSerializerOptions { WriteIndented = true })); } Console.WriteLine("Manifest=" + Path.GetFileName(manifestPath) + "; completed=" + manifest.Completed); return manifest.Completed ? 0 : 1; sealed class Manifest { public string Api { get; set; } public string Prefix { get; set; } public string[] Accounts { get; set; } = new[] { "101", "102" }; public string[] Keys { get; set; } public bool CleanupAllowed { get; set; } public HashSet CleanedAccounts { get; set; } = new(); public bool HadUnknownWrite { get; set; } public bool Completed { get; set; } } sealed class OwnedSession : IDisposable { public string Id { get; private set; } public PlayerKVClient Kv { get; private set; } readonly CancellationTokenSource lifetime = new(); S101AccountSession account; HttpClient transport; public CancellationToken Token => lifetime.Token; public static async Task Open(string id, string api) { if ((id != "101" && id != "102") || api != "http://s101.local:18080/") throw new ArgumentException("Unauthorized test target"); var owner = new OwnedSession { Id = id }; try { owner.account = new S101AccountSession(new Uri(api)); var result = await owner.account.LoginAsync(id, owner.Token); if (!result.Success || owner.account.PlayerId != id || owner.account.NewlyCreated) throw new InvalidOperationException("Existing test account login failed: " + result.Error); owner.transport = new HttpClient(new HttpClientHandler { CookieContainer = owner.account.Cookies, UseCookies = true, AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(15) }; owner.Kv = new PlayerKVClient(owner.transport, owner.account.ApiBaseUri); return owner; } catch { owner.Dispose(); throw; } } public void Dispose() { // All test requests are awaited before disposal; no background writes or retries exist. lifetime.Cancel(); transport?.Dispose(); account?.Dispose(); lifetime.Dispose(); } }