summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs132
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs61
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs94
3 files changed, 287 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs
index 1ab8fb8..89154cd 100644
--- a/tests/FundLab.Api.Tests/OrderTests.fs
+++ b/tests/FundLab.Api.Tests/OrderTests.fs
@@ -2010,3 +2010,135 @@ type CapitalDepositTests(fixture: PostgresFixture) =
Assert.Contains("\"amount\":\"111.00\"", response)
Assert.Contains("\"amount\":\"222.00\"", response)
Assert.Contains("第一笔", response)
+
+[<Collection("postgres")>]
+type SipPlanTests(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, 10, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "定投测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "sip-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "定投测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "sip-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 availableCash fundId =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- "SELECT available_cash FROM funds WHERE id = @fund_id"
+ let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ parameter.Value <- box fundId
+ command.ExecuteScalar() :?> decimal
+
+ let postPlan (fundId: Guid) (idempotencyKey: string) (code: string) (amount: string) (frequency: string) =
+ let body =
+ sprintf
+ "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}"
+ code
+ amount
+ frequency
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/sip/plans" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ body
+
+ let getPlans (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/sip/plans" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``sip plan creation persists the plan without touching cash``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let key = fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))
+
+ let status, response = postPlan fundId key code "200.00" "weekly"
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"amount\":\"200.00\"", response)
+ Assert.Contains("\"frequency\":\"weekly\"", response)
+ Assert.Contains("\"status\":\"active\"", response)
+ Assert.Contains("\"nextTradeDate\":\"2026-09-28\"", response)
+ Assert.Equal(10000.00m, availableCash fundId)
+
+ [<Fact>]
+ member _.``sip plan replay and conflict follow the shared idempotency contract``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let key = fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))
+
+ let firstStatus, firstResponse = postPlan fundId key code "150.00" "monthly"
+ let secondStatus, secondResponse = postPlan fundId key code "150.00" "monthly"
+
+ Assert.Equal(201, firstStatus)
+ Assert.Equal(200, secondStatus)
+ Assert.Equal(firstResponse, secondResponse)
+
+ let conflictingStatus, conflictingResponse = postPlan fundId key code "250.00" "weekly"
+
+ Assert.Equal(409, conflictingStatus)
+ Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse)
+
+ [<Fact>]
+ member _.``invalid sip amounts and frequencies are rejected``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+
+ for amount, frequency in [ "0.00", "weekly"; "-1.00", "weekly"; "1.234", "weekly"; "100.00", "daily" ] do
+ let key = fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))
+ let status, response = postPlan fundId key code amount frequency
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_SIP_REQUEST", response)
+
+ [<Fact>]
+ member _.``sip plan list exposes the stored plans``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let _, _ = postPlan fundId (fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))) code "111.00" "biweekly"
+
+ let status, response = getPlans fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"instrumentCode\":\"" + code + "\"", response)
+ Assert.Contains("\"frequency\":\"biweekly\"", response)
+ Assert.Contains("\"amount\":\"111.00\"", response)
diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs
index 46516d5..13be13f 100644
--- a/tests/FundLab.Domain.Tests/DomainTests.fs
+++ b/tests/FundLab.Domain.Tests/DomainTests.fs
@@ -560,3 +560,64 @@ module CapitalPolicyTests =
let overflow = CapitalPolicy.settle CapitalPolicy.cashMaximum 0.01m
Assert.Equal(Error "available cash exceeds database precision", overflow)
+
+module SipPolicyTests =
+
+ open System
+ open Xunit
+ open FundLab.Domain
+
+ let d (year: int) (month: int) (day: int) = DateOnly(year, month, day)
+
+ [<Fact>]
+ let ``sip amount validation rejects non positive or imprecise values`` () =
+ Assert.Equal(Error "sip amount must be positive", SipPolicy.validateAmount 0m)
+ Assert.Equal(Error "sip amount must be positive", SipPolicy.validateAmount -200.00m)
+ Assert.Equal(Error "sip amount exceeds cash precision", SipPolicy.validateAmount 200.005m)
+ Assert.Equal(Ok(), SipPolicy.validateAmount 200.00m)
+
+ [<Fact>]
+ let ``frequency parsing roundtrips through the canonical text`` () =
+ Assert.Equal(Some Weekly, SipPolicy.parseFrequency "weekly")
+ Assert.Equal(Some BiWeekly, SipPolicy.parseFrequency "biweekly")
+ Assert.Equal(Some Monthly, SipPolicy.parseFrequency "monthly")
+ Assert.Equal(None, SipPolicy.parseFrequency "daily")
+ Assert.Equal(None, SipPolicy.parseFrequency null)
+
+ for frequency in [ Weekly; BiWeekly; Monthly ] do
+ Assert.Equal(Some frequency, SipPolicy.parseFrequency (SipPolicy.frequencyText frequency))
+
+ [<Fact>]
+ let ``weekly and biweekly next trade dates step from the anchor`` () =
+ let anchor = d 2026 9 21
+
+ Assert.Equal(d 2026 9 21, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 21))
+ Assert.Equal(d 2026 9 28, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 22))
+ Assert.Equal(d 2026 10 5, SipPolicy.nextTradeDate BiWeekly anchor (d 2026 9 28))
+
+ [<Fact>]
+ let ``next trade dates roll off weekends`` () =
+ let anchor = d 2026 9 21
+
+ Assert.Equal(d 2026 9 28, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 26))
+ Assert.Equal(d 2026 9 28, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 27))
+
+ [<Fact>]
+ let ``monthly next trade date clamps to month end and rolls off weekends`` () =
+ let anchor = d 2026 1 31
+
+ // 2026-01-31 is a Saturday: first run rolls to Monday 02-02
+ Assert.Equal(d 2026 2 2, SipPolicy.nextTradeDate Monthly anchor anchor)
+ // February clamps day 31 to 28 (Saturday) and rolls to Monday 03-02
+ Assert.Equal(d 2026 3 2, SipPolicy.nextTradeDate Monthly anchor (d 2026 2 1))
+ Assert.Equal(d 2026 3 2, SipPolicy.nextTradeDate Monthly anchor (d 2026 2 28))
+ Assert.Equal(d 2026 2 5, SipPolicy.nextTradeDate Monthly (d 2026 1 5) (d 2026 1 20))
+
+ [<Fact>]
+ let ``due flag and net amount define the executor contract without side effects`` () =
+ Assert.True(SipPolicy.isDue (d 2026 9 21) (d 2026 9 21))
+ Assert.True(SipPolicy.isDue (d 2026 9 21) (d 2026 9 28))
+ Assert.False(SipPolicy.isDue (d 2026 9 28) (d 2026 9 21))
+
+ Assert.Equal(Ok 200.00m, SipPolicy.executionNetAmount 200.00m)
+ Assert.Equal(Error "sip amount must be positive", SipPolicy.executionNetAmount 0m)
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)