diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
| commit | be1afd248e4cc9b03a1e21090602a945af6988b2 (patch) | |
| tree | d31bd6e6c1736ab4ad784b52ebc5010ade074439 /tests/FundLab.Api.Tests | |
| parent | 2ada70d6467aec11b45328112d598454ca50f2d6 (diff) | |
| download | fund-lab-be1afd248e4cc9b03a1e21090602a945af6988b2.tar.gz | |
feat(app): 接入 AKShare 行情采集、净值查询与前端净值曲线
[变更性质]
- 本提交完成 3b 行情闭环:真实 AKShare 搜索与净值采集、PostgreSQL 持久化、鉴权 API 与 Fable 前端净值曲线展示。
[新增功能]
- API 新增 /api/instruments/search、/api/instruments/{code}/nav 与 nav/refresh 接口,经 Bearer 鉴权调用 AKShare Python 采集器并幂等落库。
- 前端提供基金搜索、精确代码选择、历史净值刷新/重读与 SVG 折线图,token 变更即清空私有结果并失效在途请求。
[实现方案]
- MarketData 以 requiredProperty 校验采集器 payload,净值观测按 (code, nav_date) 幂等 upsert 并保留来源与哈希。
- 前端以 requestId 序列守卫 SearchCompleted/SearchFailed/NavCompleted/NavFailed,TokenChanged 同时递增 searchSeq/navSeq 拒绝过期响应;边界解码兼容 F# option 的 {"case":"Some"} 线格式。
[影响范围]
- 新增 FundLab.Web.Tests(边界解码、序列失效、图表纯函数共 8 项),扩展 API 测试至 20 项;vite dev 代理 /api 至本地 API。
- 真实 AKShare 端到端依赖本机 akshare 环境,由负责人另行验收;本提交不包含 docs/overnight-progress.md 的现有修改。
Diffstat (limited to 'tests/FundLab.Api.Tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/ApiTests.fs | 66 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/PersistenceTests.fs | 144 |
2 files changed, 209 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 ()) |
