test(upserver): 归档存档迁移与玩法闭环回归工具

This commit is contained in:
Liubing\LB
2026-09-28 18:16:14 +08:00
parent c4ae02def9
commit a6f5e107f8
232 changed files with 12903 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/*.cs" Link="Production/%(Filename)%(Extension)" />
<Compile Include="../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" />
<Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,187 @@
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();
}
}