test(api): 账号删除链路 PG 集成测试 + 测试直写 SQL 适配 044 列名(social_account/browser_env/audit_event)

This commit is contained in:
2026-09-28 22:30:31 +08:00
parent fba7ee6236
commit 799087fcb1
4 changed files with 101 additions and 45 deletions
@@ -3,7 +3,6 @@ package api
import (
"context"
"errors"
"hash/fnv"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
)
@@ -11,14 +10,6 @@ import (
// 账号即环境:账号创建即绑定运行环境。
// 规则:alias = 账号文本 ID、唯一网关自动取、出口直连、seed 由账号 ID 派生。
// deriveSeed 由账号文本 ID 确定性派生 fingerprint seed(1001..2147483647)。
// 数字主键落地后改为 seed = 账号 bigint id + 1000,仅替换本函数。
func deriveSeed(accountID string) int64 {
hasher := fnv.New64a()
_, _ = hasher.Write([]byte(accountID))
return int64(hasher.Sum64()%(2147483647-1000)) + 1000
}
// soleGateway 返回平台唯一网关;零配置绑定要求恰有一个网关:
// 缺失返回 ErrNotFound,多个返回 ErrConflict(无法自动选择)。
func soleGateway(ctx context.Context, store HubStore) (hub.Gateway, error) {
@@ -49,9 +40,9 @@ func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID str
if err != nil {
return hub.EnvironmentContext{}, false, err
}
// seed 由 CreateBoundEnv 从账号 bigint id + 1000 派生(数字主键)。
return store.CreateBoundEnv(ctx, hub.Env{
Alias: accountID, Name: accountID, Gateway: gateway.Name,
Fingerprint: hub.Fingerprint{Seed: deriveSeed(accountID)},
}, accountID, "")
}
@@ -10,31 +10,11 @@ import (
"testing"
accountdomain "git.ipao.vip/rogee/creator-hub/internal/account"
"git.ipao.vip/rogee/creator-hub/internal/creator"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
)
func TestDeriveSeed(t *testing.T) {
// 确定性:同 ID 派生同 seed
if deriveSeed("account-0123456789abcdef01234567") != deriveSeed("account-0123456789abcdef01234567") {
t.Fatal("deriveSeed must be deterministic")
}
// 值域:1001..2147483647(seed 上限约束,偏移 1000 对齐未来 bigint id + 1000)
for _, accountID := range []string{"account-a", "account-000000000000000000000000", "account-zzzzzzzzzzzzzzzzzzzzzzzz", ""} {
seed := deriveSeed(accountID)
if seed < 1001 || seed > 2147483647 {
t.Fatalf("seed out of range for %q: %d", accountID, seed)
}
}
// 不同 ID 派生不同 seed(固定向量,防回归)
if deriveSeed("account-a") == deriveSeed("account-b") {
t.Fatal("distinct accounts must derive distinct seeds")
}
if got := deriveSeed("account-a"); got != 1816671480 {
t.Fatalf("deriveSeed vector drifted: %d", got)
}
}
func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
@@ -76,6 +56,12 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) {
t.Fatalf("expected 404 environment rebind without gateway, got %d: %s", response.Code, response.Body.String())
}
auditDB, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = auditDB.Close() })
// 注册唯一网关后:补建成功,alias=账号 ID、出口直连、seed 派生
if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil {
t.Fatal(err)
@@ -92,9 +78,13 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &bound); err != nil || bound.Alias != accountID || bound.Gateway != "gw-main" || !bound.Created {
t.Fatalf("rebind payload: %s err=%v", response.Body.String(), err)
}
var accountRowID int64
if err := auditDB.QueryRowContext(ctx, `SELECT id FROM social_account WHERE account_id = $1`, accountID).Scan(&accountRowID); err != nil {
t.Fatal(err)
}
environment, err := hubStore.GetEnvironmentContext(ctx, accountID)
if err != nil || environment.Fingerprint.Seed != deriveSeed(accountID) || environment.Exit.ID != "" {
t.Fatalf("auto-bound environment: %#v err=%v", environment, err)
if err != nil || environment.Fingerprint.Seed != accountRowID+1000 || environment.Exit.ID != "" {
t.Fatalf("auto-bound environment: %#v err=%v (account row id %d)", environment, err, accountRowID)
}
// 幂等:重复补建返回既有环境
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/environment", "")
@@ -114,14 +104,9 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) {
if err != nil || environment.RuntimeID == "" {
t.Fatalf("start must activate the environment runtime: %#v err=%v", environment, err)
}
auditDB, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = auditDB.Close() })
var startAudits int
if err := auditDB.QueryRowContext(ctx,
`SELECT count(*) FROM audit_event WHERE account_id = $1 AND action = 'start' AND reason_code IN ('action_requested','environment_started')`, accountID).Scan(&startAudits); err != nil || startAudits != 2 {
`SELECT count(*) FROM audit_event a JOIN social_account sa ON sa.id = a.account_id WHERE sa.account_id = $1 AND action = 'start' AND reason_code IN ('action_requested','environment_started')`, accountID).Scan(&startAudits); err != nil || startAudits != 2 {
t.Fatalf("start audit pair missing: rows=%d err=%v", startAudits, err)
}
@@ -138,3 +123,84 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) {
t.Fatalf("start conflict payload: %s err=%v", response.Body.String(), err)
}
}
func TestAccountEnvironmentDeletionLifecycle(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
accountStore, err := accountdomain.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = accountStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
gateway := &fakeGateway{token: "unit-test-gateway-token"}
gatewayServer := httptest.NewServer(gateway.handler(t))
t.Cleanup(gatewayServer.Close)
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
app := fiber.New()
RegisterAccountRoutes(app, accountStore, hubStore, &testCredentialBridge{values: map[string]string{}})
RegisterAccountDeletion(app, accountStore, hubStore, creatorStore, &testCredentialBridge{values: map[string]string{}})
if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil {
t.Fatal(err)
}
// 删除不存在的账号 → 404
if response := do(app, http.MethodDelete, "/api/phase-a/accounts/account-missing0000000000000", ""); response.Code != http.StatusNotFound {
t.Fatalf("expected 404 deleting missing account, got %d: %s", response.Code, response.Body.String())
}
// 创建即绑定 → 审计与浏览器环境随账号生成
response := do(app, http.MethodPost, "/api/phase-a/accounts",
`{"name":"待删账号","platform":"douyin","platform_account_key":"key-delete-1","cookies":"sessionid=1"}`)
if response.Code != http.StatusCreated {
t.Fatalf("expected 201 create account, got %d: %s", response.Code, response.Body.String())
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil || created.ID == "" {
t.Fatalf("create payload: %s err=%v", response.Body.String(), err)
}
accountID := created.ID
if _, err := hubStore.GetEnvironmentContext(ctx, accountID); err != nil {
t.Fatalf("auto-bound environment missing: %v", err)
}
// 删除账号 → 204;账号、环境与审计全部清除
if response := do(app, http.MethodDelete, "/api/phase-a/accounts/"+accountID, ""); response.Code != http.StatusNoContent {
t.Fatalf("expected 204 delete account, got %d: %s", response.Code, response.Body.String())
}
if _, err := accountStore.GetAccount(ctx, accountID); err == nil {
t.Fatal("account row must be gone after delete")
}
if _, err := hubStore.GetEnvironmentContext(ctx, accountID); err == nil {
t.Fatal("environment must be gone after delete")
}
auditDB, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = auditDB.Close() })
var auditCount int
if err := auditDB.QueryRowContext(ctx, `
SELECT count(*) FROM audit_event audit
JOIN social_account account ON account.id = audit.account_id
WHERE account.account_id = $1`, accountID).Scan(&auditCount); err != nil {
t.Fatal(err)
}
if auditCount != 0 {
t.Fatalf("account audit events must be purged with the account: rows=%d", auditCount)
}
}
@@ -26,7 +26,6 @@ type HubStore interface {
ListGateways(ctx context.Context) ([]hub.Gateway, error)
GetGateway(ctx context.Context, name string) (hub.Gateway, error)
DeleteGateway(ctx context.Context, name string) error
CreateEnv(ctx context.Context, env hub.Env) error
ListEnvs(ctx context.Context) ([]hub.Env, error)
GetEnv(ctx context.Context, alias string) (hub.Env, error)
CreateNetworkExit(ctx context.Context, exit hub.NetworkExit) (hub.NetworkExit, error)
+5 -5
View File
@@ -184,7 +184,7 @@ func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, e
return gateway, nil
}
func (s *memoryStore) DeleteGateway(context.Context, string) error { return nil }
func (s *memoryStore) CreateEnv(_ context.Context, env hub.Env) error {
func (s *memoryStore) insertEnv(_ context.Context, env hub.Env) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.envs[env.Alias]; exists {
@@ -287,7 +287,7 @@ func (s *memoryStore) CreateBoundEnv(ctx context.Context, env hub.Env, accountID
return existing, false, nil
}
s.mu.Unlock()
if err := s.CreateEnv(ctx, env); err != nil {
if err := s.insertEnv(ctx, env); err != nil {
return hub.EnvironmentContext{}, false, err
}
s.mu.Lock()
@@ -1362,7 +1362,7 @@ func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) {
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers",
`{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":0}}`)
`{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":-1}}`)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String())
}
@@ -1448,7 +1448,7 @@ func setFixtureAccountStatus(t *testing.T, databaseURL, status string) {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`UPDATE social_account SET status = $1 WHERE id = 'account-a'`, status); err != nil {
if _, err := db.Exec(`UPDATE social_account SET status = $1 WHERE account_id = 'account-a'`, status); err != nil {
t.Fatal(err)
}
}
@@ -2683,7 +2683,7 @@ func TestPostgresNonRunnableReconcileAuditsRuntimeRelease(t *testing.T) {
t.Fatal(err)
}
if _, err := fixture.db.ExecContext(ctx, `
UPDATE social_account SET status = 'paused', authorization_status = $1 WHERE id = 'account-a'`, authorization); err != nil {
UPDATE social_account SET status = 'paused', authorization_status = $1 WHERE account_id = 'account-a'`, authorization); err != nil {
t.Fatal(err)
}
if observation == "stopped" {