Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
214 lines
8.9 KiB
Go
214 lines
8.9 KiB
Go
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")
|
|
}
|
|
}
|