summaryrefslogtreecommitdiff
path: root/tests/FundLab.Web.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 12:03:39 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 12:03:39 +0800
commitf4ab0b08d7648914f6fd049fa1887c0422f69318 (patch)
treec2dc484ed97bd0dc573986318cce921340fbd783 /tests/FundLab.Web.Tests
parent205e2a592bf781f08fe23f3577cdfe74ff9a076a (diff)
downloadfund-lab-f4ab0b08d7648914f6fd049fa1887c0422f69318.tar.gz
Add SIP plan first slice (3d-5)
Introduce scheduled-investment plans as a schedule-only slice with no cash, position or NAV side effects. Domain gains SipPolicy: amount validation (positive, two-decimal, overflow-checked), frequency parsing/round-trip (weekly/biweekly/monthly), nextTradeDate generating the next due trade date off the plan anchor with month-end clamping and weekend roll-forward, plus the isDue/executionNetAmount contract the future executor will implement (execution itself stays unimplemented and is marked as such; nothing debits cash in this slice). Persistence stores plans in sip_plans with anchor/next trade dates and sip_plan_idempotencies under the shared replay/conflict contract; the API exposes POST/GET /funds/{id}/sip/plans behind the bearer token. The frontend adds a 07/SIP panel (code, amount, frequency select, plan list with next debit date) and Web tests cover decoding, validation, in-flight guards and stale completions. Choice note: first debit is scheduled strictly after the anchor date (creating a plan today never debits today).
Diffstat (limited to 'tests/FundLab.Web.Tests')
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs94
1 files changed, 94 insertions, 0 deletions
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 5226b65..dbaf4e5 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -707,3 +707,97 @@ module CapitalBoundaryTests =
Assert.Equal(None, cleared.createdFund)
Assert.False(cleared.capitalInFlight)
Assert.Equal(None, cleared.lastCapitalAttempt)
+
+module SipBoundaryTests =
+
+ let sampleFund () : FundSummary =
+ {
+ id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ name = "定投测试"
+ currency = "CNY"
+ initialCash = "10000.00"
+ initialUnitNav = "1.00000000"
+ isSynthetic = false
+ availableCash = "10000.00"
+ reservedCash = "0.00"
+ status = "active"
+ }
+
+ let sampleSipRaw () : RawSipPlan =
+ {
+ id = "aa1b2c3d-0000-0000-0000-000000000002"
+ instrumentCode = "000001"
+ amount = "200.00"
+ frequency = "weekly"
+ status = "active"
+ anchorDate = "2026-09-21"
+ nextTradeDate = "2026-09-28"
+ }
+
+ [<Fact>]
+ let ``sip plan decode keeps decimal strings and dates untouched`` () =
+ let decoded = Api.decodeSipPlan (sampleSipRaw ())
+
+ Assert.Equal("000001", decoded.instrumentCode)
+ Assert.Equal("200.00", decoded.amount)
+ Assert.Equal("weekly", decoded.frequency)
+ Assert.Equal("2026-09-28", decoded.nextTradeDate)
+ Assert.Equal("active", decoded.status)
+
+ [<Fact>]
+ let ``sip plan request validates inputs and guards in-flight duplicates`` () =
+ let noFund, _ = update SipPlanCreateRequested { init () with token = "qa-token" }
+
+ Assert.Equal(0, noFund.sipCreateSeq)
+ Assert.False(noFund.sipInFlight)
+
+ let noToken, _ =
+ update SipPlanCreateRequested { init () with createdFund = Some(sampleFund ()) }
+
+ Assert.Equal(Some "请输入 API token", noToken.error)
+
+ let invalidAmount, _ =
+ update SipPlanCreateRequested
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipCode = "000001"
+ sipAmount = "0.00" }
+
+ Assert.Equal(Some "定投金额必须是大于零的两位小数金额,例如 200.00", invalidAmount.error)
+ Assert.Equal(0, invalidAmount.sipCreateSeq)
+
+ let started, _ =
+ update SipPlanCreateRequested
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipCode = "000001"
+ sipAmount = "200.00" }
+
+ Assert.Equal(1, started.sipCreateSeq)
+ Assert.True(started.sipInFlight)
+ Assert.Equal(32, started.lastSipAttempt.Value.idempotencyKey.Length)
+
+ let duplicate, _ = update SipPlanCreateRequested started
+
+ Assert.Equal(started.sipCreateSeq, duplicate.sipCreateSeq)
+
+ [<Fact>]
+ let ``stale sip completions do not overwrite newer state`` () =
+ let model =
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipCreateSeq = 2
+ sipInFlight = true
+ sipPlans = [ Api.decodeSipPlan (sampleSipRaw ()) ] }
+
+ let stale, _ = update (SipPlanCreateCompleted(1, (sampleFund ()).id, sampleSipRaw ())) model
+
+ Assert.True(stale.sipInFlight)
+ Assert.Equal(1, stale.sipPlans.Length)
+
+ let staleList, _ = update (SipPlansReadCompleted(1, [| sampleSipRaw () |])) { model with sipReadSeq = 2 }
+
+ Assert.Equal(1, staleList.sipPlans.Length)