summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 00:06:45 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 00:06:45 +0800
commit2ada70d6467aec11b45328112d598454ca50f2d6 (patch)
tree5deab7d12095dfd0599c109edc2200a38258040e /tests
parentc60905e9e7f992a7f8c79c3812e92a44b2d606f5 (diff)
downloadfund-lab-2ada70d6467aec11b45328112d598454ca50f2d6.tar.gz
feat(api): 接入基金 PostgreSQL 持久化与幂等接口
[变更性质] - 本提交新增基金创建与读取的 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 修改。
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj2
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs774
2 files changed, 776 insertions, 0 deletions
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 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
+ <PackageReference Include="Npgsql" Version="8.0.8" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
@@ -20,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="ApiTests.fs" />
+ <Compile Include="PersistenceTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
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)
+
+[<CollectionDefinition("postgres")>]
+type PostgresCollection =
+ inherit ICollectionFixture<PostgresFixture>
+
+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<HttpContext option>(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
+
+[<Collection("postgres")>]
+type PersistenceTests(fixture: PostgresFixture) =
+ let repository () =
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ [<Fact>]
+ 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")
+
+ [<Fact>]
+ 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")
+
+ [<Fact>]
+ 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
+
+ [<Fact>]
+ 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
+
+ [<Fact>]
+ 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
+
+ [<Fact>]
+ 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
+
+ [<Fact>]
+ 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<Npgsql.PostgresException>(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
+
+ [<Fact>]
+ 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])
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)