188 lines
11 KiB
C#
188 lines
11 KiB
C#
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 <manifest path>.");
|
|
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<Manifest>(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<string>();
|
|
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<string, string> Business(IReadOnlyDictionary<string, string> data) =>
|
|
data.Where(x => !manifest.Keys.Contains(x.Key)).ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal);
|
|
bool Equal(IReadOnlyDictionary<string, string> a, IReadOnlyDictionary<string, string> b) =>
|
|
a.Count == b.Count && a.All(x => b.TryGetValue(x.Key, out var value) && value == x.Value);
|
|
var baseline = new Dictionary<string, Dictionary<string, string>>();
|
|
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<string, string>()); // no payload even if server auth is broken
|
|
var del = await anonymous.DeleteAsync(Array.Empty<string>());
|
|
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<string, string> { [shared] = "101-v1", [empty] = "", [json] = "{\"fixture\":1}" };
|
|
var data102 = new Dictionary<string, string> { [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<string, string> { [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<string>(), 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<string, string>(), first.Token), "Empty Set");
|
|
Success(await first.Kv.DeleteAsync(Array.Empty<string>(), 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<string> 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<OwnedSession> 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();
|
|
}
|
|
}
|