diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 132 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 61 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 94 |
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) |
