summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs51
-rw-r--r--tests/FundLab.Api.Tests/ProcessCollectorTests.fs232
3 files changed, 282 insertions, 2 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index c976006..63f0750 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="ApiTests.fs" />
+ <Compile Include="ProcessCollectorTests.fs" />
<Compile Include="PersistenceTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
diff --git a/tests/FundLab.Api.Tests/PersistenceTests.fs b/tests/FundLab.Api.Tests/PersistenceTests.fs
index 5157a9c..64e857c 100644
--- a/tests/FundLab.Api.Tests/PersistenceTests.fs
+++ b/tests/FundLab.Api.Tests/PersistenceTests.fs
@@ -695,8 +695,8 @@ type PersistenceTests(fixture: PostgresFixture) =
let marketData =
{ new IMarketDataService with
- member _.Search _ = Ok searchPayload
- member _.RefreshNav _ = Ok [ observation ]
+ member _.Search(_, _) = Ok searchPayload
+ member _.RefreshNav(_, _) = Ok [ observation ]
member _.GetNav(_, _, _) = Ok [ observation ] }
let app = App.createApplicationWithMarketData (repository ()) marketData
@@ -726,6 +726,53 @@ type PersistenceTests(fixture: PostgresFixture) =
Assert.Contains("\"source\":\"akshare\"", navBody)
[<Fact>]
+ member _.``market data API keeps collector failures and process tree out of HTTP responses``() =
+ let dir = Path.Combine(Path.GetTempPath(), "fund-lab-collector-" + Guid.NewGuid().ToString("N"))
+
+ Directory.CreateDirectory(dir) |> ignore
+
+ try
+ let scriptPath = Path.Combine(dir, "hang.sh")
+
+ File.WriteAllText(
+ scriptPath,
+ "printf 'secret=%s\\n' 'fund-lab-HTTP-SENTINEL' >&2\nsleep 60 & child=$!\necho $child > pidfile\nwait $child\n"
+ )
+
+ let collector =
+ ProcessMarketDataCollector("/bin/sh", scriptPath, None, 1) :> IMarketDataCollector
+
+ let marketData = MarketDataService(repository (), collector)
+ let app = App.createApplicationWithMarketData (repository ()) marketData
+
+ let status, responseBody =
+ PersistenceTestHelpers.invoke
+ app
+ "GET"
+ "/api/instruments/search?q=000001"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(503, status)
+ Assert.Contains("MARKET_DATA_UNAVAILABLE", responseBody)
+ Assert.Contains("did not finish within 1 seconds", responseBody)
+ Assert.DoesNotContain(dir, responseBody)
+ Assert.DoesNotContain("fund-lab-HTTP-SENTINEL", responseBody)
+
+ let recordedPid = Int32.Parse(File.ReadAllText(Path.Combine(dir, "pidfile")).Trim())
+ let mutable dead = not (Directory.Exists(sprintf "/proc/%d" recordedPid))
+ let mutable waited = 0
+
+ while not dead && waited < 10000 do
+ Thread.Sleep(100)
+ waited <- waited + 100
+ dead <- not (Directory.Exists(sprintf "/proc/%d" recordedPid))
+
+ Assert.True(dead, "backgrounded subprocess survived the HTTP timeout")
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
member _.``fund API rejects non-object JSON bodies``() =
let app = App.createApplication (repository ())
diff --git a/tests/FundLab.Api.Tests/ProcessCollectorTests.fs b/tests/FundLab.Api.Tests/ProcessCollectorTests.fs
new file mode 100644
index 0000000..32009bf
--- /dev/null
+++ b/tests/FundLab.Api.Tests/ProcessCollectorTests.fs
@@ -0,0 +1,232 @@
+namespace FundLab.Api.Tests
+
+module ProcessCollectorTests =
+
+ open System
+ open System.Diagnostics
+ open System.IO
+ open System.Threading
+ open Xunit
+ open FundLab.Api
+
+ let private secretSentinel = "fund-lab-SECRET-SENTINEL"
+
+ let private fixtureDir () =
+ let dir = Path.Combine(Path.GetTempPath(), "fund-lab-collector-" + Guid.NewGuid().ToString("N"))
+
+ Directory.CreateDirectory(dir) |> ignore
+
+ dir
+
+ let private writeScript (dir: string) (name: string) (body: string) =
+ let path = Path.Combine(dir, name)
+ File.WriteAllText(path, body)
+ path
+
+ let private searchEnvelope =
+ "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"search\",\"source\":\"fixture\",\"source_revision\":\"fixture/1\",\"collected_at\":\"2026-09-21T00:00:00Z\",\"_synthetic\":true,\"instruments\":[{\"code\":\"000001\",\"name\":\"fixture fund\",\"fund_type\":null}]}"
+
+ let private hangScript dir =
+ writeScript dir "hang.sh" ("sleep 60 & child=$!\necho $child > pidfile\nwait $child\n")
+
+ let private orphanScript dir =
+ writeScript
+ dir
+ "orphan.sh"
+ ("sleep 30 & child=$!\necho $child > pidfile\nprintf '%s' '" + searchEnvelope + "'\nexit 0\n")
+
+ let private recordedPid (dir: string) =
+ Int32.Parse(File.ReadAllText(Path.Combine(dir, "pidfile")).Trim())
+
+ let private pidAlive (pid: int) = Directory.Exists(sprintf "/proc/%d" pid)
+
+ let private waitForPidDeath (pid: int) =
+ let mutable dead = not (pidAlive pid)
+ let mutable waited = 0
+
+ while not dead && waited < 10000 do
+ Thread.Sleep(100)
+ waited <- waited + 100
+ dead <- not (pidAlive pid)
+
+ dead
+
+ [<Fact>]
+ let ``collector captures stdout from a successful subprocess`` () =
+ let dir = fixtureDir ()
+
+ try
+ let script = writeScript dir "success.sh" ("printf '%s' '" + searchEnvelope + "'")
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector
+
+ match collector.Search("000001", CancellationToken.None) with
+ | Ok raw ->
+ Assert.Contains("000001", raw)
+ Assert.Contains("\"_synthetic\":true", raw)
+ | Error message -> Assert.Fail(sprintf "expected success, got %s" message)
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``collector drains concurrent stderr flood without deadlocking`` () =
+ let dir = fixtureDir ()
+
+ try
+ let body = "yes 'flood padding padding padding padding' | head -n 5000 >&2\nprintf '%s' '" + searchEnvelope + "'"
+
+ let script = writeScript dir "flood.sh" body
+
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector
+
+ match collector.Search("000001", CancellationToken.None) with
+ | Ok raw -> Assert.Contains("000001", raw)
+ | Error message -> Assert.Fail(sprintf "expected success, got %s" message)
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``collector enforces deadline, kills owned processes and sanitizes error`` () =
+ let dir = fixtureDir ()
+
+ try
+ let script = hangScript dir
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 1) :> IMarketDataCollector
+ let stopwatch = Stopwatch.StartNew()
+
+ match collector.Search("000001", CancellationToken.None) with
+ | Error message ->
+ stopwatch.Stop()
+ Assert.Contains("did not finish within 1 seconds", message)
+ Assert.DoesNotContain(dir, message)
+ Assert.DoesNotContain(secretSentinel, message)
+ | Ok raw -> Assert.Fail(sprintf "expected timeout error, got %s" raw)
+
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(20.0))
+ Assert.True(waitForPidDeath (recordedPid dir), "backgrounded subprocess survived the deadline kill")
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``collector reports cancellation and kills owned processes`` () =
+ let dir = fixtureDir ()
+
+ try
+ let script = hangScript dir
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector
+ use cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500.0))
+
+ match collector.Search("000001", cts.Token) with
+ | Error message ->
+ Assert.Equal("collector run was cancelled", message)
+ Assert.DoesNotContain(dir, message)
+ Assert.DoesNotContain(secretSentinel, message)
+ | Ok raw -> Assert.Fail(sprintf "expected cancellation, got %s" raw)
+
+ Assert.True(waitForPidDeath (recordedPid dir), "backgrounded subprocess survived cancellation")
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``collector recovers payload and reaps pipe-holding orphan after parent exit`` () =
+ let dir = fixtureDir ()
+
+ try
+ let script = orphanScript dir
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 5) :> IMarketDataCollector
+ let stopwatch = Stopwatch.StartNew()
+
+ let result = collector.Search("000001", CancellationToken.None)
+ stopwatch.Stop()
+
+ match result with
+ | Ok raw ->
+ Assert.Equal(searchEnvelope, raw)
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(20.0))
+ | Error message ->
+ Assert.Fail(sprintf "expected recovered payload, got error %s" message)
+
+ Assert.True(waitForPidDeath (recordedPid dir), "pipe-holding orphan survived parent exit")
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``collector surfaces malformed payload for parser to reject`` () =
+ let dir = fixtureDir ()
+
+ try
+ let script = writeScript dir "malformed.sh" "printf '%s' '{ not json at all'"
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector
+
+ match collector.Search("000001", CancellationToken.None) with
+ | Ok raw ->
+ Assert.Equal("{ not json at all", raw)
+
+ match MarketData.parseSearchPayload raw with
+ | Error _ -> ()
+ | Ok _ -> Assert.Fail("expected malformed payload to fail parsing")
+ | Error message -> Assert.Fail(sprintf "expected raw output, got error %s" message)
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``nonzero exit keeps stderr sentinel and paths out of the public error`` () =
+ let dir = fixtureDir ()
+
+ try
+ let body =
+ "printf 'secret=%s\\n' '"
+ + secretSentinel
+ + "' >&2\nprintf 'script lives under "
+ + dir
+ + "\\n' >&2\nexit 3\n"
+
+ let script = writeScript dir "failing.sh" body
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector
+
+ match collector.Search("000001", CancellationToken.None) with
+ | Error message ->
+ Assert.Equal("collector exited with code 3", message)
+ Assert.DoesNotContain(secretSentinel, message)
+ Assert.DoesNotContain(dir, message)
+ | Ok raw -> Assert.Fail(sprintf "expected failure, got %s" raw)
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``start failure keeps executable path out of the public error`` () =
+ let dir = fixtureDir ()
+
+ try
+ let script = writeScript dir "success.sh" ("printf '%s' '" + searchEnvelope + "'")
+ let missingExecutable = "/nonexistent-" + secretSentinel + "/python3"
+
+ let collector = ProcessMarketDataCollector(missingExecutable, script, None, 30) :> IMarketDataCollector
+
+ match collector.Search("000001", CancellationToken.None) with
+ | Error message ->
+ let sanitized =
+ message = "could not run the AKShare collector"
+ || message.StartsWith("collector exited with code")
+
+ Assert.True(sanitized, sprintf "unexpected error message: %s" message)
+ Assert.DoesNotContain(secretSentinel, message)
+ Assert.DoesNotContain(dir, message)
+ | Ok raw -> Assert.Fail(sprintf "expected failure, got %s" raw)
+ finally
+ Directory.Delete(dir, true)
+
+ [<Fact>]
+ let ``missing collector script yields sanitized error without paths`` () =
+ let dir = fixtureDir ()
+
+ try
+ let script = Path.Combine(dir, "missing.py")
+ let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector
+
+ match collector.Search("000001", CancellationToken.None) with
+ | Error message ->
+ Assert.Equal("collector script is not deployed", message)
+ Assert.DoesNotContain(dir, message)
+ | Ok raw -> Assert.Fail(sprintf "expected error, got %s" raw)
+ finally
+ Directory.Delete(dir, true)