Files
ft/CodexOutput/upserver/KVGameplayLoopTests/Preflight/Import101Tests.cs

232 lines
16 KiB
C#

using game.Account;
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Net;
using System.Text;
internal static class Import101Tests
{
static int assertions;
static readonly List<string> assertionFailures = new();
static void Check(bool condition, string message)
{
assertions++;
if (!condition)
{
assertionFailures.Add(message);
throw new InvalidOperationException("ASSERT: " + message);
}
}
static JObject Window() => new JObject {
["playerId"] = "101", ["api"] = Import101.Api, ["windowId"] = "offline-fixture",
["operator"] = "Import101Tests", ["testOnly"] = true,
["startUtc"] = DateTime.UtcNow.AddMinutes(-1).ToString("o"),
["expiresUtc"] = DateTime.UtcNow.AddHours(1).ToString("o"),
["verificationMethods"] = new JArray("mock only; no real account window"),
["noOtherClientsDeclared"] = true, ["implementationReviewApproved"] = true
};
sealed class Mock
{
internal string Fault;
internal int Login, Get, Set;
internal JObject Window;
internal readonly Dictionary<string, string> Expected;
internal Dictionary<string, string> Stored = new(StringComparer.Ordinal);
internal string[] ReturnedOrder;
internal Mock(Dictionary<string, string> expected) { Expected = expected; }
internal HttpMessageHandler Handler(CookieContainer jar) => new HandlerImpl(this, jar);
sealed class HandlerImpl : HttpMessageHandler
{
readonly Mock mock;
readonly CookieContainer jar;
internal HandlerImpl(Mock mock, CookieContainer jar) { this.mock = mock; this.jar = jar; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
{
Check(request.Method == HttpMethod.Post, "only POST");
Check(request.RequestUri.AbsoluteUri.StartsWith(Import101.Api, StringComparison.Ordinal), "fixed API");
var body = JObject.Parse(await request.Content.ReadAsStringAsync(token));
HttpResponseMessage Reply(object value, HttpStatusCode status = HttpStatusCode.OK) => new(status) {
Content = new StringContent(JsonConvert.SerializeObject(value), Encoding.UTF8, "application/json")
};
switch (request.RequestUri.AbsolutePath)
{
case "/account/Login":
mock.Login++;
Check(mock.Login == 1 && mock.Get == 0 && mock.Set == 0, "one initial login");
Check((string)body["playerId"] == "101" && body.Count == 1, "only 101");
jar.Add(new Uri(Import101.Api), new Cookie("session", "offline", "/"));
if (mock.Fault == "login-http") return Reply(new { }, HttpStatusCode.Forbidden);
if (mock.Fault == "login-protocol") return Reply(new { });
return Reply(new { state = 0, internalId = 101, newCreate = mock.Fault == "new-account",
createTime = "2026-09-24T00:00:00Z", svrTime = "2026-09-28T07:30:00Z" });
case "/playerdata/Get":
mock.Get++;
Check(mock.Login == 1 && mock.Get <= 2 && (mock.Get == 1 ? mock.Set == 0 : mock.Set == 1), "Get order/bound");
Check(body.Count == 0, "full Get");
if (mock.Get == 1)
{
if (mock.Fault == "before-http") return Reply(new { }, HttpStatusCode.ServiceUnavailable);
if (mock.Fault == "nonempty") return Reply(new { data = new { existing = "untouched" } });
return Reply(new { data = mock.Stored });
}
if (mock.Fault == "after-http") return Reply(new { }, HttpStatusCode.ServiceUnavailable);
if (mock.Fault == "after-throw") throw new InvalidOperationException("mock after exception");
var after = mock.Stored.Reverse().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
if (mock.Fault == "missing") after.Remove(after.Keys.First());
if (mock.Fault == "extra") after.Add("test-extra", "x");
if (mock.Fault == "changed") after[after.Keys.First()] += " ";
if (mock.Fault == "window-ended") mock.Window["expiresUtc"] = DateTime.UtcNow.AddSeconds(-1).ToString("o");
mock.ReturnedOrder = after.Keys.ToArray();
return Reply(new { data = after });
case "/playerdata/Set":
mock.Set++;
Check(mock.Login == 1 && mock.Get == 1 && mock.Set == 1, "one Set after baseline");
Check(body.Count == 1 && body["data"] is JObject, "Set data only");
var data = (JObject)body["data"];
Check(data.Count == 55, "55 entries incl unknown keys");
foreach (var p in mock.Expected)
Check(data[p.Key]?.Type == JTokenType.String && (string)data[p.Key] == p.Value, "original string " + p.Key);
mock.Stored = data.Properties().ToDictionary(p => p.Name, p => (string)p.Value, StringComparer.Ordinal);
if (mock.Fault == "set-throw") throw new InvalidOperationException("mock uncaught transport exception");
if (mock.Fault == "unknown") return Reply(new { }, HttpStatusCode.ServiceUnavailable);
return Reply(new { });
default: throw new InvalidOperationException("Forbidden request (including PF/102/Delete): " + request.RequestUri);
}
}
}
}
internal static async Task Run()
{
assertions = 0;
assertionFailures.Clear();
var start = DateTime.UtcNow;
var suite = Path.GetFullPath(Import101.Root + "D2-tests/" + start.ToString("yyyyMMddTHHmmssZ") + "-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(suite);
var original = Import101.LoadInputs(new Import101.InputPaths());
var cases = new List<object>();
int realDecodes = 0;
var names = new[] { "source-hash", "manifest-missing-key", "manifest-duplicate-key", "manifest-value-change", "config-manifest-version",
"config-hash", "source-json-duplicate", "source-json-trailing", "window-not-approved", "window-expired", "null-handler",
"login-http", "login-protocol", "new-account", "before-http", "nonempty", "candidate-invalid",
"save-before", "save-candidate", "claim-existing", "claim-failure", "claim-partial", "claim-race", "window-after-claim", "save-account", "success-real-decode",
"unknown", "set-throw", "save-set-result", "after-http", "after-throw", "missing", "extra", "changed", "save-after",
"save-verification", "window-ended" };
foreach (var name in names)
{
var evidence = Path.Combine(suite, name);
Directory.CreateDirectory(evidence);
var paths = new Import101.InputPaths();
var window = Window();
var mock = new Mock(original.Raw) { Fault = name, Window = window };
var claim = Path.Combine(evidence, "import-101.claimed");
string previousClaim = null;
if (name == "claim-existing") { previousClaim = "existing-attempt"; File.WriteAllText(claim, previousClaim); }
if (name == "source-hash") { paths.SourcePath = Path.Combine(evidence, "source.json"); File.WriteAllText(paths.SourcePath, "{}"); }
if (name.StartsWith("manifest-", StringComparison.Ordinal))
{
var values = (JObject)Import101.Parse(File.ReadAllText(paths.ValuesPath));
var entries = (JArray)values["entries"];
if (name == "manifest-missing-key") entries.RemoveAt(0);
if (name == "manifest-duplicate-key") entries.Add(entries[0].DeepClone());
if (name == "manifest-value-change") entries[0]["valueSha256"] = "changed";
paths.ValuesPath = Path.Combine(evidence, "values.json"); File.WriteAllText(paths.ValuesPath, values.ToString());
}
if (name == "config-manifest-version")
{
var cfg = (JObject)Import101.Parse(File.ReadAllText(paths.ConfigPath)); cfg["version"] = "wrong";
paths.ConfigPath = Path.Combine(evidence, "config.json"); File.WriteAllText(paths.ConfigPath, cfg.ToString());
}
if (name == "config-hash")
{
paths.ConfigRoot = Path.Combine(evidence, "config-copy"); Directory.CreateDirectory(Path.Combine(paths.ConfigRoot, "cfgData"));
var cfg = (JObject)Import101.Parse(File.ReadAllText(paths.ConfigPath));
foreach (var item in (JArray)cfg["items"])
File.Copy((string)item["path"], Path.Combine(paths.ConfigRoot, (string)item["path"]));
File.AppendAllText(Path.Combine(paths.ConfigRoot, (string)cfg["items"][0]["path"]), " ");
}
if (name == "window-not-approved") window["implementationReviewApproved"] = false;
if (name == "window-expired") window["expiresUtc"] = DateTime.UtcNow.AddSeconds(-1).ToString("o");
Action<string, object> save = (path, value) => {
var file = Path.GetFileName(path);
if ((name == "save-before" && file == "before.json") || (name == "save-candidate" && file == "candidate-schema.json") ||
(name == "save-set-result" && file == "set-result.json") || (name == "save-after" && file == "after.json") ||
(name == "save-verification" && file == "verification.json") || (name == "save-account" && file == "account.json") ||
(name == "claim-failure" && path == claim))
throw new IOException("injected persistence failure: " + file);
if (name == "claim-partial" && path == claim) { File.WriteAllText(path, "partial-claim"); throw new IOException("injected partial claim"); }
if (name == "claim-race" && path == claim) File.WriteAllText(path, "concurrent-claim");
Import101.Save(path, value);
if (name == "window-after-claim" && path == claim) window["expiresUtc"] = DateTime.UtcNow.AddSeconds(-1).ToString("o");
};
Func<S101AccountSession, Import101.Input, PlayerKVSchemaResult> validate = (session, input) => {
if (name == "candidate-invalid") return new PlayerKVSchemaResult(PlayerKVSchemaVerdict.Malformed, new PlayerKVSchemaDiagnostics());
if (name == "success-real-decode")
{
realDecodes++;
Check(session.ServerTimeUtc == new DateTime(2026, 9, 28, 7, 30, 0, DateTimeKind.Utc), "candidate uses mock server time");
return Import101.ValidateCandidate(session, input);
}
return new PlayerKVSchemaResult(PlayerKVSchemaVerdict.Valid, new PlayerKVSchemaDiagnostics());
};
int beforeAssertions = assertions;
string folder = null, error = null;
try
{
if (name.StartsWith("source-json-", StringComparison.Ordinal))
{
Import101.Parse(name == "source-json-duplicate" ? "{\"a\":1,\"a\":2}" : "{} {}");
}
else folder = await Import101.RunCore(evidence, paths, window, name == "null-handler" ? null : mock.Handler, validate, save);
}
catch (Exception e) { error = e.GetType().Name + ": " + e.Message; }
// Expected importer exceptions must not hide an assertion thrown inside a mock handler.
Check(assertionFailures.Count == 0, name + " no swallowed mock assertion");
bool success = name == "success-real-decode";
Check((error == null) == success, name + " expected outcome: " + error);
int expectedSet = Array.IndexOf(names, name) >= Array.IndexOf(names, "success-real-decode") ? 1 : 0;
Check(mock.Set == expectedSet, name + " Set bound");
if (expectedSet == 1)
{
Check(mock.Login == 1 && mock.Get == 2, name + " one observation even on failure");
Check(File.Exists(claim), name + " durable claim retained");
var claimHash = Import101.Hash(File.ReadAllBytes(claim));
int total = mock.Login + mock.Get + mock.Set;
bool rejected = false;
try { await Import101.RunCore(evidence, paths, Window(), mock.Handler, validate, save); }
catch (InvalidOperationException) { rejected = true; }
Check(rejected && total == mock.Login + mock.Get + mock.Set, name + " replay rejected before network");
Check(Import101.Hash(File.ReadAllBytes(claim)) == claimHash, name + " replay claim unchanged");
}
if (name.StartsWith("source-", StringComparison.Ordinal) || name.StartsWith("manifest-", StringComparison.Ordinal) ||
name.StartsWith("config-", StringComparison.Ordinal) || name is "window-not-approved" or "window-expired" ||
name == "null-handler" || name == "claim-existing") Check(mock.Login == 0 && mock.Get == 0, name + " local gate");
if (name is "login-http" or "login-protocol" or "new-account") Check(mock.Get == 0, name + " no Get after login refusal");
if (name == "claim-existing") Check(File.ReadAllText(claim) == previousClaim, "existing claim byte content");
if (name == "claim-partial") Check(File.ReadAllText(claim) == "partial-claim", "partial claim retained");
if (name == "claim-race") Check(File.ReadAllText(claim) == "concurrent-claim", "competing claim not overwritten");
if (name == "window-after-claim") Check(File.Exists(claim) && mock.Get == 1, "expired window consumes claim but never Set");
if (name == "save-account") Check(mock.Login == 1 && mock.Get == 0, "account evidence before Get");
if (name is "candidate-invalid" or "save-before" or "save-candidate" or "claim-failure") Check(!File.Exists(claim), name + " no completed claim");
var runs = Path.Combine(evidence, "runs");
var actualFolder = folder ?? (Directory.Exists(runs) ? Directory.GetDirectories(runs).Single() : null);
if (name == "save-set-result") Check(File.Exists(Path.Combine(actualFolder, "after.json")), "after persisted despite set log failure");
if (success)
{
var result = (JObject)Import101.Parse(File.ReadAllText(Path.Combine(folder, "verification.json")));
Check((bool)result["valuesEqual"] && (bool)result["setSuccess"], "positive exact readback");
Check(result["adapterKeyOrder"].Values<string>().SequenceEqual(mock.ReturnedOrder), "unmodified adapter enumeration order");
var decoded = (JObject)Import101.Parse(File.ReadAllText(Path.Combine(folder, "candidate-schema.json")));
Check((string)decoded["verdict"] == "Valid" && (int)decoded["errors"] == 0, "real 55-key candidate valid");
}
cases.Add(new { name, pass = true, assertions = assertions - beforeAssertions, mock.Login, mock.Get, mock.Set,
evidence, error, realCandidateDecode = success, formalRequests = 0 });
Console.WriteLine("PASS " + name);
}
Check(realDecodes == 1, "real candidate exercised once");
Import101.Save(Path.Combine(suite, "result.json"), new { success = true, cases, assertions, realDecodes,
startUtc = start, endUtc = DateTime.UtcNow, formalRequests = 0, note = "All handlers are in-memory; failures other than success case are injected." });
Console.WriteLine(JsonConvert.SerializeObject(new { success = true, cases = cases.Count, assertions, realDecodes, formalRequests = 0, suite }));
}
}