summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
commit6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (patch)
treef53ce1746591b640f78cd11d63c8411160059d98 /tests/FundLab.Api.Tests
parent545f2a507fd0138f7fd50ed7fb2631d0273db625 (diff)
downloadfund-lab-6abd4c206fbfe8369b3fde4c905619d08f13c5d1.tar.gz
Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24)
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj2
-rw-r--r--tests/FundLab.Api.Tests/RebalanceArchiveTests.fs149
-rw-r--r--tests/FundLab.Api.Tests/SipPlanStatusTests.fs177
3 files changed, 328 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 38a9e7e..d42612a 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -37,6 +37,8 @@
<Compile Include="StockTradeTests.fs" />
<Compile Include="BondTradeTests.fs" />
<Compile Include="FundValuationTests.fs" />
+ <Compile Include="SipPlanStatusTests.fs" />
+ <Compile Include="RebalanceArchiveTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs b/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs
new file mode 100644
index 0000000..ebb238b
--- /dev/null
+++ b/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs
@@ -0,0 +1,149 @@
+namespace FundLab.Api.Tests
+
+open System
+open Xunit
+open FundLab.Api
+
+[<Collection("postgres")>]
+type RebalanceArchiveTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "再平衡归档测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "rebalance-archive-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "再平衡归档测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "rebalance-archive-fund-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateFund(key, command) with
+ | FundWriteResult.Created fund -> fund.Id
+ | other -> failwithf "unexpected fund creation result: %A" other
+
+ let app () = App.createApplication (repository ())
+
+ let createPlan (fundId: Guid) (code: string) =
+ let key = fixture.Key(sprintf "rebalance-archive-plan-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateRebalancePlan(key, fundId, { Targets = [ { InstrumentCode = code; TargetPercent = 100.00m } ] })
+ with
+ | RebalanceWriteResult.RebalancePlanCreated plan -> plan.Id
+ | other -> failwithf "unexpected rebalance plan result: %A" other
+
+ let archiveViaApi fundId planId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/archive" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ "{}"
+
+ let previewViaApi fundId planId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/preview" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let executeViaApi fundId planId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ "{}"
+
+ let plansViaApi fundId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/rebalance/plans" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``archiving an active plan marks it archived in the plan list``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let status, body = archiveViaApi fundId planId
+ Assert.Equal(200, status)
+ Assert.Contains("\"status\":\"archived\"", body)
+
+ let _, plansBody = plansViaApi fundId
+ Assert.Contains("\"status\":\"archived\"", plansBody)
+
+ [<Fact>]
+ member _.``archived plan cannot be previewed``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let _, _ = archiveViaApi fundId planId
+ let status, body = previewViaApi fundId planId
+
+ Assert.Equal(409, status)
+ Assert.Contains("PLAN_ARCHIVED", body)
+
+ [<Fact>]
+ member _.``archived plan cannot be executed``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let _, _ = archiveViaApi fundId planId
+ let status, body = executeViaApi fundId planId
+
+ Assert.Equal(409, status)
+ Assert.Contains("PLAN_ARCHIVED", body)
+
+ [<Fact>]
+ member _.``archiving an already archived plan is idempotent``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let firstStatus, firstBody = archiveViaApi fundId planId
+ Assert.Equal(200, firstStatus)
+ Assert.Contains("\"status\":\"archived\"", firstBody)
+
+ let secondStatus, secondBody = archiveViaApi fundId planId
+ Assert.Equal(200, secondStatus)
+ Assert.Contains("\"status\":\"archived\"", secondBody)
+
+ [<Fact>]
+ member _.``archiving a plan owned by another fund answers 404``() =
+ let fundId = createFund 1000.00m
+ let otherFundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let status, body = archiveViaApi otherFundId planId
+ Assert.Equal(404, status)
+ Assert.Contains("PLAN_NOT_FOUND", body)
diff --git a/tests/FundLab.Api.Tests/SipPlanStatusTests.fs b/tests/FundLab.Api.Tests/SipPlanStatusTests.fs
new file mode 100644
index 0000000..780d85f
--- /dev/null
+++ b/tests/FundLab.Api.Tests/SipPlanStatusTests.fs
@@ -0,0 +1,177 @@
+namespace FundLab.Api.Tests
+
+open System
+open Xunit
+open FundLab.Api
+open FundLab.Domain
+
+[<Collection("postgres")>]
+type SipPlanStatusTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "定投状态测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "sip-status-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "定投状态测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "sip-status-fund-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateFund(key, command) with
+ | FundWriteResult.Created fund -> fund.Id
+ | other -> failwithf "unexpected fund creation result: %A" other
+
+ let app () = App.createApplication (repository ())
+
+ let truncateMicroseconds (moment: DateTimeOffset) =
+ let utc = moment.ToUniversalTime()
+ DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero)
+
+ let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) =
+ let revision = sprintf "akshare-test/%O" (Guid.NewGuid())
+
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = revision
+ CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = None
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, sprintf "sip-status-hash/%s" revision)
+
+ let createPlanWithAnchor (fundId: Guid) (code: string) (amount: decimal) (frequency: SipFrequency) =
+ let key = fixture.Key(sprintf "sip-status-plan-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateSipPlan(
+ key,
+ fundId,
+ { InstrumentCode = code; Amount = amount; Frequency = frequency },
+ DateOnly(2026, 9, 7)
+ )
+ with
+ | SipPlanWriteResult.SipPlanCreated plan -> plan.Id
+ | other -> failwithf "unexpected sip plan result: %A" other
+
+ let postStatus (fundId: Guid) (planId: Guid) (action: string) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/sip/plans/%O/%s" fundId planId action)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let advance (fundId: Guid) (body: string) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/sip/advance" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ body
+
+ let listPlans (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/sip/plans" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``pause skips advance, resume lets the next due period execute again``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))
+ insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 21))
+
+ let pauseStatus, pauseBody = postStatus fundId planId "pause"
+ Assert.Equal(200, pauseStatus)
+ Assert.Contains("\"status\":\"paused\"", pauseBody)
+
+ let advanceStatus, advanceBody = advance fundId "{\"endDate\":\"2026-09-21\"}"
+ Assert.Equal(200, advanceStatus)
+ Assert.DoesNotContain("\"status\":\"succeeded\"", advanceBody)
+ Assert.DoesNotContain("\"status\":\"pending_nav\"", advanceBody)
+
+ let resumeStatus, resumeBody = postStatus fundId planId "resume"
+ Assert.Equal(200, resumeStatus)
+ Assert.Contains("\"status\":\"active\"", resumeBody)
+
+ let _, resumedBody = advance fundId "{\"endDate\":\"2026-09-21\"}"
+ Assert.Contains("\"status\":\"succeeded\"", resumedBody)
+ Assert.Contains("\"tradeDate\":\"2026-09-14\"", resumedBody)
+
+ [<Fact>]
+ member _.``pausing an already paused plan is idempotent``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ let firstStatus, firstBody = postStatus fundId planId "pause"
+ Assert.Equal(200, firstStatus)
+ Assert.Contains("\"status\":\"paused\"", firstBody)
+
+ let secondStatus, secondBody = postStatus fundId planId "pause"
+ Assert.Equal(200, secondStatus)
+ Assert.Contains("\"status\":\"paused\"", secondBody)
+
+ [<Fact>]
+ member _.``paused plan status is reflected in the sip plan list``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ let _, _ = postStatus fundId planId "pause"
+ let _, listBody = listPlans fundId
+ Assert.Contains("\"status\":\"paused\"", listBody)
+
+ let _, _ = postStatus fundId planId "resume"
+ let _, resumedListBody = listPlans fundId
+ Assert.Contains("\"status\":\"active\"", resumedListBody)
+
+ [<Fact>]
+ member _.``pausing a plan that belongs to another fund answers 404``() =
+ let fundId = createFund 1000.00m
+ let otherFundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ let status, body = postStatus otherFundId planId "pause"
+ Assert.Equal(404, status)
+ Assert.Contains("PLAN_NOT_FOUND", body)