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 let queryIndex = path.IndexOf('?') if queryIndex >= 0 then context.Request.Path <- PathString(path.Substring(0, queryIndex)) context.Request.QueryString <- QueryString(path.Substring(queryIndex)) else 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 _.``market data survives repository restart with source provenance``() = let code = Random.Shared.Next(0, 1000000).ToString("D6") let collectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero) let searchPayload = { Source = "akshare" SourceRevision = "akshare-test/eastmoney" CollectedAt = collectedAt Instruments = [ { Code = code Name = "测试基金" FundType = None } ] } let navPayload = { Source = "akshare" SourceRevision = "akshare-test/eastmoney" CollectedAt = collectedAt Code = code Observations = [ { NavDate = DateOnly(2026, 9, 18) PublishedAt = None Nav = 1.23456789m AccumulatedNav = Some 2.34567890m DailyReturn = Some -0.12m } ] } let firstRepository = repository () firstRepository.UpsertInstruments(searchPayload, "search-hash") firstRepository.UpsertNavObservations(navPayload, "nav-hash") let restarted = FundRepository(fixture.ConnectionString) match restarted.GetInstrument(code) with | Some instrument -> Assert.Equal(code, instrument.Code) Assert.Equal("测试基金", instrument.Name) Assert.Equal(None, instrument.FundType) Assert.Equal("akshare-test/eastmoney", instrument.SourceRevision) Assert.Equal("search-hash", instrument.SourcePayloadHash) | None -> Assert.Fail("instrument was not readable after repository restart") match restarted.GetNav(code, Some(DateOnly(2026, 9, 1)), Some(DateOnly(2026, 9, 30))) with | [ observation ] -> Assert.Equal(code, observation.Code) Assert.Equal(DateOnly(2026, 9, 18), observation.NavDate) Assert.Equal(None, observation.PublishedAt) Assert.Equal(1.23456789m, observation.Nav) Assert.Equal(Some 2.34567890m, observation.AccumulatedNav) Assert.Equal(Some -0.12m, observation.DailyReturn) Assert.Equal("nav-hash", observation.SourcePayloadHash) | observations -> Assert.Fail(sprintf "expected one NAV observation, got %d" observations.Length) [] 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 _.``market data API searches exact codes and returns persisted NAV dates``() = let code = "000001" let collectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero) let searchPayload = { Source = "akshare" SourceRevision = "akshare-test/eastmoney" CollectedAt = collectedAt Instruments = [ { Code = code Name = "华夏成长混合" FundType = Some "混合型-灵活" } ] } let observation = { Code = code NavDate = DateOnly(2026, 9, 18) PublishedAt = None Nav = 1.23456789m AccumulatedNav = None DailyReturn = Some -0.12m Source = "akshare" SourceRevision = "akshare-test/eastmoney" SourceCollectedAt = collectedAt SourcePayloadHash = "nav-hash" FirstSeenAt = collectedAt LastSeenAt = collectedAt } let marketData = { new IMarketDataService with member _.Search _ = Ok searchPayload member _.RefreshNav _ = Ok [ observation ] member _.GetNav(_, _, _) = Ok [ observation ] } let app = App.createApplicationWithMarketData (repository ()) marketData let searchStatus, searchBody = PersistenceTestHelpers.invoke app "GET" "/api/instruments/search?q=000001" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, searchStatus) Assert.Contains("\"code\":\"000001\"", searchBody) Assert.Contains("华夏成长混合", searchBody) let navStatus, navBody = PersistenceTestHelpers.invoke app "POST" "/api/instruments/000001/nav/refresh" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, navStatus) Assert.Contains("\"navDate\":\"2026-09-18\"", navBody) Assert.Contains("\"source\":\"akshare\"", navBody) [] 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)