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,175 @@
using System.Net;
using System.Text;
using game.PlayerDataStorage;
using Newtonsoft.Json.Linq;
var passed = 0;
async Task Check(string name, Func<Task> action)
{
await action();
Console.WriteLine("PASS " + name);
passed++;
}
void Assert(bool ok, string message) { if (!ok) throw new Exception(message); }
async Task<(PlayerKVResult result, Probe handler)> Get(string body, int status = 200)
{
var handler = new Probe(body, status);
using var http = new HttpClient(handler);
var result = await new PlayerKVClient(http, new Uri("http://example.test/api")).GetAsync();
return (result, handler);
}
await Check("Get preserves string/empty/JSON values and full-read request", async () => {
var (r, h) = await Get("{\"data\":{\"empty\":\"\",\"json\":\"{\\\"v\\\":1}\"}}");
Assert(r.Success && r.Data["empty"] == "" && r.Data["json"] == "{\"v\":1}", "Values changed");
Assert(h.Url == "http://example.test/api/playerdata/Get" && h.Body == "{}", "Wrong route/body");
});
await Check("Empty data is valid", async () => Assert((await Get("{\"data\":{}}")).result.Success, "Empty rejected"));
foreach (var body in new[] { "", "null", "{}", "{\"data\":null}", "{\"data\":[]}", "{\"data\":{\"x\":1}}", "{\"data\":{\"x\":null}}", "not-json" })
await Check("Invalid Get response " + body, async () => {
var (r, _) = await Get(body);
Assert(!r.Success && r.Error == PlayerKVError.Protocol && r.Data == null, "Invalid response accepted");
});
await Check("Filtered read preserves keys", async () => {
var h = new Probe("{\"data\":{}}"); using var http = new HttpClient(h);
await new PlayerKVClient(http, new Uri("http://example.test/")).GetAsync(new[] { "missing", "Lv" });
Assert(JObject.Parse(h.Body)["keys"].Count() == 2, "Missing keys");
});
await Check("Set/Delete accept empty 200 and preserve payload", async () => {
var h = new Probe(""); using var http = new HttpClient(h);
var kv = new PlayerKVClient(http, new Uri("http://example.test/"));
var r = await kv.SetAsync(new Dictionary<string,string> { ["key"] = "{\"v\":1}" });
Assert(r.Success && h.Url.EndsWith("/Set") && (string)JObject.Parse(h.Body)["data"]["key"] == "{\"v\":1}", "Set failed");
Assert((await kv.DeleteAsync(new[] { "key" })).Success && h.Url.EndsWith("/Delete"), "Delete failed");
});
foreach (var status in new[] { 401, 403, 400, 500, 302 })
await Check("HTTP failure " + status, async () => {
var (r, _) = await Get("", status);
Assert(!r.Success && r.StatusCode == status && r.Error == (status == 401 || status == 403 ? PlayerKVError.Unauthorized : PlayerKVError.Http), "Wrong HTTP result");
});
await Check("Invalid write/key rejected before HTTP", async () => {
var h = new Probe(""); using var http = new HttpClient(h);
var kv = new PlayerKVClient(http, new Uri("http://example.test/"));
Assert((await kv.SetAsync(null)).Error == PlayerKVError.Validation, "Null write");
Assert((await kv.SetAsync(new Dictionary<string,string> { ["x"] = null })).Error == PlayerKVError.Validation, "Null value");
Assert((await kv.GetAsync(new[] { new string('a',129) })).Error == PlayerKVError.Validation, "Long key");
Assert((await kv.DeleteAsync(null)).Error == PlayerKVError.Validation && h.Calls == 0, "Unexpected request");
});
foreach (var mode in new[] { "network", "timeout", "cancel" })
await Check(mode + " failure", async () => {
var h = new Probe("") { Mode = mode }; using var http = new HttpClient(h);
using var cts = new CancellationTokenSource(); if(mode == "cancel") cts.Cancel();
var r = await new PlayerKVClient(http,new Uri("http://example.test/")).GetAsync(null,cts.Token);
var expected = mode == "network" ? PlayerKVError.Network : mode == "timeout" ? PlayerKVError.Timeout : PlayerKVError.Cancelled;
Assert(!r.Success && r.Error == expected, "Wrong exception mapping");
});
foreach (var body in new[] { "{\"data\":{}} {}", "{\"data\":{\"x\":\"a\",\"x\":\"b\"}}", "{\"data\":{},\"data\":{}}", "{\"data\":{\"x\":true}}" })
await Check("Reject ambiguous or non-string response", async () => {
var (r, _) = await Get(body);
Assert(r.Error == PlayerKVError.Protocol && r.Data == null, "Ambiguous data accepted");
});
await Check("Date-shaped values remain strings; key comparison is ordinal", async () => {
var (r, _) = await Get("{\"data\":{\"A\":\"2026-09-22T00:00:00Z\",\"a\":\"different\"}}");
Assert(r.Success && r.Data["A"] == "2026-09-22T00:00:00Z" && r.Data["a"] == "different", "Value coercion");
try { ((IDictionary<string,string>)r.Data).Add("mutated", "x"); throw new Exception("Mutable result"); }
catch (NotSupportedException) { }
});
await Check("Empty operations preserve server authentication and full-read semantics", async () => {
var h = new Probe("",401); using var http = new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
Assert((await kv.GetAsync(Array.Empty<string>())).Error==PlayerKVError.Unauthorized && h.Body=="{}", "Empty Get");
Assert((await kv.SetAsync(new Dictionary<string,string>())).Error==PlayerKVError.Unauthorized && h.Body=="{\"data\":{}}", "Empty Set bypassed auth");
Assert((await kv.DeleteAsync(Array.Empty<string>())).Error==PlayerKVError.Unauthorized && h.Body=="{\"keys\":[]}" && h.Calls==3, "Empty Delete bypassed auth");
});
await Check("128-char keys and empty strings allowed; empty/null/129-char keys rejected", async () => {
var h=new Probe("");using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
Assert((await kv.SetAsync(new Dictionary<string,string>{{new string('k',128),""}})).Success,"Valid boundary rejected");
foreach(var key in new[]{"",null,new string('k',129)}) {
Assert((await kv.GetAsync(new[]{key})).Error==PlayerKVError.Validation,"Invalid read key");
Assert((await kv.DeleteAsync(new[]{key})).Error==PlayerKVError.Validation,"Invalid delete key");
}
Assert(h.Calls==1,"Validation sent requests");
});
foreach(var action in new[]{"Get","Set","Delete"})
await Check(action+" payload is snapshotted before caller mutation",async()=>{
var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/api/"));
var keys=new List<string>{"original"};var data=new Dictionary<string,string>{{"original","old"}};
var pending=action=="Get"?kv.GetAsync(keys):action=="Set"?kv.SetAsync(data):kv.DeleteAsync(keys);
keys[0]="changed";data["original"]="new";data["added"]="extra";
h.Gate.SetResult(true);var r=await pending;
Assert(r.Success && h.Body.Contains("original") && !h.Body.Contains("changed") && !h.Body.Contains("extra") && !h.Body.Contains("new"),"Mutable request leaked");
Assert(h.Url=="http://example.test/api/playerdata/"+action,"Wrong absolute route");
});
foreach(var action in new[]{"Set","Delete"})
foreach(var failure in new[]{"network","timeout","http"})
await Check(action+" "+failure+" is not retried and has unknown commit outcome",async()=>{
var h=new Probe("",failure=="http"?500:200){Mode=failure};using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
var r=action=="Set"?await kv.SetAsync(new Dictionary<string,string>{{"x","y"}}):await kv.DeleteAsync(new[]{"x"});
Assert(!r.Success && r.MayHaveCommitted && r.Data==null && h.Calls==1,"Unsafe failure/retry semantics");
});
await Check("Pre-cancelled write never dispatches; in-flight cancellation may have committed",async()=>{
using var cts=new CancellationTokenSource();cts.Cancel();var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
var before=await kv.DeleteAsync(new[]{"x"},cts.Token);
Assert(before.Error==PlayerKVError.Cancelled && !before.MayHaveCommitted && h.Calls==0,"Pre-cancel dispatch");
using var running=new CancellationTokenSource();var task=kv.SetAsync(new Dictionary<string,string>{{"x","y"}},running.Token);
running.Cancel();h.Gate.SetResult(true);var after=await task;
Assert(after.Error==PlayerKVError.Cancelled && after.MayHaveCommitted && h.Calls==1,"Cancelled mutation reported success");
});
await Check("Cancelled read cannot publish a late successful result",async()=>{
using var cts=new CancellationTokenSource();var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
var task=kv.GetAsync(null,cts.Token);cts.Cancel();h.Gate.SetResult(true);var r=await task;
Assert(r.Error==PlayerKVError.Cancelled && r.Data==null && !r.MayHaveCommitted,"Late read published");
});
await Check("Adapter preserves injected client configuration and ownership; disposes messages",async()=>{
var h=new DeferredProbe();using var http=new HttpClient(h){BaseAddress=new Uri("http://other.test/")};http.DefaultRequestHeaders.Add("X-Test","preserve");
var kv=new PlayerKVClient(http,new Uri("http://example.test/"));var task=kv.GetAsync();h.Gate.SetResult(true);await task;
Assert(http.BaseAddress.AbsoluteUri=="http://other.test/" && http.DefaultRequestHeaders.GetValues("X-Test").Single()=="preserve","Shared configuration mutated");
Assert(h.ResponseBody.Disposed,"Response not disposed");
try{await h.Request.Content.ReadAsStringAsync();throw new Exception("Request not disposed");}catch(ObjectDisposedException){}
using var stillAlive=await http.GetAsync("probe");Assert(stillAlive.IsSuccessStatusCode,"Injected client disposed");
});
await Check("Disposed owner transport returns cancellation without logging payload",async()=>{
var http=new HttpClient(new Probe(""));var kv=new PlayerKVClient(http,new Uri("http://example.test/"));http.Dispose();
Assert((await kv.GetAsync()).Error==PlayerKVError.Cancelled,"Disposed owner failed classification");
});
await Check("Reject invalid base URIs before any request",()=>{
using var http=new HttpClient(new Probe(""));
foreach(var uri in new Uri[]{null,new Uri("relative",UriKind.Relative),new Uri("ftp://example.test/"),new Uri("http://example.test/?q=x"),new Uri("http://example.test/#x"),new Uri("http://user:secret@example.test/")}) {
try{new PlayerKVClient(http,uri);throw new Exception("Invalid base allowed");}catch(ArgumentException){}
}
return Task.CompletedTask;
});
Console.WriteLine($"Passed {passed} tests");
sealed class Probe : HttpMessageHandler
{
public string Url, Body, Mode;
public int Calls;
readonly string response;
readonly int status;
public Probe(string response, int status = 200) { this.response=response; this.status=status; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken token)
{
Calls++; Url=req.RequestUri.AbsoluteUri; Body=await req.Content.ReadAsStringAsync();
if(Mode == "network") throw new HttpRequestException("simulated");
if(Mode == "timeout" || Mode == "cancel") throw new TaskCanceledException();
return new HttpResponseMessage((HttpStatusCode)status) { Content=new StringContent(response,Encoding.UTF8,"application/json") };
}
}
sealed class DeferredProbe : HttpMessageHandler
{
public readonly TaskCompletionSource<bool> Gate=new();
public int Calls;public string Body,Url;public HttpRequestMessage Request;public TrackedContent ResponseBody;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
Calls++;Request=request;await Gate.Task; // deliberately returns even if the caller cancelled
Url=request.RequestUri.AbsoluteUri;Body=request.Content==null?"":await request.Content.ReadAsStringAsync();
ResponseBody=new TrackedContent(Url.EndsWith("/Get")?"{\"data\":{}}":"");
return new HttpResponseMessage(HttpStatusCode.OK){Content=ResponseBody};
}
}
sealed class TrackedContent(string body) : StringContent(body)
{
public bool Disposed;
protected override void Dispose(bool disposing){Disposed=true;base.Dispose(disposing);}
}