From 2ada70d6467aec11b45328112d598454ca50f2d6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 00:06:45 +0800 Subject: feat(api): 接入基金 PostgreSQL 持久化与幂等接口 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] - 本提交新增基金创建与读取的 PostgreSQL 持久化能力,并完成 3a API 收口验证。 [新增功能] - 提供带事务和幂等键的基金创建、重放、冲突检测及基金读取接口。 - 覆盖认证、JSON 输入、数值边界、进程重启持久化和 SQL 故障回滚测试。 [实现方案] - 使用 Npgsql 建立 `funds` 与 `fund_idempotencies` 表,并以 advisory lock 串行化同一幂等键。 - 通过真实 Kestrel 子进程和临时 PostgreSQL 验证 HTTP 行为;同步更新环境、构建和交接文档。 [影响范围] - API 新增 PostgreSQL 配置要求 `FUND_LAB_DATABASE_URL`,前端构建入口和本地验证命令同步明确。 - 本提交不包含 `docs/overnight-progress.md` 的现有 Hermes 修改。 --- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 2 + tests/FundLab.Api.Tests/PersistenceTests.fs | 774 +++++++++++++++++++++++ 2 files changed, 776 insertions(+) create mode 100644 tests/FundLab.Api.Tests/PersistenceTests.fs (limited to 'tests/FundLab.Api.Tests') diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index ef9f063..c976006 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -9,6 +9,7 @@ + all @@ -20,6 +21,7 @@ + diff --git a/tests/FundLab.Api.Tests/PersistenceTests.fs b/tests/FundLab.Api.Tests/PersistenceTests.fs new file mode 100644 index 0000000..f319839 --- /dev/null +++ b/tests/FundLab.Api.Tests/PersistenceTests.fs @@ -0,0 +1,774 @@ +namespace FundLab.Api.Tests + +open System +open System.Diagnostics +open System.IO +open System.Net +open System.Net.Http +open System.Net.Http.Headers +open System.Text +open System.Text.Json +open System.Threading +open System.Threading.Tasks +open FundLab.Api +open Giraffe +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Primitives +open Npgsql +open Xunit + +type PostgresFixture() = + let externalConnectionString = Environment.GetEnvironmentVariable("FUND_LAB_TEST_DATABASE_URL") + let originalAuthToken = Environment.GetEnvironmentVariable("FUND_LAB_AUTH_TOKEN") + let mutable containerName: string option = None + let mutable connectionString = externalConnectionString + let runId = Guid.NewGuid().ToString("N") + + let docker arguments = + let startInfo = ProcessStartInfo() + startInfo.FileName <- "docker" + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + + for argument in arguments do + startInfo.ArgumentList.Add(argument) + + use child = new Process() + child.StartInfo <- startInfo + + if not (child.Start()) then + failwith "Could not start docker" + + let output = child.StandardOutput.ReadToEnd() + let error = child.StandardError.ReadToEnd() + child.WaitForExit() + + if child.ExitCode <> 0 then + failwithf "docker %s failed: %s" (String.concat " " arguments) error + + output.Trim() + + let startDatabase () = + let name = sprintf "fund-lab-test-%s" (Guid.NewGuid().ToString("N")) + let image = + Environment.GetEnvironmentVariable("FUND_LAB_TEST_POSTGRES_IMAGE") + |> Option.ofObj + |> Option.defaultValue "postgres:16-alpine" + + docker [ + "run" + "--detach" + "--rm" + "--name" + name + "--env" + "POSTGRES_USER=postgres" + "--env" + "POSTGRES_PASSWORD=postgres" + "--env" + "POSTGRES_DB=fund_lab_test" + "--publish" + "127.0.0.1::5432" + image + ] + |> ignore + + containerName <- Some name + let port = + docker [ + "inspect" + "--format" + "{{(index (index .NetworkSettings.Ports \"5432/tcp\") 0).HostPort}}" + name + ] + + connectionString <- + sprintf "Host=127.0.0.1;Port=%s;Database=fund_lab_test;Username=postgres;Password=postgres;Pooling=false" port + + let waitForDatabase () = + let deadline = DateTime.UtcNow.AddSeconds(45.0) + + let rec tryConnect () = + try + use connection = new NpgsqlConnection(connectionString) + connection.Open() + true + with _ when DateTime.UtcNow < deadline -> + Thread.Sleep(250) + tryConnect () + + if not (tryConnect ()) then + failwith "PostgreSQL did not become ready" + + do + if String.IsNullOrWhiteSpace externalConnectionString then + startDatabase () + + waitForDatabase () + Environment.SetEnvironmentVariable("FUND_LAB_AUTH_TOKEN", "test-token") + + member _.ConnectionString = connectionString + member _.Key(suffix: string) = sprintf "fund-lab-test-%s-%s" runId suffix + + interface IDisposable with + member _.Dispose() = + match containerName with + | Some name -> + try + docker [ "rm"; "--force"; name ] |> ignore + with _ -> + () + | None -> + () + + Environment.SetEnvironmentVariable("FUND_LAB_AUTH_TOKEN", originalAuthToken) + +[] +type PostgresCollection = + inherit ICollectionFixture + +type ApiProcess(connectionString: string) = + let repositoryRoot = + let rec locate (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, "FundLab.sln")) then + directory.FullName + elif isNull directory.Parent then + failwith "Could not locate repository root" + else + locate directory.Parent + + locate (DirectoryInfo(AppContext.BaseDirectory)) + + let port = + use listener = new Net.Sockets.TcpListener(IPAddress.Loopback, 0) + listener.Start() + let value = (listener.LocalEndpoint :?> IPEndPoint).Port + listener.Stop() + value + + let baseAddress = sprintf "http://127.0.0.1:%d" port + let childProcess = new Process() + let mutable outputTask = Task.FromResult("") + let mutable errorTask = Task.FromResult("") + + do + let startInfo = ProcessStartInfo() + startInfo.FileName <- "dotnet" + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + startInfo.WorkingDirectory <- repositoryRoot + startInfo.Environment["FUND_LAB_DATABASE_URL"] <- connectionString + startInfo.Environment["FUND_LAB_AUTH_TOKEN"] <- "test-token" + startInfo.Environment["ASPNETCORE_URLS"] <- baseAddress + + startInfo.ArgumentList.Add("run") + startInfo.ArgumentList.Add("--project") + startInfo.ArgumentList.Add(Path.Combine(repositoryRoot, "src", "FundLab.Api", "FundLab.Api.fsproj")) + startInfo.ArgumentList.Add("--no-build") + startInfo.ArgumentList.Add("--no-launch-profile") + + childProcess.StartInfo <- startInfo + + if not (childProcess.Start()) then + failwith "Could not start FundLab API" + + outputTask <- childProcess.StandardOutput.ReadToEndAsync() + errorTask <- childProcess.StandardError.ReadToEndAsync() + + member _.BaseAddress = baseAddress + member _.HasExited = childProcess.HasExited + + member private _.Logs = + let output = outputTask.GetAwaiter().GetResult() + let error = errorTask.GetAwaiter().GetResult() + sprintf "stdout:\n%s\nstderr:\n%s" output error + + member this.Stop() = + if not childProcess.HasExited then + childProcess.Kill(true) + childProcess.WaitForExit() + + this.Logs + + interface IDisposable with + member this.Dispose() = + this.Stop() |> ignore + +module PersistenceTestHelpers = + let invoke (handler: HttpHandler) (method: string) (path: string) (headers: (string * string) list) (body: string) = + let context = DefaultHttpContext() + use services = ServiceCollection().AddGiraffe().BuildServiceProvider() + context.RequestServices <- services + context.Request.Method <- method + context.Request.Path <- PathString(path) + context.Request.Body <- + new MemoryStream(Encoding.UTF8.GetBytes(body)) + :> Stream + context.Request.ContentType <- "application/json" + + for key, value in headers do + context.Request.Headers[key] <- StringValues(value) + + let responseBody = new MemoryStream() + context.Response.Body <- responseBody + let next : HttpFunc = fun _ -> Task.FromResult(None) + handler next context |> fun task -> task.GetAwaiter().GetResult() |> ignore + + responseBody.Position <- 0L + use reader = new StreamReader(responseBody) + context.Response.StatusCode, reader.ReadToEnd() + + let responseId (body: string) = + use document = JsonDocument.Parse(body) + document.RootElement.GetProperty("id").GetGuid() + + let waitForApi (api: ApiProcess) = + use client = new HttpClient() + client.BaseAddress <- Uri(api.BaseAddress) + let deadline = DateTime.UtcNow.AddSeconds(45.0) + let mutable ready = false + + while not ready && DateTime.UtcNow < deadline && not api.HasExited do + try + use response = client.GetAsync("/health").GetAwaiter().GetResult() + ready <- response.IsSuccessStatusCode + with _ -> + Thread.Sleep(250) + + if not ready then + Thread.Sleep(250) + + if not ready then + let logs = api.Stop() + failwithf "FundLab API did not become ready: %s" logs + + let send (client: HttpClient) (httpMethod: string) (path: string) (token: string option) (idempotencyKey: string option) (body: string) = + use request = new HttpRequestMessage(HttpMethod(httpMethod), path) + + match token with + | Some value -> request.Headers.Authorization <- AuthenticationHeaderValue("Bearer", value) + | None -> () + + match idempotencyKey with + | Some value -> request.Headers.Add("Idempotency-Key", value) + | None -> () + + if not (String.IsNullOrEmpty body) then + request.Content <- new StringContent(body, Encoding.UTF8, "application/json") + + use response = client.SendAsync(request).GetAwaiter().GetResult() + let responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + int response.StatusCode, responseBody + + let queryCount connectionString sql parameters = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- sql + + for name, value, dbType in parameters do + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + + Convert.ToInt64(command.ExecuteScalar()) + + let installFailureTrigger (connectionString: string) (key: string) = + let escapedKey = key.Replace("'", "''") + + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- + sprintf + """ + CREATE OR REPLACE FUNCTION fund_lab_test_fail_idempotency() RETURNS trigger + LANGUAGE plpgsql AS $function$ + BEGIN + RAISE EXCEPTION 'forced idempotency write failure'; + END; + $function$; + DROP TRIGGER IF EXISTS fund_lab_test_fail_idempotency ON fund_idempotencies; + CREATE TRIGGER fund_lab_test_fail_idempotency + AFTER INSERT ON fund_idempotencies + FOR EACH ROW + WHEN (NEW.idempotency_key = '%s') + EXECUTE FUNCTION fund_lab_test_fail_idempotency(); + """ + escapedKey + command.ExecuteNonQuery() |> ignore + + let removeFailureTrigger connectionString = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- + """ + DROP TRIGGER IF EXISTS fund_lab_test_fail_idempotency ON fund_idempotencies; + DROP FUNCTION IF EXISTS fund_lab_test_fail_idempotency(); + """ + command.ExecuteNonQuery() |> ignore + +[] +type PersistenceTests(fixture: PostgresFixture) = + let repository () = + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + [] + member _.``created fund remains readable after repository restart with exact values``() = + let command = + { + Name = "精确 FOF" + InitialCash = 1234.56m + InitialUnitNav = 1.23456789m + IsSynthetic = true + } + + let created = + match (repository ()).CreateFund(fixture.Key("create-read-key"), command) with + | FundWriteResult.Created fund -> fund + | result -> failwithf "expected created result, got %A" result + + let restarted = FundRepository(fixture.ConnectionString) + let read = restarted.GetFund(created.Id) + + match read with + | Some fund -> + Assert.Equal(created.Id, fund.Id) + Assert.Equal(1234.56m, fund.InitialCash) + Assert.Equal(1.23456789m, fund.InitialUnitNav) + Assert.Equal("精确 FOF", fund.Name) + | None -> + Assert.Fail("created fund was not readable after repository restart") + + [] + member _.``same idempotency key replays the same fund``() = + let command = + { + Name = "幂等 FOF" + InitialCash = 100m + InitialUnitNav = 1m + IsSynthetic = true + } + + let key = fixture.Key("replay-key") + let first = (repository ()).CreateFund(key, command) + let second = (FundRepository(fixture.ConnectionString)).CreateFund(key, command) + + match first, second with + | FundWriteResult.Created firstFund, FundWriteResult.Replayed secondFund -> + Assert.Equal(firstFund.Id, secondFund.Id) + | _ -> + Assert.Fail("same request should replay the original fund") + + [] + member _.``different payload with the same idempotency key is rejected``() = + let firstCommand = + { + Name = "原始 FOF" + InitialCash = 100m + InitialUnitNav = 1m + IsSynthetic = true + } + + let conflictingCommand = + { + Name = "冲突 FOF" + InitialCash = 101m + InitialUnitNav = 1m + IsSynthetic = true + } + + let key = fixture.Key("conflict-key") + (repository ()).CreateFund(key, firstCommand) |> ignore + + match (FundRepository(fixture.ConnectionString)).CreateFund(key, conflictingCommand) with + | FundWriteResult.IdempotencyConflict -> () + | result -> failwithf "expected idempotency conflict, got %A" result + + [] + member _.``idempotency fingerprint distinguishes delimiter-like values``() = + let firstCommand = + { + Name = "a|100" + InitialCash = 1m + InitialUnitNav = 1m + IsSynthetic = true + } + + let conflictingCommand = + { + Name = "a" + InitialCash = 100m + InitialUnitNav = 1m + IsSynthetic = true + } + + let key = fixture.Key("delimiter-collision-key") + (repository ()).CreateFund(key, firstCommand) |> ignore + + match (FundRepository(fixture.ConnectionString)).CreateFund(key, conflictingCommand) with + | FundWriteResult.IdempotencyConflict -> () + | result -> failwithf "expected delimiter-like payloads to conflict, got %A" result + + [] + member _.``database numeric range violations are returned as validation errors``() = + let tooMuchCash = + { + Name = "现金精度 FOF" + InitialCash = 1000000000000000000.00m + InitialUnitNav = 1m + IsSynthetic = true + } + + let tooMuchNav = + { + Name = "净值精度 FOF" + InitialCash = 100m + InitialUnitNav = 100000000000000000000.00000000m + IsSynthetic = true + } + + let assertInvalid key command = + match (repository ()).CreateFund(key, command) with + | FundWriteResult.Invalid message -> Assert.Contains("precision", message) + | result -> failwithf "expected precision validation error, got %A" result + + assertInvalid (fixture.Key("cash-range-key")) tooMuchCash + assertInvalid (fixture.Key("nav-range-key")) tooMuchNav + + [] + member _.``invalid fund write rolls back its idempotency reservation``() = + let invalidCommand = + { + Name = "回滚 FOF" + InitialCash = 100.001m + InitialUnitNav = 1m + IsSynthetic = true + } + + let key = fixture.Key("rollback-key") + + match (repository ()).CreateFund(key, invalidCommand) with + | FundWriteResult.Invalid _ -> () + | result -> failwithf "expected invalid fund write, got %A" result + + let validCommand = { invalidCommand with InitialCash = 100m } + + match (FundRepository(fixture.ConnectionString)).CreateFund(key, validCommand) with + | FundWriteResult.Created _ -> () + | result -> failwithf "rolled back key should be reusable, got %A" result + + [] + member _.``database failure after the fund insert rolls back both tables``() = + let command = + { + Name = sprintf "SQL 故障回滚 FOF %s" (Guid.NewGuid().ToString("N")) + InitialCash = 100m + InitialUnitNav = 1m + IsSynthetic = true + } + + let key = fixture.Key("sql-failure-rollback-key") + let fundCountBefore = + PersistenceTestHelpers.queryCount + fixture.ConnectionString + "SELECT count(*) FROM funds WHERE name = @name" + [ "name", box command.Name, NpgsqlTypes.NpgsqlDbType.Text ] + + PersistenceTestHelpers.installFailureTrigger fixture.ConnectionString key + + try + Assert.Throws(fun () -> + (repository ()).CreateFund(key, command) |> ignore) + |> ignore + + let fundCountAfter = + PersistenceTestHelpers.queryCount + fixture.ConnectionString + "SELECT count(*) FROM funds WHERE name = @name" + [ "name", box command.Name, NpgsqlTypes.NpgsqlDbType.Text ] + + let idempotencyCount = + PersistenceTestHelpers.queryCount + fixture.ConnectionString + "SELECT count(*) FROM fund_idempotencies WHERE idempotency_key = @key" + [ "key", box key, NpgsqlTypes.NpgsqlDbType.Text ] + + Assert.Equal(fundCountBefore, fundCountAfter) + Assert.Equal(0L, idempotencyCount) + finally + PersistenceTestHelpers.removeFailureTrigger fixture.ConnectionString + + [] + member _.``concurrent requests with the same key create one fund``() = + repository () |> ignore + + let command = + { + Name = "并发 FOF" + InitialCash = 200m + InitialUnitNav = 1m + IsSynthetic = true + } + + let key = fixture.Key("concurrent-key") + let run () = (FundRepository(fixture.ConnectionString)).CreateFund(key, command) + let results = + [| + Task.Run(fun () -> run ()) + Task.Run(fun () -> run ()) + |] + |> Task.WhenAll + |> fun task -> task.GetAwaiter().GetResult() + + let createdCount = results |> Array.filter (function | FundWriteResult.Created _ -> true | _ -> false) |> Array.length + let replayedCount = results |> Array.filter (function | FundWriteResult.Replayed _ -> true | _ -> false) |> Array.length + let fundIds = + results + |> Array.choose (function + | FundWriteResult.Created fund + | FundWriteResult.Replayed fund -> Some fund.Id + | _ -> None) + + Assert.Equal(1, createdCount) + Assert.Equal(1, replayedCount) + Assert.Equal(2, fundIds.Length) + Assert.Equal(fundIds[0], fundIds[1]) + + [] + member _.``fund API requires bearer auth and returns decimal strings``() = + let app = App.createApplication (repository ()) + let requestBody = "{\"name\":\"API FOF\",\"initialCash\":\"42.10\",\"initialUnitNav\":\"1.25000000\",\"isSynthetic\":true}" + + let unauthorizedStatus, _ = + PersistenceTestHelpers.invoke app "POST" "/api/funds" [ "Idempotency-Key", "api-unauthorized-key" ] requestBody + + Assert.Equal(401, unauthorizedStatus) + + let missingKeyStatus, _ = + PersistenceTestHelpers.invoke app "POST" "/api/funds" [ "Authorization", "Bearer test-token" ] requestBody + + Assert.Equal(400, missingKeyStatus) + + let status, body = + PersistenceTestHelpers.invoke + app + "POST" + "/api/funds" + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key("api-create-key") + ] + requestBody + + Assert.Equal(201, status) + Assert.Contains("\"initialCash\":\"42.10\"", body) + Assert.Contains("\"initialUnitNav\":\"1.25000000\"", body) + + let fundId = PersistenceTestHelpers.responseId body + let readStatus, readBody = + PersistenceTestHelpers.invoke + app + "GET" + (sprintf "/api/funds/%O" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, readStatus) + Assert.Contains("\"id\":", readBody) + Assert.Contains("\"initialCash\":\"42.10\"", readBody) + + [] + member _.``fund API rejects non-object JSON bodies``() = + let app = App.createApplication (repository ()) + + for body, suffix in [ "[]", "array"; "null", "null" ] do + let status, responseBody = + PersistenceTestHelpers.invoke + app + "POST" + "/api/funds" + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key(suffix) + ] + body + + Assert.Equal(400, status) + Assert.Contains("INVALID_FUND_REQUEST", responseBody) + + [] + member _.``real Kestrel API preserves funds and idempotency across process restart``() = + let requestBody = + "{\"name\":\"真实 HTTP FOF\",\"initialCash\":\"42.10\",\"initialUnitNav\":\"1.25000000\",\"isSynthetic\":true}" + + let boundaryBody = + "{\"name\":\"最大边界 FOF\",\"initialCash\":\"999999999999999999.99\",\"initialUnitNav\":\"99999999999999999999.99999999\",\"isSynthetic\":true}" + + let overCashBody = + "{\"name\":\"超额现金 FOF\",\"initialCash\":\"1000000000000000000.00\",\"initialUnitNav\":\"1.00000000\",\"isSynthetic\":true}" + + let overNavBody = + "{\"name\":\"超额净值 FOF\",\"initialCash\":\"100.00\",\"initialUnitNav\":\"100000000000000000000.00000000\",\"isSynthetic\":true}" + + let exerciseFirstProcess (api: ApiProcess) = + PersistenceTestHelpers.waitForApi api + use client = new HttpClient() + client.BaseAddress <- Uri(api.BaseAddress) + + let unauthorizedStatus, _ = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + None + (Some(fixture.Key("http-unauthorized-key"))) + requestBody + + Assert.Equal(401, unauthorizedStatus) + + let malformedStatus, malformedBody = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some(fixture.Key("http-malformed-json-key"))) + "{\"name\":" + + Assert.Equal(400, malformedStatus) + Assert.Contains("INVALID_FUND_REQUEST", malformedBody) + + for body, suffix in [ "[]", "array"; "null", "null" ] do + let status, responseBody = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some(fixture.Key(sprintf "http-json-root-%s" suffix))) + body + + Assert.Equal(400, status) + Assert.Contains("INVALID_FUND_REQUEST", responseBody) + + let boundaryStatus, boundaryResponse = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some(fixture.Key("http-boundary-key"))) + boundaryBody + + Assert.Equal(201, boundaryStatus) + Assert.Contains("\"initialCash\":\"999999999999999999.99\"", boundaryResponse) + Assert.Contains("\"initialUnitNav\":\"99999999999999999999.99999999\"", boundaryResponse) + + for body, suffix in [ overCashBody, "http-over-cash-key"; overNavBody, "http-over-nav-key" ] do + let status, responseBody = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some(fixture.Key(suffix))) + body + + Assert.Equal(400, status) + Assert.Contains("INVALID_FUND_REQUEST", responseBody) + + let key = fixture.Key("http-restart-key") + let createStatus, createBody = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some key) + requestBody + + Assert.Equal(201, createStatus) + Assert.Contains("\"initialCash\":\"42.10\"", createBody) + Assert.Contains("\"initialUnitNav\":\"1.25000000\"", createBody) + + let fundId = PersistenceTestHelpers.responseId createBody + let replayStatus, replayBody = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some key) + requestBody + + Assert.Equal(200, replayStatus) + Assert.Equal(fundId, PersistenceTestHelpers.responseId replayBody) + + let conflictBody = + "{\"name\":\"真实 HTTP 冲突 FOF\",\"initialCash\":\"43.10\",\"initialUnitNav\":\"1.25000000\",\"isSynthetic\":true}" + + let conflictStatus, conflictResponse = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some key) + conflictBody + + Assert.Equal(409, conflictStatus) + Assert.Contains("IDEMPOTENCY_CONFLICT", conflictResponse) + + let readStatus, readBody = + PersistenceTestHelpers.send + client + "GET" + (sprintf "/api/funds/%O" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, readStatus) + Assert.Equal(fundId, PersistenceTestHelpers.responseId readBody) + Assert.Contains("\"initialCash\":\"42.10\"", readBody) + Assert.Contains("\"initialUnitNav\":\"1.25000000\"", readBody) + fundId, key + + use firstProcess = new ApiProcess(fixture.ConnectionString) + let fundId, key = exerciseFirstProcess firstProcess + firstProcess.Stop() |> ignore + + use restartedProcess = new ApiProcess(fixture.ConnectionString) + PersistenceTestHelpers.waitForApi restartedProcess + use restartedClient = new HttpClient() + restartedClient.BaseAddress <- Uri(restartedProcess.BaseAddress) + + let restartedReadStatus, restartedReadBody = + PersistenceTestHelpers.send + restartedClient + "GET" + (sprintf "/api/funds/%O" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, restartedReadStatus) + Assert.Equal(fundId, PersistenceTestHelpers.responseId restartedReadBody) + + let restartedReplayStatus, restartedReplayBody = + PersistenceTestHelpers.send + restartedClient + "POST" + "/api/funds" + (Some "test-token") + (Some key) + requestBody + + Assert.Equal(200, restartedReplayStatus) + Assert.Equal(fundId, PersistenceTestHelpers.responseId restartedReplayBody) -- cgit v1.2.3