From 6abd4c206fbfe8369b3fde4c905619d08f13c5d1 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 05:23:52 +0800 Subject: Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24) --- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 2 + tests/FundLab.Api.Tests/RebalanceArchiveTests.fs | 149 +++++++++++++++++++ tests/FundLab.Api.Tests/SipPlanStatusTests.fs | 177 +++++++++++++++++++++++ tests/FundLab.Web.Tests/BoundaryTests.fs | 60 ++++++++ 4 files changed, 388 insertions(+) create mode 100644 tests/FundLab.Api.Tests/RebalanceArchiveTests.fs create mode 100644 tests/FundLab.Api.Tests/SipPlanStatusTests.fs (limited to 'tests') 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 @@ + + 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 + +[] +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" ] + "" + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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 + +[] +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" ] + "" + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index d8bb472..4c05f37 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -1164,6 +1164,36 @@ module SipBoundaryTests = Assert.Equal(1, staleList.sipPlans.Length) + [] + let ``sip pause completion updates the matching plan row status`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFund ()) + sipStatusSeq = 1 + sipStatusInFlight = true + sipPlans = [ Api.decodeSipPlan (sampleSipRaw ()) ] } + + let pausedRaw = { sampleSipRaw () with status = "paused" } + let updated, _ = update (SipPlanStatusChanged(1, pausedRaw)) model + + Assert.Equal("paused", updated.sipPlans[0].status) + Assert.False(updated.sipStatusInFlight) + + [] + let ``sip pause failure clears in-flight and surfaces a message`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFund ()) + sipStatusSeq = 1 + sipStatusInFlight = true } + + let updated, _ = update (SipPlanStatusChangeFailed(1, "sip plan does not belong to this fund")) model + + Assert.Equal(Some "sip plan does not belong to this fund", updated.error) + Assert.False(updated.sipStatusInFlight) + module RebalanceBoundaryTests = open Xunit @@ -1249,3 +1279,33 @@ module RebalanceBoundaryTests = Assert.True(stale.rebalExecuteInFlight) Assert.Equal(None, stale.rebalResult) + + [] + let ``rebalance archive completion updates the matching plan row status`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFundInfo ()) + rebalArchiveSeq = 1 + rebalArchiveInFlight = true + rebalPlans = [ Api.decodeRebalancePlan (sampleRebalanceRaw ()) ] } + + let archivedRaw = { sampleRebalanceRaw () with status = "archived" } + let updated, _ = update (RebalancePlanArchived(1, archivedRaw)) model + + Assert.Equal("archived", updated.rebalPlans[0].status) + Assert.False(updated.rebalArchiveInFlight) + + [] + let ``rebalance archive failure clears in-flight and surfaces a message`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFundInfo ()) + rebalArchiveSeq = 1 + rebalArchiveInFlight = true } + + let updated, _ = update (RebalancePlanArchiveFailed(1, "rebalance plan does not belong to this fund")) model + + Assert.Equal(Some "rebalance plan does not belong to this fund", updated.error) + Assert.False(updated.rebalArchiveInFlight) -- cgit v1.2.3