summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/ApiTests.fs66
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs144
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs151
-rw-r--r--tests/FundLab.Web.Tests/ChartTests.fs23
-rw-r--r--tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj26
-rw-r--r--tests/FundLab.Web.Tests/Program.fs5
6 files changed, 414 insertions, 1 deletions
diff --git a/tests/FundLab.Api.Tests/ApiTests.fs b/tests/FundLab.Api.Tests/ApiTests.fs
index a46d330..1aaab6b 100644
--- a/tests/FundLab.Api.Tests/ApiTests.fs
+++ b/tests/FundLab.Api.Tests/ApiTests.fs
@@ -2,6 +2,7 @@ namespace FundLab.Api.Tests
module ApiTests =
+ open System
open Xunit
open FundLab.Api
@@ -22,3 +23,68 @@ module ApiTests =
Assert.Contains("\"status\":\"ok\"", payload)
Assert.DoesNotContain("secret", payload)
+
+ [<Fact>]
+ let ``AKShare NAV payload keeps missing publication time empty`` () =
+ let payload =
+ """
+ {
+ "schema_version": "fund-lab.akshare.v1",
+ "operation": "nav",
+ "source": "akshare",
+ "source_revision": "akshare-1.18.96/eastmoney",
+ "collected_at": "2026-09-21T10:00:00Z",
+ "instrument": { "code": "000001" },
+ "observations": [
+ {
+ "nav_date": "2001-12-18",
+ "published_at": null,
+ "nav": "1.00000000",
+ "accumulated_nav": null,
+ "daily_return": "0.00"
+ }
+ ]
+ }
+ """
+
+ match MarketData.parseNavPayload payload with
+ | Ok parsed ->
+ let observation = parsed.Observations |> List.head
+ Assert.Equal("000001", parsed.Code)
+ Assert.Equal(DateOnly(2001, 12, 18), observation.NavDate)
+ Assert.Equal(None, observation.PublishedAt)
+ Assert.Equal(None, observation.AccumulatedNav)
+ Assert.Equal(Some 0.00m, observation.DailyReturn)
+ Assert.Equal(1.00000000m, observation.Nav)
+ | Error message ->
+ Assert.Fail(sprintf "expected valid NAV payload, got %s" message)
+
+ [<Fact>]
+ let ``AKShare search payload preserves exact fund code and source`` () =
+ let payload =
+ """
+ {
+ "schema_version": "fund-lab.akshare.v1",
+ "operation": "search",
+ "source": "akshare",
+ "source_revision": "akshare-1.18.96/eastmoney",
+ "collected_at": "2026-09-21T10:00:00Z",
+ "instruments": [
+ {
+ "code": "000001",
+ "name": "华夏成长混合",
+ "fund_type": "混合型-灵活"
+ }
+ ]
+ }
+ """
+
+ match MarketData.parseSearchPayload payload with
+ | Ok parsed ->
+ let instrument = parsed.Instruments |> List.head
+ Assert.Equal("000001", instrument.Code)
+ Assert.Equal("华夏成长混合", instrument.Name)
+ Assert.Equal(Some "混合型-灵活", instrument.FundType)
+ Assert.Equal("akshare", parsed.Source)
+ | Error message ->
+ Assert.Fail(sprintf "expected valid search payload, got %s" message)
diff --git a/tests/FundLab.Api.Tests/PersistenceTests.fs b/tests/FundLab.Api.Tests/PersistenceTests.fs
index f319839..5157a9c 100644
--- a/tests/FundLab.Api.Tests/PersistenceTests.fs
+++ b/tests/FundLab.Api.Tests/PersistenceTests.fs
@@ -203,7 +203,15 @@ module PersistenceTestHelpers =
use services = ServiceCollection().AddGiraffe().BuildServiceProvider()
context.RequestServices <- services
context.Request.Method <- method
- context.Request.Path <- PathString(path)
+
+ 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
@@ -319,6 +327,72 @@ type PersistenceTests(fixture: PostgresFixture) =
value
[<Fact>]
+ 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)
+
+ [<Fact>]
member _.``created fund remains readable after repository restart with exact values``() =
let command =
{
@@ -584,6 +658,74 @@ type PersistenceTests(fixture: PostgresFixture) =
Assert.Contains("\"initialCash\":\"42.10\"", readBody)
[<Fact>]
+ 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)
+
+ [<Fact>]
member _.``fund API rejects non-object JSON bodies``() =
let app = App.createApplication (repository ())
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
new file mode 100644
index 0000000..9059cef
--- /dev/null
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -0,0 +1,151 @@
+namespace FundLab.Web.Tests
+
+open Xunit
+open FundLab.Web
+
+module BoundaryTests =
+
+ let someText (value: string) : obj = box { case = "Some"; fields = [| value |] }
+
+ let sampleInstrument code name fundType : Instrument =
+ { code = code; name = name; fundType = fundType }
+
+ let sampleObservation navDate nav dailyReturn : NavObservation =
+ { navDate = navDate; nav = nav; dailyReturn = dailyReturn }
+
+ [<Fact>]
+ let ``decode search converts raw JSON array into F# list with optional fund type`` () =
+ let raw =
+ {
+ instruments =
+ [|
+ { code = "000001"; name = "华夏成长混合"; fundType = someText "混合型" }
+ { code = "110011"; name = "易方达优质精选混合"; fundType = null }
+ |]
+ }
+
+ let decoded = Api.decodeSearch raw
+
+ Assert.Equal(2, decoded.Length)
+ Assert.Equal("000001", decoded[0].code)
+ Assert.Equal("华夏成长混合", decoded[0].name)
+ Assert.Equal(Some "混合型", decoded[0].fundType)
+ Assert.Equal(None, decoded[1].fundType)
+
+ [<Fact>]
+ let ``decode nav tolerates missing daily return at the boundary`` () =
+ let raw =
+ {
+ observations =
+ [|
+ { navDate = "2026-09-01"; nav = "1.2345"; dailyReturn = someText "0.45" }
+ { navDate = "2026-09-02"; nav = "1.2400"; dailyReturn = null }
+ |]
+ }
+
+ let decoded = Api.decodeNav raw
+
+ Assert.Equal(2, decoded.Length)
+ Assert.Equal(Some "0.45", decoded[0].dailyReturn)
+ Assert.Equal(None, decoded[1].dailyReturn)
+ Assert.Equal("2026-09-02", decoded[1].navDate)
+
+ [<Fact>]
+ let ``stale search completion does not overwrite newer results`` () =
+ let sentinel = [ sampleInstrument "999999" "旧结果" None ]
+ let model = { init () with searchSeq = 2; searchInFlight = true; searchResults = sentinel }
+
+ let staleModel, _ = update (SearchCompleted(1, [ sampleInstrument "000001" "新结果" None ])) model
+
+ Assert.Equal<Instrument>(sentinel, staleModel.searchResults)
+ Assert.True(staleModel.searchInFlight)
+
+ let freshModel, _ = update (SearchCompleted(2, [ sampleInstrument "000001" "新结果" None ])) model
+
+ Assert.Equal(1, freshModel.searchResults.Length)
+ Assert.Equal("000001", freshModel.searchResults[0].code)
+ Assert.False(freshModel.searchInFlight)
+
+ [<Fact>]
+ let ``stale nav failure does not overwrite newer in-flight state`` () =
+ let selected = sampleInstrument "000001" "华夏成长混合" (Some "混合型")
+ let model = { init () with navSeq = 3; navInFlight = true; selected = Some selected }
+
+ let staleModel, _ = update (NavFailed(2, "过期失败")) model
+
+ Assert.True(staleModel.navInFlight)
+ Assert.Equal(None, staleModel.error)
+
+ let freshModel, _ = update (NavFailed(3, "同步失败")) model
+
+ Assert.False(freshModel.navInFlight)
+ Assert.Equal(Some "同步失败", freshModel.error)
+
+ [<Fact>]
+ let ``changing token clears private results selection and loading state`` () =
+ let model =
+ { init () with
+ token = "old-token"
+ searchResults = [ sampleInstrument "000001" "华夏成长混合" (Some "混合型") ]
+ selected = Some(sampleInstrument "110011" "易方达优质精选混合" None)
+ observations = [ sampleObservation "2026-09-01" "1.2345" (Some "0.45") ]
+ searchInFlight = true
+ navInFlight = true
+ error = Some "旧错误" }
+
+ let updated, _ = update (TokenChanged "new-token") model
+
+ Assert.Equal("new-token", updated.token)
+ Assert.Empty(updated.searchResults)
+ Assert.Equal(None, updated.selected)
+ Assert.Empty(updated.observations)
+ Assert.False(updated.searchInFlight)
+ Assert.False(updated.navInFlight)
+ Assert.Equal(None, updated.error)
+
+ [<Fact>]
+ let ``token change invalidates in flight search and nav responses`` () =
+ let model = { init () with searchSeq = 1; navSeq = 1 }
+
+ let cleared, _ = update (TokenChanged "new-token") model
+
+ Assert.Equal(2, cleared.searchSeq)
+ Assert.Equal(2, cleared.navSeq)
+
+ let staleSearchModel, _ =
+ update (SearchCompleted(1, [ sampleInstrument "000001" "过期结果" (Some "混合型") ])) cleared
+
+ Assert.Empty(staleSearchModel.searchResults)
+
+ let staleSearchFailureModel, _ = update (SearchFailed(1, "过期搜索失败")) cleared
+
+ Assert.Equal(None, staleSearchFailureModel.error)
+
+ let staleNavModel, _ =
+ update (NavCompleted(1, [ sampleObservation "2026-09-01" "1.2345" (Some "0.45") ])) cleared
+
+ Assert.Empty(staleNavModel.observations)
+
+ let staleNavFailureModel, _ = update (NavFailed(1, "过期净值失败")) cleared
+
+ Assert.Equal(None, staleNavFailureModel.error)
+
+ let freshNavModel, _ =
+ update (NavCompleted(2, [ sampleObservation "2026-09-01" "1.2345" None ])) cleared
+
+ Assert.Equal(1, freshNavModel.observations.Length)
+
+ [<Fact>]
+ let ``search failure clears loading and next attempt recovers`` () =
+ let model = { init () with token = "qa-token"; query = "华夏"; searchSeq = 1; searchInFlight = true }
+
+ let failedModel, _ = update (SearchFailed(1, "collector exited with code 1")) model
+
+ Assert.False(failedModel.searchInFlight)
+ Assert.Equal(Some "collector exited with code 1", failedModel.error)
+
+ let retryModel, _ = update SearchRequested failedModel
+
+ Assert.True(retryModel.searchInFlight)
+ Assert.Equal(None, retryModel.error)
+ Assert.Equal(2, retryModel.searchSeq)
diff --git a/tests/FundLab.Web.Tests/ChartTests.fs b/tests/FundLab.Web.Tests/ChartTests.fs
new file mode 100644
index 0000000..cede2d2
--- /dev/null
+++ b/tests/FundLab.Web.Tests/ChartTests.fs
@@ -0,0 +1,23 @@
+namespace FundLab.Web.Tests
+
+open Xunit
+open FundLab.Web
+
+module ChartTests =
+
+ [<Fact>]
+ let ``chart points preserve chronological order and normalize NAV values`` () =
+ let observations : NavObservation list =
+ [
+ { navDate = "2026-01-01"; nav = "1.00000000"; dailyReturn = None }
+ { navDate = "2026-01-02"; nav = "1.25000000"; dailyReturn = Some "25.00000000" }
+ { navDate = "2026-01-03"; nav = "1.10000000"; dailyReturn = Some "-12.00000000" }
+ ]
+
+ let points = Chart.chartPoints observations
+
+ Assert.Equal(3, points.Length)
+ Assert.Equal(0.0, points[0].x)
+ Assert.Equal(1.0, points[1].y)
+ Assert.Equal(0.4, points[2].y)
+ Assert.Equal("2026-01-03", points[2].navDate)
diff --git a/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj b/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj
new file mode 100644
index 0000000..0c1c7f9
--- /dev/null
+++ b/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <OutputType>Library</OutputType>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <GenerateProgramFile>false</GenerateProgramFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
+ <PackageReference Include="xunit" Version="2.9.2" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\FundLab.Web\FundLab.Web.fsproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <Compile Include="ChartTests.fs" />
+ <Compile Include="BoundaryTests.fs" />
+ <Compile Include="Program.fs" />
+ </ItemGroup>
+</Project>
diff --git a/tests/FundLab.Web.Tests/Program.fs b/tests/FundLab.Web.Tests/Program.fs
new file mode 100644
index 0000000..120e538
--- /dev/null
+++ b/tests/FundLab.Web.Tests/Program.fs
@@ -0,0 +1,5 @@
+module FundLab.Web.Tests.Program
+
+[<EntryPoint>]
+let main _argv =
+ 0