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)