using System.Net; using System.Text; using System.Text.Json; namespace ChangeT50.Agent; internal static class ContractSelfTest { private static readonly string[] ExpectedPaths = [ "GET /api/v1/one-card-agent/tasks", "POST /api/v1/one-card-agent/task-acceptances", "POST /api/v1/one-card-agent/consume-results", "POST /api/v1/one-card-agent/upload-results", "GET /api/v1/one-card-agent/recovery-tasks", "POST /api/v1/one-card-agent/heartbeats", "POST /api/v1/one-card-agent/identity-batches", "GET /api/v1/one-card-agent/persons/P001", "POST /api/v1/one-card-agent/identity-batch-acknowledgements", "POST /api/v1/one-card-agent/reconciliation-results" ]; public static async Task RunAsync() { const string secret = "test-secret"; var handler = new ContractHandler(secret); var options = new AgentOptions( new Uri("http://127.0.0.1:18080"), "change_t50", "device-01", secret, "simulate", Path.Combine(Path.GetTempPath(), "change-t50-contract-journal.json"), null, null, 3, 1024); var client = new AgentApiClient(new HttpClient(handler), options); await client.GetTasksAsync(); var accepted = await client.AcceptTaskAsync("ORDER-001", "accept-001"); var consume = new ConsumeExecution { ClientOrderNo = "ORDER-001", AmountCents = 1234, Result = "success", CardNoHash = new string('a', 64), CardNoMasked = "1234****5678", OpCountAfter = 9, PsamTradeNo = "PSAM-001", Tac = "TAC-001" }; await client.PostConsumeResultAsync(consume, accepted.LeaseToken); await client.PostUploadResultAsync("ORDER-001", new UploadExecution { Result = "success", ProviderTradeNo = "CENTER-001", RetryCount = 1 }); await client.GetRecoveryTasksAsync(); await client.PostHeartbeatAsync(new HealthSnapshot()); await client.PostIdentityBatchAsync("BATCH-001", "100", [new { external_no = "P001", acc_num = "ACC-001" }]); var person = await client.GetPersonAsync("P001"); await client.AcknowledgeIdentityBatchAsync("BATCH-001", "100"); await client.PostReconciliationAsync("REC-001", "ORDER-001", "matched", "success", 1234, 1234); if (!person.Found) { throw new InvalidOperationException("Person contract response was not deserialized."); } if (!ExpectedPaths.SequenceEqual(handler.Seen, StringComparer.Ordinal)) { throw new InvalidOperationException("The .NET Agent did not call the frozen 10-interface contract in the expected order."); } var signature = AgentApiClient.Sign( "POST", "/api/v1/one-card-agent/consume-results", "1786387200", "nonce-001", "{\"client_order_no\":\"ORDER-001\",\"amount_cents\":1234}", secret); if (signature != "6c7c6a788f375006a8dcb614c1e85bbb78b15855209d38f4443b8a9f99b8c860") { throw new InvalidOperationException("The .NET HMAC output does not match the PHP contract vector."); } var flow = await RunSimulatedMoneyFlowAsync(options); Console.WriteLine(JsonSerializer.Serialize(new { status = "passed", interfaces = handler.Seen.Count, hmac_vector = "matched_php", person_deserialized = person.Found, simulated_money_flow = flow.Settled == 2 ? "settled_and_replayed" : "failed", physical_debit_calls = flow.DebitCalls }, new JsonSerializerOptions { WriteIndented = true })); } private static async Task<(int Settled, int DebitCalls)> RunSimulatedMoneyFlowAsync(AgentOptions template) { var journalPath = Path.Combine(Path.GetTempPath(), $"change-t50-flow-{Guid.NewGuid():N}.json"); try { var options = template with { JournalPath = journalPath }; var handler = new StatefulFlowHandler(template.Secret); var provider = new CountingProvider(); var runner = new AgentRunner( new AgentApiClient(new HttpClient(handler), options), provider, new LocalJournal(journalPath)); var first = await runner.RunOnceAsync(CancellationToken.None); handler.ReplayTask(); var second = await runner.RunOnceAsync(CancellationToken.None); if (first.Settled != 1 || second.Settled != 1 || provider.DebitCalls != 1) { throw new InvalidOperationException("The simulated debit/upload flow or journal replay guard failed."); } return (first.Settled + second.Settled, provider.DebitCalls); } finally { File.Delete(journalPath); File.Delete(journalPath + ".tmp"); } } private sealed class ContractHandler(string secret) : HttpMessageHandler { private readonly HashSet _nonces = new(StringComparer.Ordinal); public List Seen { get; } = []; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); var provider = Header(request, "X-OneCard-Provider"); var device = Header(request, "X-OneCard-Device"); var timestamp = Header(request, "X-OneCard-Timestamp"); var nonce = Header(request, "X-OneCard-Nonce"); var signature = Header(request, "X-OneCard-Signature"); var path = request.RequestUri?.AbsolutePath ?? string.Empty; var expected = AgentApiClient.Sign(request.Method.Method, path, timestamp, nonce, body, secret); if (provider != "change_t50" || device != "device-01" || signature != expected || !_nonces.Add(nonce)) { return Response(HttpStatusCode.Unauthorized, "{\"code\":1,\"message\":\"invalid signature\",\"data\":{},\"trace_id\":\"test\"}"); } Seen.Add($"{request.Method.Method} {path}"); return Response(HttpStatusCode.OK, BodyFor(path)); } private static string BodyFor(string path) => path switch { "/api/v1/one-card-agent/tasks" or "/api/v1/one-card-agent/recovery-tasks" => "{\"code\":0,\"message\":\"success\",\"data\":{\"tasks\":[]},\"trace_id\":\"test\"}", "/api/v1/one-card-agent/task-acceptances" => "{\"code\":0,\"message\":\"success\",\"data\":{\"idempotent\":false,\"client_order_no\":\"ORDER-001\",\"lease_token\":\"lease-001\",\"lease_until\":\"2026-08-11 12:00:00\",\"task\":{\"task_id\":1,\"client_order_no\":\"ORDER-001\",\"task_type\":\"consume\",\"amount_cents\":1234,\"wallet_code\":\"1\",\"status\":\"device_processing\"}},\"trace_id\":\"test\"}", "/api/v1/one-card-agent/persons/P001" => "{\"code\":0,\"message\":\"success\",\"data\":{\"found\":true,\"external_no\":\"P001\",\"staff_uuid\":\"staff-001\",\"name\":\"张三\",\"account_type\":\"one_card\"},\"trace_id\":\"test\"}", _ => "{\"code\":0,\"message\":\"success\",\"data\":{},\"trace_id\":\"test\"}" }; internal static HttpResponseMessage Response(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; private static string Header(HttpRequestMessage request, string name) => request.Headers.TryGetValues(name, out var values) ? values.Single() : string.Empty; } private sealed class StatefulFlowHandler(string secret) : HttpMessageHandler { private string _state = "waiting"; public void ReplayTask() => _state = "waiting"; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); var path = request.RequestUri?.AbsolutePath ?? string.Empty; var timestamp = Header(request, "X-OneCard-Timestamp"); var nonce = Header(request, "X-OneCard-Nonce"); var expected = AgentApiClient.Sign(request.Method.Method, path, timestamp, nonce, body, secret); if (Header(request, "X-OneCard-Signature") != expected) { return ContractHandler.Response(HttpStatusCode.Unauthorized, "{\"code\":1,\"message\":\"invalid signature\",\"data\":{},\"trace_id\":\"flow\"}"); } var data = path switch { "/api/v1/one-card-agent/tasks" => _state == "waiting" ? "{\"tasks\":[{\"task_id\":1,\"client_order_no\":\"FLOW-001\",\"task_type\":\"consume\",\"amount_cents\":100,\"wallet_code\":\"1\",\"person_external_no\":\"P001\",\"status\":\"waiting_device\"}]}" : "{\"tasks\":[]}", "/api/v1/one-card-agent/task-acceptances" => Accept(), "/api/v1/one-card-agent/consume-results" => Consume(), "/api/v1/one-card-agent/upload-results" => Upload(), "/api/v1/one-card-agent/recovery-tasks" => "{\"tasks\":[]}", _ => "{}" }; return ContractHandler.Response(HttpStatusCode.OK, $"{{\"code\":0,\"message\":\"success\",\"data\":{data},\"trace_id\":\"flow\"}}"); } private string Accept() { _state = "processing"; return "{\"idempotent\":false,\"client_order_no\":\"FLOW-001\",\"lease_token\":\"lease-flow\",\"lease_until\":\"2026-08-11 12:00:00\",\"task\":{\"task_id\":1,\"client_order_no\":\"FLOW-001\",\"task_type\":\"consume\",\"amount_cents\":100,\"wallet_code\":\"1\",\"status\":\"device_processing\"}}"; } private string Consume() { _state = "debited"; return "{}"; } private string Upload() { _state = "settled"; return "{}"; } private static string Header(HttpRequestMessage request, string name) => request.Headers.TryGetValues(name, out var values) ? values.Single() : string.Empty; } private sealed class CountingProvider : IChangeT50Provider { private readonly SimulatedChangeT50Provider _inner = new(); public int DebitCalls { get; private set; } public Task GetHealthAsync(int pendingUploadCount, CancellationToken cancellationToken) => _inner.GetHealthAsync(pendingUploadCount, cancellationToken); public Task ConsumeAsync(AgentTask task, CancellationToken cancellationToken) { DebitCalls++; return _inner.ConsumeAsync(task, cancellationToken); } public Task UploadAsync(AgentTask task, ConsumeExecution consume, int retryCount, CancellationToken cancellationToken) => _inner.UploadAsync(task, consume, retryCount, cancellationToken); public void Dispose() => _inner.Dispose(); } }