diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:06:45 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:06:45 +0800 |
| commit | 2ada70d6467aec11b45328112d598454ca50f2d6 (patch) | |
| tree | 5deab7d12095dfd0599c109edc2200a38258040e | |
| parent | c60905e9e7f992a7f8c79c3812e92a44b2d606f5 (diff) | |
| download | fund-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 修改。
| -rw-r--r-- | .env.example | 1 | ||||
| -rw-r--r-- | README.md | 21 | ||||
| -rw-r--r-- | docs/leader-environment-unblock.md | 12 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 148 | ||||
| -rw-r--r-- | src/FundLab.Api/FundLab.Api.fsproj | 2 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 282 | ||||
| -rw-r--r-- | src/FundLab.Api/Program.fs | 10 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 2 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/PersistenceTests.fs | 774 |
9 files changed, 1237 insertions, 15 deletions
diff --git a/.env.example b/.env.example index f7fd666..28fa925 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ FUND_LAB_AUTH_TOKEN=replace-with-a-local-secret +FUND_LAB_DATABASE_URL=Host=127.0.0.1;Port=5432;Database=fund_lab;Username=postgres;Password=replace-with-a-local-secret FUND_LAB_TRUSTED_PROXY_CIDRS= ASPNETCORE_URLS=http://127.0.0.1:5080 @@ -1,16 +1,18 @@ # fund-lab -私人单用户模拟基金平台。当前仓库处于阶段 1 骨架和阶段 2a 纯 F# 账本切片阶段;尚未接入数据库、真实行情或真实下单。 +私人单用户模拟基金平台。当前仓库处于阶段 1 骨架和阶段 2a 纯 F# 账本切片阶段;已接入基金创建/读取 PostgreSQL 持久化,尚未接入真实行情或真实下单。 ## 本地验证 在仓库根目录运行: ```sh -dotnet test -dotnet build FundLab.sln +dotnet build "FundLab.sln" --no-restore +dotnet test "FundLab.sln" --no-restore ``` +API 持久化测试默认启动临时 Docker PostgreSQL 容器;运行 `dotnet test` 前需要 Docker。也可以用 `FUND_LAB_TEST_DATABASE_URL` 指向已有 PostgreSQL 实例。 + 只运行账本领域测试: ```sh @@ -21,10 +23,15 @@ dotnet test tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj ```sh cd src/FundLab.Web -npm install -npm run build +PATH=/home/somhairle/projects/fund-lab/.tools/node-v22.23.2/bin:$PATH npm run build ``` -API 使用环境变量 `FUND_LAB_AUTH_TOKEN` 配置 Bearer token;仓库只提供 `.env.example`,不保存真实凭证。`/health` 可匿名访问,业务 API 需要 `Authorization: Bearer <token>`。 +仓库自带可复用的 Node.js 22.23.2/npm 10.9.8;系统 PATH 没有 npm 时使用上面的项目本地 PATH,不需要重新安装依赖。最近一次收口验证结果为:Fable/Vite 成功转换 65 个模块并以 exit 0 结束。 + +API 使用环境变量 `FUND_LAB_AUTH_TOKEN` 配置 Bearer token,使用 `FUND_LAB_DATABASE_URL` 配置 PostgreSQL 连接字符串;仓库只提供 `.env.example`,不保存真实凭证。`/health` 可匿名访问,业务 API 需要 `Authorization: Bearer <token>`。 + +基金创建接口为 `POST /api/funds`,需要 `Idempotency-Key` 和 decimal 字符串字段;基金读取接口为 `GET /api/funds/{id}`。首次启动会自动创建所需表。 + +运行时数据库、行情缓存、凭证和构建产物不应进入 Git。当前 `origin` 为 `/home/somhairle/git/fund-lab.git`;基线提交已独立发布并核对,最新 3a 持久化修改在完成最终验证前保持在本地工作树。 -运行时数据库、行情缓存、凭证和构建产物不应进入 Git。远端 Git 写入机制和准确 remote URL 尚未确认,因此当前只进行本地开发和验证。 +3a 收口测试还会启动独立临时 PostgreSQL 和真实 Kestrel 进程,覆盖 HTTP 创建、幂等重放/冲突、认证、畸形 JSON、金额边界、停止重启后的读取,以及首次 SQL 写入后故障的完整事务回滚。 diff --git a/docs/leader-environment-unblock.md b/docs/leader-environment-unblock.md index d3ea84d..1fe9186 100644 --- a/docs/leader-environment-unblock.md +++ b/docs/leader-environment-unblock.md @@ -1,6 +1,6 @@ # Hermes environment verification -Verified independently on host, for next worker/supervisor step. Do not interrupt current ledger fix to repeat discovery. +Verified independently on host for the 3a closeout. Do not repeat environment discovery or reinstall local tools. ## Frontend @@ -12,7 +12,15 @@ Use this PATH for frontend builds. Node/npm absence in system PATH is not missin ## Git service discovery Host /home/somhairle/gitweb/docker-compose.yml maps /home/somhairle/git to /git in gitweb container. cgitrc uses scan-path=/git. Existing bare-style repository directories: blog.git, living-village.git, sandbox.git, strategy-lab.git under /home/somhairle/git. Thus actual local serving root has been found. Do not continue guessing web creation APIs. -Next authorized operational step: verify running mount/config, ownership/write permissions and an existing repo's bare status. Only after secret exclusions review, initialize independent fund-lab.git within confirmed root, establish exact local remote, push and compare commit hash with public cgit/remote readback. This needs precise external-directory access; if OpenCode access denied, stop and have Hermes perform authorized operational repository setup, not bypass permissions. Never modify existing repos or shared service configuration to add the new repository. Bare repository setup is now independently complete: /home/somhairle/git/fund-lab.git, verified bare=true and HEAD=refs/heads/main. Running gitweb mount was read back and maps /home/somhairle/git to /git read-write. The working tree still has no initial commit or remote; no source push or public code-tree verification has occurred. A preliminary scan of 33 nonignored candidate files found no private-key/token/password-pattern matches; repeat the scan on the exact final commit before publishing. Use this existing bare target, do not recreate it. +The authorized repository target is already complete: `/home/somhairle/git/fund-lab.git`, verified bare with `HEAD=refs/heads/main`; `origin` points to that exact path and the published baseline hash was independently checked. Never modify existing repositories or shared service configuration, and do not recreate this target. Repeat secret exclusion checks on the exact final 3a commit before publishing any further source. + +## Current 3a closeout + +- The current worktree contains the uncommitted PostgreSQL persistence/API slice and its tests; `docs/overnight-progress.md` contains separate Hermes-owned progress evidence and must remain untouched by the 3a commit. +- Reproducible backend commands from the repository root are `dotnet build "FundLab.sln" --no-restore` and `dotnet test "FundLab.sln" --no-restore`. The current run passed with 0 build warnings/errors, Domain `19/19`, and API `16/16`. +- Reproducible frontend command from `src/FundLab.Web` is `PATH=/home/somhairle/projects/fund-lab/.tools/node-v22.23.2/bin:$PATH npm run build`; the current run passed with Fable/Vite converting 65 modules. +- The API test suite now uses a temporary PostgreSQL database and a real Kestrel process. It verifies HTTP input/auth/idempotency boundaries, process stop/restart persistence, and a PostgreSQL trigger failure after the `funds` insert with no `funds` or `fund_idempotencies` residue. +- Remaining closeout work is a final fresh verification, secret exclusion scan, explicit file-list staging, and one 3a commit. Do not stage `docs/overnight-progress.md`. ## Work order diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 44628ab..0eb367d 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -1,6 +1,9 @@ namespace FundLab.Api open System +open System.Globalization +open System.IO +open System.Text.Json open Giraffe open Microsoft.AspNetCore.Http @@ -10,7 +13,103 @@ type EmptyPortfolioResponse = message: string } +type FundApiResponse = + { + id: Guid + name: string + currency: string + initialCash: string + initialUnitNav: string + isSynthetic: bool + availableCash: string + status: string + } + +type ApiErrorResponse = + { + error: string + message: string + } + module App = + let private invariant = CultureInfo.InvariantCulture + + let private cashText (value: decimal) = value.ToString("0.00", invariant) + let private unitNavText (value: decimal) = value.ToString("0.00000000", invariant) + + let private fundResponse (fund: FundRecord) : FundApiResponse = + { + id = fund.Id + name = fund.Name + currency = fund.Currency + initialCash = cashText fund.InitialCash + initialUnitNav = unitNavText fund.InitialUnitNav + isSynthetic = fund.IsSynthetic + availableCash = cashText fund.AvailableCash + status = fund.Status + } + + let private errorResponse status error message : HttpHandler = + setStatusCode status + >=> json ({ + error = error + message = message + } : ApiErrorResponse) + + let private tryStringProperty (root: JsonElement) (name: string) = + let mutable property = Unchecked.defaultof<JsonElement> + + if root.TryGetProperty(name, &property) && property.ValueKind = JsonValueKind.String then + property.GetString() |> Option.ofObj + else + None + + let private tryBoolProperty (root: JsonElement) (name: string) = + let mutable property = Unchecked.defaultof<JsonElement> + + if root.TryGetProperty(name, &property) then + match property.ValueKind with + | JsonValueKind.True -> Some true + | JsonValueKind.False -> Some false + | _ -> None + else + None + + let private tryDecimal (label: string) (text: string) = + if String.IsNullOrWhiteSpace text then + Error(sprintf "%s must be a decimal string" label) + else + match Decimal.TryParse(text, NumberStyles.AllowLeadingSign ||| NumberStyles.AllowDecimalPoint, invariant) with + | true, value -> Ok value + | false, _ -> Error(sprintf "%s must be a decimal string" label) + + let private parseFundCommand (body: string) = + try + use document = JsonDocument.Parse(body) + let root = document.RootElement + + if root.ValueKind <> JsonValueKind.Object then + Error "request body must be a JSON object" + else + match tryStringProperty root "name", tryStringProperty root "initialCash", tryStringProperty root "initialUnitNav", tryBoolProperty root "isSynthetic" with + | Some name, Some initialCashText, Some initialUnitNavText, Some isSynthetic -> + match tryDecimal "initialCash" initialCashText, tryDecimal "initialUnitNav" initialUnitNavText with + | Ok initialCash, Ok initialUnitNav -> + Ok { + Name = name + InitialCash = initialCash + InitialUnitNav = initialUnitNav + IsSynthetic = isSynthetic + } + | Error message, _ + | _, Error message -> Error message + | _ -> + Error "name, initialCash, initialUnitNav and isSynthetic are required" + with + | :? JsonException -> Error "request body must be valid JSON" + + let private invokeHandler handler next ctx = handler next ctx + let private unauthorized : HttpHandler = setStatusCode 401 >=> setHttpHeader "WWW-Authenticate" "Bearer" @@ -35,18 +134,57 @@ module App = } : HealthResponse) let private emptyPortfolio : HttpHandler = - json { - status = "empty" - message = "尚未创建基金/尚未选择投资" - } + json ({ + status = "empty" + message = "尚未创建基金/尚未选择投资" + } : EmptyPortfolioResponse) + + let private createFund (repository: FundRepository) : HttpHandler = + fun next ctx -> + task { + use reader = new StreamReader(ctx.Request.Body) + let! body = reader.ReadToEndAsync() + let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString() + + match parseFundCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_FUND_REQUEST" message) next ctx + | Ok command -> + try + match repository.CreateFund(idempotencyKey, command) with + | FundWriteResult.Created fund -> + return! invokeHandler (setStatusCode 201 >=> json (fundResponse fund)) next ctx + | FundWriteResult.Replayed fund -> + return! invokeHandler (json (fundResponse fund)) next ctx + | FundWriteResult.IdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | FundWriteResult.Invalid message -> + return! invokeHandler (errorResponse 400 "INVALID_FUND_REQUEST" message) next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed") next ctx + } + + let private getFund (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx + | true, fundId -> + try + match repository.GetFund fundId with + | Some fund -> json (fundResponse fund) next ctx + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx - let createApplication () : HttpHandler = + let createApplication (repository: FundRepository) : HttpHandler = choose [ GET >=> route "/health" >=> health subRoute "/api" ( requireBearer >=> choose [ GET >=> route "/portfolio/summary" >=> emptyPortfolio + POST >=> route "/funds" >=> createFund repository + GET >=> routef "/funds/%s" (getFund repository) ] ) setStatusCode 404 >=> text "Not Found" diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj index 81952df..8a3f590 100644 --- a/src/FundLab.Api/FundLab.Api.fsproj +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -7,6 +7,7 @@ </PropertyGroup> <ItemGroup> <PackageReference Include="Giraffe" Version="6.0.0" /> + <PackageReference Include="Npgsql" Version="8.0.8" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\FundLab.Domain\FundLab.Domain.fsproj" /> @@ -14,6 +15,7 @@ <ItemGroup> <Compile Include="Authentication.fs" /> <Compile Include="Health.fs" /> + <Compile Include="Persistence.fs" /> <Compile Include="App.fs" /> <Compile Include="Program.fs" /> </ItemGroup> diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs new file mode 100644 index 0000000..db0fb6f --- /dev/null +++ b/src/FundLab.Api/Persistence.fs @@ -0,0 +1,282 @@ +namespace FundLab.Api + +open System +open System.Data +open System.Data.Common +open System.Globalization +open System.Security.Cryptography +open System.Text +open FundLab.Domain +open Npgsql +open NpgsqlTypes + +[<CLIMutable>] +type FundCreateCommand = + { + Name: string + InitialCash: decimal + InitialUnitNav: decimal + IsSynthetic: bool + } + +type FundRecord = + { + Id: Guid + Name: string + Currency: string + InitialCash: decimal + InitialUnitNav: decimal + IsSynthetic: bool + AvailableCash: decimal + Status: string + } + +type FundWriteResult = + | Created of FundRecord + | Replayed of FundRecord + | IdempotencyConflict + | Invalid of string + +type FundRepository(connectionString: string) = + let cashMaximum = 999999999999999999.99m + let unitNavMaximum = 99999999999999999999.99999999m + + let schema = + """ + CREATE TABLE IF NOT EXISTS funds ( + id uuid PRIMARY KEY, + name text NOT NULL, + currency text NOT NULL, + initial_cash numeric(20, 2) NOT NULL, + initial_unit_nav numeric(28, 8) NOT NULL, + is_synthetic boolean NOT NULL, + available_cash numeric(20, 2) NOT NULL, + status text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS fund_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); + """ + + let statusText status = + match status with + | FundStatus.Empty -> "empty" + | FundStatus.Active -> "active" + | FundStatus.ZeroUnits -> "zero_units" + + let recordFromLedger (fund: LedgerFund) = + { + Id = fund.Id + Name = fund.Name + Currency = fund.Currency + InitialCash = fund.InitialCash + InitialUnitNav = fund.InitialUnitNav + IsSynthetic = fund.IsSynthetic + AvailableCash = fund.AvailableCash + Status = statusText fund.Status + } + + let recordFromReader (reader: DbDataReader) = + { + Id = reader.GetGuid(0) + Name = reader.GetString(1) + Currency = reader.GetString(2) + InitialCash = reader.GetDecimal(3) + InitialUnitNav = reader.GetDecimal(4) + IsSynthetic = reader.GetBoolean(5) + AvailableCash = reader.GetDecimal(6) + Status = reader.GetString(7) + } + + let commandWithTransaction (connection: NpgsqlConnection) (transaction: NpgsqlTransaction option) sql = + let command = connection.CreateCommand() + command.CommandText <- sql + + match transaction with + | Some value -> command.Transaction <- value + | None -> () + + command + + let addParameter (command: NpgsqlCommand) name dbType (value: obj) = + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + parameter + + let findFund connection transaction fundId = + use command = + commandWithTransaction + connection + transaction + """ + SELECT id, name, currency, initial_cash, initial_unit_nav, + is_synthetic, available_cash, status + FROM funds + WHERE id = @fund_id + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(recordFromReader reader) else None + + let findIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + "SELECT request_hash, fund_id FROM fund_idempotencies WHERE idempotency_key = @idempotency_key" + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(reader.GetString(0), reader.GetGuid(1)) else None + + let insertFund connection transaction (fund: LedgerFund) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO funds + (id, name, currency, initial_cash, initial_unit_nav, + is_synthetic, available_cash, status) + VALUES + (@id, @name, @currency, @initial_cash, @initial_unit_nav, + @is_synthetic, @available_cash, @status) + """ + + addParameter command "id" NpgsqlDbType.Uuid (box fund.Id) |> ignore + addParameter command "name" NpgsqlDbType.Text (box fund.Name) |> ignore + addParameter command "currency" NpgsqlDbType.Text (box fund.Currency) |> ignore + addParameter command "initial_cash" NpgsqlDbType.Numeric (box fund.InitialCash) |> ignore + addParameter command "initial_unit_nav" NpgsqlDbType.Numeric (box fund.InitialUnitNav) |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box fund.IsSynthetic) |> ignore + addParameter command "available_cash" NpgsqlDbType.Numeric (box fund.AvailableCash) |> ignore + addParameter command "status" NpgsqlDbType.Text (box (statusText fund.Status)) |> ignore + command.ExecuteNonQuery() |> ignore + + let insertIdempotency connection transaction key requestHash fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO fund_idempotencies (idempotency_key, request_hash, fund_id) + VALUES (@idempotency_key, @request_hash, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + + let requestHash (command: FundCreateCommand) = + let invariant = CultureInfo.InvariantCulture + let encoded (value: string) = sprintf "%d:%s" value.Length value + let name = if isNull command.Name then "" else command.Name + let payload = + String.concat + "|" + [ + "fund-create" + encoded name + (encoded (command.InitialCash.ToString("G29", invariant))) + (encoded (command.InitialUnitNav.ToString("G29", invariant))) + (encoded (command.IsSynthetic.ToString())) + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + + let validateStorageRange (command: FundCreateCommand) = + if command.InitialCash > cashMaximum then + Error "initial cash exceeds database precision" + elif command.InitialUnitNav > unitNavMaximum then + Error "initial unit NAV exceeds database precision" + else + Ok() + + let ledgerErrorMessage error = + match error with + | InvalidIdentifier label -> sprintf "%s is invalid" label + | InvalidAmount label -> sprintf "%s is invalid" label + | InvalidPrecision label -> sprintf "%s has invalid precision" label + | InvalidState message -> message + | FundAlreadyExists fundId -> sprintf "fund %O already exists" fundId + | other -> sprintf "%A" other + + member _.EnsureSchema() = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- schema + command.ExecuteNonQuery() |> ignore + + member _.GetFund(fundId: Guid) = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + findFund connection None fundId + + member _.CreateFund(idempotencyKey: string, command: FundCreateCommand) = + if String.IsNullOrWhiteSpace idempotencyKey then + FundWriteResult.Invalid "idempotency key cannot be empty" + else + match validateStorageRange command with + | Error message -> FundWriteResult.Invalid message + | Ok() -> + let fingerprint = requestHash command + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, fundId) when existingHash = fingerprint -> + match findFund connection (Some transaction) fundId with + | Some fund -> + transaction.Commit() + FundWriteResult.Replayed fund + | None -> + transaction.Rollback() + FundWriteResult.Invalid "idempotency record references a missing fund" + | Some _ -> + transaction.Rollback() + FundWriteResult.IdempotencyConflict + | None -> + let fundId = Guid.NewGuid() + + match Ledger.initializeFund fundId command.Name command.InitialCash command.InitialUnitNav command.IsSynthetic Ledger.empty with + | Error error -> + transaction.Rollback() + FundWriteResult.Invalid(ledgerErrorMessage error) + | Ok state -> + match Ledger.getFund fundId state with + | Error error -> + transaction.Rollback() + FundWriteResult.Invalid(ledgerErrorMessage error) + | Ok fund -> + insertFund connection (Some transaction) fund + insertIdempotency connection (Some transaction) idempotencyKey fingerprint fundId + transaction.Commit() + FundWriteResult.Created(recordFromLedger fund) + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs index d5cc0b2..4c757d6 100644 --- a/src/FundLab.Api/Program.fs +++ b/src/FundLab.Api/Program.fs @@ -1,15 +1,23 @@ module FundLab.Api.Program +open System open Giraffe open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection +let private requiredEnvironment name = + match Environment.GetEnvironmentVariable(name) with + | value when not (String.IsNullOrWhiteSpace value) -> value + | _ -> failwithf "%s must be configured" name + [<EntryPoint>] let main argv = let builder = WebApplication.CreateBuilder(argv) builder.Services.AddGiraffe() |> ignore + let repository = FundRepository(requiredEnvironment "FUND_LAB_DATABASE_URL") + repository.EnsureSchema() let app = builder.Build() - app.UseGiraffe(App.createApplication()) + app.UseGiraffe(App.createApplication(repository)) app.Run() 0 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) |
