summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
commitbe1afd248e4cc9b03a1e21090602a945af6988b2 (patch)
treed31bd6e6c1736ab4ad784b52ebc5010ade074439 /tests/FundLab.Api.Tests
parent2ada70d6467aec11b45328112d598454ca50f2d6 (diff)
downloadfund-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.fs66
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs144
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 ())