diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:03:39 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:03:39 +0800 |
| commit | f4ab0b08d7648914f6fd049fa1887c0422f69318 (patch) | |
| tree | c2dc484ed97bd0dc573986318cce921340fbd783 /tests/FundLab.Web.Tests | |
| parent | 205e2a592bf781f08fe23f3577cdfe74ff9a076a (diff) | |
| download | fund-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.fs | 94 |
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) |
