统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func testAdminCredentialChange(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
||||
t.Helper()
|
||||
repo := repository.NewIdentityRepository(pool)
|
||||
previous, credential, err := repo.FindCredential(ctx, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSession, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password := "test1234"
|
||||
updater := service.NewAdminCredentialService(repo, true)
|
||||
if _, err := service.NewAdminCredentialService(repo, false).Set(ctx, "admin", "operator", password); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("production credential setup allowed")
|
||||
}
|
||||
if _, err := updater.Set(ctx, "alice", "operator", password); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("ordinary user promoted by credential command")
|
||||
}
|
||||
if _, err := updater.Set(ctx, "admin", "bob", password); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("duplicate login accepted")
|
||||
}
|
||||
if _, err := auth.Authenticate(ctx, oldSession.Token, "admin"); err != nil {
|
||||
t.Fatal("failed update revoked existing session")
|
||||
}
|
||||
userID, err := updater.Set(ctx, "admin", "operator", password)
|
||||
if err != nil || userID != previous.ID {
|
||||
t.Fatal("administrator identity was not preserved")
|
||||
}
|
||||
if _, err := auth.Authenticate(ctx, oldSession.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("old session survived password change")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("old login survived update")
|
||||
}
|
||||
login, err := auth.Login(ctx, "operator", password, "admin")
|
||||
if err != nil || login.User.ID != previous.ID {
|
||||
t.Fatal("new credentials failed")
|
||||
}
|
||||
_, updated, err := repo.FindCredential(ctx, "operator")
|
||||
if err != nil || updated.PasswordHash == password || updated.PasswordHash == credential.PasswordHash {
|
||||
t.Fatal("new password was not hashed")
|
||||
}
|
||||
stale := model.Session{ID: uuid.NewString(), UserID: previous.ID, TokenHash: service.TokenHash(uuid.NewString()), Audience: "admin", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := repo.CreateSession(ctx, stale, credential.PasswordHash); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("stale password verification created a new session")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
||||
"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"
|
||||
)
|
||||
|
||||
func testCandidateLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService, admin, user model.User) {
|
||||
t.Helper()
|
||||
repo := repository.NewCandidateRepository(pool)
|
||||
svc := service.NewCandidateService(repo, true)
|
||||
input := model.CandidateDraftInput{Content: model.CandidateContent{Symbol: "600000", Exchange: "SSE", Name: "测试证券", StrategyCode: "trend_following", Title: "测试候选", Thesis: "可核验测试依据", Evidence: []model.CandidateEvidence{{SourceURL: "https://evidence.example/report", Locator: "测试段落", Summary: "测试摘要"}}, CounterEvidence: "存在风险", Invalidation: "条件失效", ReferencePlan: model.CandidateReferencePlan{EntryLow: "10.00", EntryHigh: "11.00", StopLoss: "9.00", TargetPrice: "13.00"}}, ObservedAt: time.Now().UTC(), ValidUntil: time.Now().UTC().Add(time.Hour)}
|
||||
create := func(source string, expected int64) model.CandidateRevision {
|
||||
r, err := svc.Create(ctx, admin, source, uuid.NewString(), input, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
act := func(r model.CandidateRevision, action string) model.CandidateRevision {
|
||||
next, err := svc.Transition(ctx, admin, r.ID, action, uuid.NewString(), model.CandidateActionInput{ExpectedVersion: r.Version, Reason: "集成测试审核意见"})
|
||||
if err != nil {
|
||||
t.Fatalf("transition %s: %v", action, err)
|
||||
}
|
||||
return next
|
||||
}
|
||||
list := func() []model.CandidateRevision {
|
||||
r, err := svc.List(ctx, user, false, "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
draft := create("", 0)
|
||||
if len(list()) != 0 {
|
||||
t.Fatal("draft leaked into app feed")
|
||||
}
|
||||
if _, err := svc.Detail(ctx, user, draft.ID, false); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("app read unpublished revision")
|
||||
}
|
||||
if _, err := svc.Create(ctx, user, "", uuid.NewString(), input, 0); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("user created candidate")
|
||||
}
|
||||
if _, err := service.NewCandidateService(repo, false).List(ctx, user, false, "", 100); !errors.Is(err, apperror.ErrUnavailable) {
|
||||
t.Fatal("simulation capability exposed in production")
|
||||
}
|
||||
if _, err := svc.Transition(ctx, admin, draft.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: draft.Version, Reason: "invalid"}); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("draft published without submission")
|
||||
}
|
||||
pending := act(draft, "SUBMIT")
|
||||
if _, err := svc.Create(ctx, admin, pending.ID, uuid.NewString(), input, pending.Version); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("pending review overwritten")
|
||||
}
|
||||
returned := act(pending, "RETURN")
|
||||
replacement := create(returned.ID, returned.Version)
|
||||
if replacement.CandidateID != draft.CandidateID || replacement.Revision != 2 {
|
||||
t.Fatal("revision history lost")
|
||||
}
|
||||
pending = act(replacement, "SUBMIT")
|
||||
// Two concurrent reviewers cannot both commit the same input version.
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := svc.Transition(ctx, admin, pending.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: pending.Version, Reason: "并发发布测试"})
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success, conflict := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if errors.Is(err, apperror.ErrConflict) {
|
||||
conflict++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatal("publication CAS failed")
|
||||
}
|
||||
published, err := svc.Detail(ctx, admin, pending.ID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list()) != 1 || !published.IsActivePublication || len(published.Reviews) != 2 {
|
||||
t.Fatal("publication missing or duplicate reviews")
|
||||
}
|
||||
appDetail, err := svc.Detail(ctx, user, pending.ID, false)
|
||||
if err != nil || len(appDetail.Reviews) != 0 {
|
||||
t.Fatal("internal reviews leaked")
|
||||
}
|
||||
source := published.CandidateRevision
|
||||
input.Content.Thesis = "第二次发布的新内容"
|
||||
next := create(source.ID, source.Version)
|
||||
if len(list()) != 1 || list()[0].ID != source.ID {
|
||||
t.Fatal("new draft hid current publication")
|
||||
}
|
||||
next = act(act(next, "SUBMIT"), "PUBLISH")
|
||||
if len(list()) != 1 || list()[0].ID != next.ID {
|
||||
t.Fatal("superseded publication remained active")
|
||||
}
|
||||
old, err := svc.Detail(ctx, user, source.ID, false)
|
||||
if err != nil || old.IsActivePublication || old.Snapshot.Thesis == input.Content.Thesis {
|
||||
t.Fatal("old content overwritten")
|
||||
}
|
||||
testTradePlans(t, ctx, pool, user, next)
|
||||
// Inject a durable-event write failure; publication, review and audit must roll back.
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_outbox_event ADD CONSTRAINT veyra_test_event_failure CHECK(event_type!='candidate.withdrawn') NOT VALID`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Transition(ctx, admin, next.ID, "WITHDRAW", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: next.Version, Reason: "事务回滚测试"}); err == nil {
|
||||
t.Fatal("injected outbox error did not fail")
|
||||
}
|
||||
after, err := svc.Detail(ctx, admin, next.ID, true)
|
||||
if err != nil || after.Version != next.Version || after.Status != "PUBLISHED" || len(after.Reviews) != 2 {
|
||||
t.Fatal("failed event left partial publication")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_outbox_event DROP CONSTRAINT veyra_test_event_failure`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withdrawn := act(next, "WITHDRAW")
|
||||
plans, err := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true).List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.CandidateRevisionID == next.ID && p.Status != "CANCELLED" && (p.Status != "INVALIDATED" || p.ReservedMinor != 0) {
|
||||
t.Fatal("withdrawal did not invalidate plan and release budget")
|
||||
}
|
||||
}
|
||||
if len(list()) != 0 {
|
||||
t.Fatal("withdrawn publication visible in feed")
|
||||
}
|
||||
historical, err := svc.Detail(ctx, user, withdrawn.ID, false)
|
||||
if err != nil || historical.Status != "WITHDRAWN" || historical.IsActivePublication {
|
||||
t.Fatal("withdrawal history unavailable")
|
||||
}
|
||||
var eventCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_outbox_event WHERE aggregate_type='candidate_revision'`).Scan(&eventCount); err != nil || eventCount != 4 {
|
||||
t.Fatalf("outbox facts: count=%d err=%v", eventCount, err)
|
||||
}
|
||||
// Expired published candidates leave the live feed without deleting history.
|
||||
expires := act(act(create("", 0), "SUBMIT"), "PUBLISH")
|
||||
if _, err := pool.Exec(ctx, `UPDATE veyra_candidate_revision SET observed_at=now()-interval '2 hours',valid_until=now()-interval '1 hour' WHERE id=$1`, expires.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list()) != 0 {
|
||||
t.Fatal("expired publication visible in feed")
|
||||
}
|
||||
// Real HTTP adapters enforce cookie audience, origin and unpublished visibility.
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := handler.NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), handler.IdentityOptions{Auth: auth, Candidates: svc, Limiter: allowLogin{}, DevelopmentLogin: true})
|
||||
adminLogin, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userLogin, err := auth.Login(ctx, "alice", "a-test-only-password", "app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(method, path, body, origin, token string, adminAudience bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", origin)
|
||||
if adminAudience {
|
||||
req.AddCookie(&http.Cookie{Name: "veyra_admin_session", Value: token})
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
if r := call("GET", "/api/v1/candidates/"+draft.ID, "", "", userLogin.Token, false); r.Code != 404 {
|
||||
t.Fatal("HTTP draft disclosure")
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
if r := call("POST", "/api/admin/v1/candidates", string(body), "http://attacker.example", adminLogin.Token, true); r.Code != 403 {
|
||||
t.Fatal("cross origin create accepted")
|
||||
}
|
||||
if r := call("GET", "/api/admin/v1/candidates", "", "", userLogin.Token, true); r.Code != 401 {
|
||||
t.Fatal("app token accepted as admin cookie")
|
||||
}
|
||||
r := call("POST", "/api/admin/v1/candidates", string(body), "http://example.com", adminLogin.Token, true)
|
||||
if r.Code != 201 || !bytes.Contains(r.Body.Bytes(), []byte(`"status":"DRAFT"`)) {
|
||||
t.Fatalf("HTTP create: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
if r := call("GET", "/api/v1/candidates?limit=101", "", "", userLogin.Token, false); r.Code != 422 {
|
||||
t.Fatal("unbounded list accepted")
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package integration
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/database"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if dsn == "" {
|
||||
t.Skip("set VEYRA_TEST_DATABASE_URL for real database integration")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
admin, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
@@ -127,11 +127,15 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("expired session remained valid")
|
||||
}
|
||||
if _, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN"); err != nil {
|
||||
adminUser, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testProviderLifecycle(t, ctx, pool, adminUser, u1)
|
||||
vault, _ := secret.NewVault("")
|
||||
providers := service.NewProviderService(repository.NewProviderRepository(pool), vault)
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := handler.NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), handler.IdentityOptions{Auth: auth, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
|
||||
router := handler.NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), handler.IdentityOptions{Auth: auth, Providers: providers, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
|
||||
call := func(method, path, body, origin string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
@@ -162,6 +166,23 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
|
||||
t.Fatal("cookie authentication failed")
|
||||
}
|
||||
|
||||
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", nil); response.Code != http.StatusUnauthorized {
|
||||
t.Fatal("unauthenticated configuration read allowed")
|
||||
}
|
||||
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", cookie); response.Code != http.StatusOK {
|
||||
t.Fatal("admin configuration read failed")
|
||||
}
|
||||
body := `{"name":"","base_url":"","model_name":"","enabled":false,"timeout_seconds":15,"clear_api_key":false,"expected_version":0}`
|
||||
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
||||
t.Fatal("cross-origin configuration write allowed")
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusOK {
|
||||
t.Fatal("empty market configuration write failed")
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusConflict {
|
||||
t.Fatal("HTTP stale version accepted")
|
||||
}
|
||||
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
||||
t.Fatal("cross-origin logout allowed")
|
||||
}
|
||||
@@ -174,6 +195,10 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusUnauthorized {
|
||||
t.Fatal("revoked admin cookie remained valid")
|
||||
}
|
||||
testCandidateLifecycle(t, ctx, pool, auth, adminUser, u1)
|
||||
testStrategyBacktest(t, ctx, pool, adminUser, u1)
|
||||
testTradeLedger(t, ctx, pool, auth)
|
||||
testAdminCredentialChange(t, ctx, pool, auth)
|
||||
}
|
||||
|
||||
type allowLogin struct{}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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/secret"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func testProviderLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, actor, ordinary model.User) {
|
||||
t.Helper()
|
||||
vault, err := secret.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := repository.NewProviderRepository(pool)
|
||||
svc := service.NewProviderService(repo, vault)
|
||||
configs, err := svc.List(ctx, actor)
|
||||
if err != nil || len(configs) != 0 {
|
||||
t.Fatalf("configuration must start empty: %v", err)
|
||||
}
|
||||
input := service.ProviderInput{TimeoutSeconds: 15}
|
||||
config, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil || config.Enabled || config.BaseURL != "" || config.LastTestStatus != "NOT_CONFIGURED" || config.WireAPI != "responses" || !config.DisableResponseStorage || !config.RequiresOpenAIAuth {
|
||||
t.Fatalf("blank configuration: %v", err)
|
||||
}
|
||||
if _, err := svc.List(ctx, ordinary); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("ordinary user accessed provider configuration")
|
||||
}
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("stale configuration accepted")
|
||||
}
|
||||
token := "test-private-provider-key"
|
||||
input = service.ProviderInput{Name: "configured vendor", BaseURL: "https://provider.example/v1", ModelName: "configured-model", ModelCatalogURL: "https://provider.example/v1/models", TimeoutSeconds: 15, Enabled: true, APIKey: &token, ExpectedVersion: config.Version}
|
||||
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := json.Marshal(config)
|
||||
if bytes.Contains(data, []byte(token)) || bytes.Contains(data, []byte("encrypted_api_key")) {
|
||||
t.Fatal("API exposes credential")
|
||||
}
|
||||
loaded, err := repo.Get(ctx, "model_primary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := vault.Decrypt(loaded.EncryptedAPIKey, "model_primary")
|
||||
if err != nil || value != token {
|
||||
t.Fatal("credential was not persisted encrypted")
|
||||
}
|
||||
input.ExpectedVersion = config.Version
|
||||
input.ModelCatalogURL = "https://another.example/models"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("cross-origin model catalog accepted")
|
||||
}
|
||||
input.ModelCatalogURL = "https://provider.example/v1/models"
|
||||
input.WireAPI = "unknown"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("unsupported protocol accepted")
|
||||
}
|
||||
input.WireAPI = "responses"
|
||||
input.ExpectedVersion = config.Version
|
||||
input.APIKey = nil
|
||||
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil || !config.HasAPIKey {
|
||||
t.Fatal("omitted key did not preserve existing credential")
|
||||
}
|
||||
input.ExpectedVersion = config.Version
|
||||
input.BaseURL = "https://different.example/v1"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("changed endpoint reused existing secret")
|
||||
}
|
||||
input.BaseURL = "https://provider.example/v1?api_key=secret"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("query credentials accepted")
|
||||
}
|
||||
input.BaseURL = config.BaseURL
|
||||
input.Enabled = false
|
||||
input.ClearAPIKey = true
|
||||
disabled := false
|
||||
input.DisableResponseStorage = &disabled
|
||||
input.RequiresOpenAIAuth = &disabled
|
||||
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil || config.HasAPIKey || config.DisableResponseStorage || config.RequiresOpenAIAuth {
|
||||
t.Fatal("key clear failed")
|
||||
}
|
||||
input.DisableResponseStorage = nil
|
||||
input.RequiresOpenAIAuth = nil
|
||||
// Two writers of the same version must produce one successor and one conflict.
|
||||
input.ExpectedVersion = config.Version
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
succeeded, conflicted := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
} else if errors.Is(err, apperror.ErrConflict) {
|
||||
conflicted++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
latest, err := repo.Get(ctx, "model_primary")
|
||||
if err != nil || latest.DisableResponseStorage || latest.RequiresOpenAIAuth {
|
||||
t.Fatal("omitted flags did not preserve configuration")
|
||||
}
|
||||
if succeeded != 1 || conflicted != 1 {
|
||||
t.Fatal("concurrent version protection failed")
|
||||
}
|
||||
var snapshots string
|
||||
var historyCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*),string_agg(snapshot::text,'') FROM veyra_provider_config_history`).Scan(&historyCount, &snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if historyCount != 5 || strings.Contains(snapshots, token) {
|
||||
t.Fatal("history count or credential sanitization failed")
|
||||
}
|
||||
events, err := svc.Audit(ctx, actor)
|
||||
if err != nil || len(events) != 5 {
|
||||
t.Fatalf("atomic audit: %v", err)
|
||||
}
|
||||
encoded, _ := json.Marshal(events)
|
||||
if bytes.Contains(encoded, []byte(token)) {
|
||||
t.Fatal("audit exposed credential")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/research"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testStrategyBacktest(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admin, user model.User) {
|
||||
t.Helper()
|
||||
strategies := service.NewStrategyService(repository.NewStrategyRepository(pool), true)
|
||||
input := service.StrategyInput{Code: "sma_research", Name: "均线研究", Description: "fixture parameters", Parameters: model.StrategyParameters{ShortWindow: 2, LongWindow: 3, FeeBPS: 10, SlippageBPS: 0, LotSize: 100}}
|
||||
if _, e := strategies.Save(ctx, user, "", input, uuid.NewString()); !errors.Is(e, apperror.ErrForbidden) {
|
||||
t.Fatal("ordinary user can edit research strategy")
|
||||
}
|
||||
created, e := strategies.Save(ctx, admin, "", input, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
deployed, e := strategies.Deploy(ctx, admin, created.ID, created.Versions[0].ID, "ACTIVE", created.Version, "研究选用", uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
input.ExpectedVersion = deployed.Version
|
||||
input.Parameters.ShortWindow = 3
|
||||
input.Parameters.LongWindow = 4
|
||||
revised, e := strategies.Save(ctx, admin, created.ID, input, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(revised.Versions) != 2 || *revised.ActiveVersionID != created.Versions[0].ID || revised.Versions[1].Parameters.ShortWindow != 2 {
|
||||
t.Fatal("revision changed selected or immutable parameters")
|
||||
}
|
||||
if _, e := strategies.Save(ctx, admin, created.ID, input, uuid.NewString()); !errors.Is(e, apperror.ErrConflict) {
|
||||
t.Fatal("stale strategy edit succeeded")
|
||||
}
|
||||
if _, e := pool.Exec(ctx, `UPDATE veyra_strategy_version SET description='overwrite' WHERE id=$1`, created.Versions[0].ID); e == nil {
|
||||
t.Fatal("strategy parameters can be overwritten")
|
||||
}
|
||||
repo := repository.NewBacktestRepository(pool)
|
||||
svc := service.NewBacktestService(repo, research.New("python3"), true)
|
||||
bars := []model.HistoricalBar{}
|
||||
for i, v := range []string{"10.00", "9.00", "8.00", "20.00", "21.00", "7.00", "6.00", "5.00"} {
|
||||
bars = append(bars, model.HistoricalBar{Date: fmt.Sprintf("2025-01-%02d", i+1), Open: v, High: v, Low: v, Close: v})
|
||||
}
|
||||
bi := model.BacktestInput{StrategyVersionID: created.Versions[0].ID, DatasetName: "integration fixture", Symbol: "600000", InitialCash: "10000.00", Bars: bars}
|
||||
bad := bi
|
||||
bad.Bars = append([]model.HistoricalBar(nil), bars...)
|
||||
bad.Bars[1].Date = bad.Bars[0].Date
|
||||
if _, e := svc.Create(ctx, admin, bad, uuid.NewString()); !errors.Is(e, apperror.ErrInvalidInput) {
|
||||
t.Fatal("duplicate day allowed")
|
||||
}
|
||||
job, e := svc.Create(ctx, admin, bi, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var snapshot model.ResearchInput
|
||||
if json.Unmarshal(job.Input, &snapshot) != nil || snapshot.Parameters.ShortWindow != 2 {
|
||||
t.Fatal("backtest lost immutable version parameters")
|
||||
}
|
||||
claim, e := repo.Claim(ctx)
|
||||
if e != nil || claim.ID != job.ID || claim.LeaseID == nil {
|
||||
t.Fatalf("claim: %v", e)
|
||||
}
|
||||
if _, e := repo.Claim(ctx); !errors.Is(e, apperror.ErrNotFound) {
|
||||
t.Fatal("active lease claimed twice")
|
||||
}
|
||||
if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET lease_expires_at=now()-interval '1 second' WHERE id=$1`, job.ID); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
renewed, e := repo.Claim(ctx)
|
||||
if e != nil || *renewed.LeaseID == *claim.LeaseID {
|
||||
t.Fatal("expired job cannot recover")
|
||||
}
|
||||
result, e := research.New("python3").Execute(ctx, renewed.Input)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := repo.Finish(ctx, claim, result, ""); !errors.Is(e, apperror.ErrConflict) {
|
||||
t.Fatal("stale worker can overwrite result")
|
||||
}
|
||||
if e := repo.Finish(ctx, renewed, result, ""); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
done, e := svc.Detail(ctx, admin, job.ID)
|
||||
if e != nil || done.Status != "COMPLETED" || done.FinishedAt == nil || len(done.Result) == 0 {
|
||||
t.Fatalf("completed job: %v", e)
|
||||
}
|
||||
if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET result='{}'::jsonb WHERE id=$1`, job.ID); e == nil {
|
||||
t.Fatal("completed result can be overwritten")
|
||||
}
|
||||
if _, e := svc.Detail(ctx, user, job.ID); !errors.Is(e, apperror.ErrForbidden) {
|
||||
t.Fatal("app user can access research inputs")
|
||||
}
|
||||
failed, e := svc.Create(ctx, admin, bi, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
failing := service.NewBacktestService(repo, research.New("/missing/python"), true)
|
||||
if _, e := failing.ProcessNext(ctx); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
failed, e = svc.Detail(ctx, admin, failed.ID)
|
||||
if e != nil || failed.Status != "FAILED" || failed.Result != nil || failed.Failure == "" {
|
||||
t.Fatal("execution failure presented a result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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 testTradeLedger(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
||||
t.Helper()
|
||||
user, err := auth.ProvisionUser(ctx, "ledger_user", "ledger-test-password", "Ledger", "USER")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cash := "10000.00"
|
||||
account, err := service.NewAccountService(repository.NewAccountRepository(pool)).Create(ctx, user.ID, "simulation", "ledger", &cash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true)
|
||||
now := time.Now().UTC().Add(-time.Minute)
|
||||
settlement := now.Add(20 * time.Second)
|
||||
input := service.FillInput{AccountID: account.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: "600000", Exchange: "SSE", Name: "测试证券", Quantity: "200", Price: "10.00", Fee: "5.01", OccurredAt: now, SellableAt: &settlement}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan model.TradeFill, 2)
|
||||
failures := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
f, err := svc.Record(ctx, user, input, uuid.NewString())
|
||||
if err != nil {
|
||||
failures <- err
|
||||
} else {
|
||||
results <- f
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(failures)
|
||||
for err := range failures {
|
||||
t.Fatal(err)
|
||||
}
|
||||
one, two := <-results, <-results
|
||||
if one.ID != two.ID {
|
||||
t.Fatal("idempotent fill duplicated")
|
||||
}
|
||||
input.Price = "11.00"
|
||||
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("same idempotency key accepted different input")
|
||||
}
|
||||
input.Price = "10.00"
|
||||
portfolio, err := svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "7994.99" || len(portfolio.Positions) != 1 || portfolio.Positions[0].CostBasis != "2005.01" || portfolio.Positions[0].MarketValue != nil {
|
||||
t.Fatalf("buy ledger %+v %v", portfolio, err)
|
||||
}
|
||||
stranger := user
|
||||
stranger.ID = uuid.NewString()
|
||||
if _, err := svc.Portfolio(ctx, stranger, account.ID); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("portfolio owner isolation")
|
||||
}
|
||||
sell := input
|
||||
sell.Side = "SELL"
|
||||
sell.SellableAt = nil
|
||||
sell.IdempotencyKey = uuid.NewString()
|
||||
sell.Quantity = "100"
|
||||
sell.Price = "12.00"
|
||||
sell.Fee = "3.00"
|
||||
sell.OccurredAt = now.Add(10 * time.Second)
|
||||
if _, err := svc.Record(ctx, user, sell, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("unsettled shares sold")
|
||||
}
|
||||
sell.OccurredAt = now.Add(30 * time.Second)
|
||||
sale, err := svc.Record(ctx, user, sell, uuid.NewString())
|
||||
if err != nil || sale.RealizedPnL != "194.50" {
|
||||
t.Fatalf("FIFO sale %+v %v", sale, err)
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "9191.99" || portfolio.RealizedPnL != "194.50" || portfolio.Review.TradeCount != 2 || portfolio.Review.TotalFees != "8.01" || portfolio.Review.BuyOutflow != "2005.01" || portfolio.Review.SellInflow != "1197.00" || portfolio.Review.ProfitableSellCount != 1 || portfolio.Positions[0].CostBasis != "1002.51" || portfolio.Positions[0].Quantity != "100" {
|
||||
t.Fatal("FIFO rounding or cash mismatch")
|
||||
}
|
||||
if _, err := svc.Reverse(ctx, user, one.ID, "非最新成交", uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("dependent buy reversed")
|
||||
}
|
||||
// Fail the cash reversal write and assert no partial restoration survives.
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_test_reverse_failure CHECK(entry_type!='REVERSAL') NOT VALID`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Reverse(ctx, user, sale.ID, "回滚检查", uuid.NewString()); err == nil {
|
||||
t.Fatal("injected cash error ignored")
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.Positions[0].Quantity != "100" || portfolio.CashBalance != "9191.99" {
|
||||
t.Fatal("failed reversal changed positions")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_test_reverse_failure`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reversed, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
||||
if err != nil || duplicate.ID != reversed.ID {
|
||||
t.Fatal("duplicate reversal applied twice")
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "7994.99" || portfolio.RealizedPnL != "0.00" || portfolio.Positions[0].CostBasis != "2005.01" {
|
||||
t.Fatal("sell reversal restoration mismatch")
|
||||
}
|
||||
if _, err := svc.Reverse(ctx, user, one.ID, "纠正买入", uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "10000.00" || len(portfolio.Positions) != 0 {
|
||||
t.Fatal("buy reversal did not restore initial ledger")
|
||||
}
|
||||
fills, err := svc.List(ctx, user, account.ID)
|
||||
if err != nil || len(fills) != 2 || !fills[0].Reversed || !fills[1].Reversed {
|
||||
t.Fatal("original fill facts lost")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE veyra_trade_fill SET quantity=1 WHERE id=$1`, one.ID); err == nil {
|
||||
t.Fatal("posted fill mutation allowed")
|
||||
}
|
||||
input.IdempotencyKey = uuid.NewString()
|
||||
input.Quantity = "1000000"
|
||||
input.Price = "100.00"
|
||||
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("insufficient cash accepted")
|
||||
}
|
||||
var entries int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_cash_entry WHERE account_id=$1`, account.ID).Scan(&entries); err != nil || entries != 5 {
|
||||
t.Fatal("failed request left cash or duplicate cash entries")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user