test(upserver): 归档存档迁移与玩法闭环回归工具
This commit is contained in:
12
CodexOutput/upserver/KVLiveTests/KVLiveTests.csproj
Normal file
12
CodexOutput/upserver/KVLiveTests/KVLiveTests.csproj
Normal 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>
|
||||
187
CodexOutput/upserver/KVLiveTests/Program.cs
Normal file
187
CodexOutput/upserver/KVLiveTests/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user