diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/ApiTests.fs | 66 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/PersistenceTests.fs | 144 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 151 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/ChartTests.fs | 23 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj | 26 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/Program.fs | 5 |
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 |
