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" "testing" "time" ) func testNotifications(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision, account string) { t.Helper() repo := repository.NewNotificationRepository(pool) svc := service.NewNotificationService(repo, true) var admin model.User if err := pool.QueryRow(ctx, `SELECT id,role,status FROM veyra_user WHERE role='ADMIN' LIMIT 1`).Scan(&admin.ID, &admin.Role, &admin.Status); err != nil { t.Fatal(err) } process := func() { for i := 0; i < 50; i++ { worked, err := repo.ProcessNext(ctx) if err != nil { t.Fatal(err) } if !worked { return } } t.Fatal("notification queue did not drain") } var cashBefore, fillBefore int64 if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_minor),0)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account).Scan(&cashBefore); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account).Scan(&fillBefore); err != nil { t.Fatal(err) } quote := service.SimulationQuoteInput{Symbol: candidate.Snapshot.Symbol, Exchange: candidate.Snapshot.Exchange, Price: "10.50", ObservedAt: time.Now().UTC()} if _, err := svc.SaveQuote(ctx, user, quote, uuid.NewString()); !errors.Is(err, apperror.ErrForbidden) { t.Fatal("user wrote quote") } q, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString()) if err != nil { t.Fatal(err) } process() notices, err := svc.List(ctx, user) if err != nil || len(notices) != 1 { t.Fatalf("plan reminder missing: %d %v", len(notices), err) } quote.ObservedAt = time.Now().UTC() if _, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString()); err != nil { t.Fatal(err) } process() notices, err = svc.List(ctx, user) if err != nil || len(notices) != 1 { t.Fatal("repeated price produced duplicate reminder") } var cashAfter, fillAfter int64 if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_minor),0)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account).Scan(&cashAfter); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account).Scan(&fillAfter); err != nil { t.Fatal(err) } if cashBefore != cashAfter || fillBefore != fillAfter { t.Fatal("reminder altered ledger") } stranger := user stranger.ID = uuid.NewString() if err := svc.MarkRead(ctx, stranger, notices[0].ID); !errors.Is(err, apperror.ErrNotFound) { t.Fatal("cross user notification write") } if err := svc.MarkRead(ctx, user, notices[0].ID); err != nil { t.Fatal(err) } tasks, err := svc.Tasks(ctx, admin) if err != nil || len(tasks) == 0 { t.Fatal("task audit missing") } for _, task := range tasks { if task.ProcessedAt == nil { t.Fatal("queue event did not commit processed flag") } } // A second plan must not trigger on an expired or superseded quote event. plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true) preview, err := plans.Preview(ctx, user, service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "100", FeeBudget: "0"}) if err != nil { t.Fatal(err) } if _, err := plans.Confirm(ctx, user, preview.ID, uuid.NewString()); err != nil { t.Fatal(err) } quote.ObservedAt = time.Now().UTC() q, err = svc.SaveQuote(ctx, admin, quote, uuid.NewString()) if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE veyra_quote_snapshot SET observed_at=now()-interval '2 minutes' WHERE id=$1`, q.ID); err != nil { t.Fatal(err) } process() notices, err = svc.List(ctx, user) if err != nil || len(notices) != 1 { t.Fatal("stale quote triggered plan") } quote.ObservedAt = time.Now().UTC() if _, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString()); err != nil { t.Fatal(err) } process() notices, err = svc.List(ctx, user) if err != nil || len(notices) != 2 { t.Fatal("fresh second-plan reminder failed") } }