test(upserver): 归档存档迁移与玩法闭环回归工具
This commit is contained in:
16
CodexOutput/upserver/KVContractTests/KVContractTests.csproj
Normal file
16
CodexOutput/upserver/KVContractTests/KVContractTests.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<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/PlayerKVModels.cs" Link="Production/PlayerKVModels.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs" Link="Production/PlayerKVClient.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>
|
||||
175
CodexOutput/upserver/KVContractTests/Program.cs
Normal file
175
CodexOutput/upserver/KVContractTests/Program.cs
Normal 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);}
|
||||
}
|
||||
Reference in New Issue
Block a user