package integration import ( "context" "errors" "git.sundynix.cn/Blizzard/Veyra/internal/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/model" "git.sundynix.cn/Blizzard/Veyra/internal/repository" "git.sundynix.cn/Blizzard/Veyra/internal/service" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "sync" "testing" "time" ) func testTradePlans(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision) { t.Helper() svc := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true) accounts, err := service.NewAccountService(repository.NewAccountRepository(pool)).List(ctx, user.ID) if err != nil { t.Fatal(err) } var account string for _, a := range accounts { if a.Mode == "simulation" { account = a.ID } } input := service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "9000", FeeBudget: "0"} first, err := svc.Preview(ctx, user, input) if err != nil { t.Fatal(err) } second, err := svc.Preview(ctx, user, input) if err != nil { t.Fatal(err) } var wg sync.WaitGroup results := make(chan model.TradePlan, 2) failures := make(chan error, 2) for _, p := range []model.PlanPreview{first, second} { wg.Add(1) go func(p model.PlanPreview) { defer wg.Done() plan, err := svc.Confirm(ctx, user, p.ID, uuid.NewString()) if err != nil { failures <- err } else { results <- plan } }(p) } wg.Wait() close(results) close(failures) if len(results) != 1 || len(failures) != 1 { t.Fatal("concurrent plans overspent available cash") } for err := range failures { if !errors.Is(err, apperror.ErrConflict) { t.Fatal(err) } } plan := <-results same, err := svc.Confirm(ctx, user, plan.PreviewID, uuid.NewString()) if err != nil || same.ID != plan.ID { t.Fatal("confirmation retry created duplicate plan") } budget, err := svc.Budget(ctx, user, account) if err != nil || budget.ReservedAmount != "99000.00" || budget.AvailableAmount != "1000.01" { t.Fatalf("reservation budget: %+v %v", budget, err) } stranger := user stranger.ID = uuid.NewString() if _, err := svc.Confirm(ctx, stranger, first.ID, uuid.NewString()); !errors.Is(err, apperror.ErrNotFound) { t.Fatal("cross-user preview confirmation") } if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version+1, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) { t.Fatal("stale plan cancellation") } if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version, uuid.NewString()); err != nil { t.Fatal(err) } budget, err = svc.Budget(ctx, user, account) if err != nil || budget.ReservedAmount != "0.00" || budget.CashBalance != "100000.01" { t.Fatal("plan modified cash ledger or failed to release") } input.Quantity = "100" input.FeeBudget = "5.00" expired, err := svc.Preview(ctx, user, input) if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE veyra_plan_preview SET expires_at=$2 WHERE id=$1`, expired.ID, time.Now().Add(-time.Minute)); err != nil { t.Fatal(err) } if _, err := svc.Confirm(ctx, user, expired.ID, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) { t.Fatal("expired preview confirmed") } input.ExpectedCandidateVersion++ if _, err := svc.Preview(ctx, user, input); !errors.Is(err, apperror.ErrConflict) { t.Fatal("stale candidate preview accepted") } input.ExpectedCandidateVersion-- active, err := svc.Preview(ctx, user, input) if err != nil { t.Fatal(err) } if _, err := svc.Confirm(ctx, user, active.ID, uuid.NewString()); err != nil { t.Fatal(err) } testNotifications(t, ctx, pool, user, candidate, account) testLinkedFills(t, ctx, pool, user, candidate, account) } func testLinkedFills(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision, account string) { t.Helper() plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true) fills := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true) preview, err := plans.Preview(ctx, user, service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "200", FeeBudget: "5"}) if err != nil { t.Fatal(err) } plan, err := plans.Confirm(ctx, user, preview.ID, uuid.NewString()) if err != nil { t.Fatal(err) } occurred := time.Now().UTC().Add(-time.Second) settlement := occurred.Add(time.Hour) input := service.FillInput{AccountID: account, PlanID: &plan.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: candidate.Snapshot.Symbol, Exchange: candidate.Snapshot.Exchange, Name: candidate.Snapshot.Name, Quantity: "100", Price: "10", Fee: "2", OccurredAt: occurred, SellableAt: &settlement} first, err := fills.Record(ctx, user, input, uuid.NewString()) if err != nil { t.Fatal(err) } list, err := plans.List(ctx, user) if err != nil { t.Fatal(err) } for _, p := range list { if p.ID == plan.ID && (p.Status != "PART_FILLED" || p.FilledQuantity != 100 || p.ReservedAmount != "1105.00") { t.Fatal("partial fill reservation mismatch") } } input.IdempotencyKey = uuid.NewString() input.Price = "12" input.Fee = "3" input.OccurredAt = time.Now().UTC() if _, err := fills.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrInvalidInput) { t.Fatal("unexplained price deviation accepted") } input.Note = "实际模拟成交偏离参考区间" last, err := fills.Record(ctx, user, input, uuid.NewString()) if err != nil { t.Fatal(err) } list, err = plans.List(ctx, user) if err != nil { t.Fatal(err) } for _, p := range list { if p.ID == plan.ID && (p.Status != "COMPLETED" || p.FilledQuantity != 200 || p.ReservedMinor != 0) { t.Fatal("completed plan kept reservation") } } if _, err := fills.Reverse(ctx, user, last.ID, "纠正偏离录入", uuid.NewString()); err != nil { t.Fatal(err) } if _, err := fills.Reverse(ctx, user, first.ID, "纠正第一笔录入", uuid.NewString()); err != nil { t.Fatal(err) } list, err = plans.List(ctx, user) if err != nil { t.Fatal(err) } for _, p := range list { if p.ID == plan.ID && (p.Status != "INVALIDATED" || p.FilledQuantity != 0 || p.ReservedMinor != 0) { t.Fatal("reversal silently reopened plan") } } }