Files
creator-hub/internal/controlplane/api/account_environment_unit_test.go
T

207 lines
8.9 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"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 TestAccountEnvironmentAutoBindingAndStart(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)
app := fiber.New()
RegisterAccountRoutes(app, accountStore, hubStore, &testCredentialBridge{values: map[string]string{}})
// 网关缺失:创建账号即绑定失败 → 503 environment_binding_failed,但账号已存在(可补建重试)
response := do(app, http.MethodPost, "/api/phase-a/accounts",
`{"name":"测试账号","platform":"douyin","platform_account_key":"key-binding-1","cookies":"sessionid=1"}`)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 when no gateway exists, got %d: %s", response.Code, response.Body.String())
}
var failure map[string]string
if err := json.Unmarshal(response.Body.Bytes(), &failure); err != nil || failure["reason_code"] != "environment_binding_failed" || failure["account_id"] == "" {
t.Fatalf("binding failure payload: %s err=%v", response.Body.String(), err)
}
accountID := failure["account_id"]
if _, err := accountStore.GetAccount(ctx, accountID); err != nil {
t.Fatalf("account must exist after failed binding: %v", err)
}
if response := do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/environment", ""); response.Code != http.StatusNotFound {
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)
}
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/environment", "")
if response.Code != http.StatusOK {
t.Fatalf("expected 200 environment rebind, got %d: %s", response.Code, response.Body.String())
}
var bound struct {
Alias string `json:"alias"`
Gateway string `json:"gateway"`
Created bool `json:"created"`
}
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 != 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", "")
var rebound struct {
Created bool `json:"created"`
}
if err := json.Unmarshal(response.Body.Bytes(), &rebound); err != nil || response.Code != http.StatusOK || rebound.Created {
t.Fatalf("rebind must be idempotent: %d %s err=%v", response.Code, response.Body.String(), err)
}
// start = resume + 启动环境:激活 runtime 并落审计对
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/start", "")
if response.Code != http.StatusNoContent {
t.Fatalf("expected 204 start, got %d: %s", response.Code, response.Body.String())
}
environment, err = hubStore.GetEnvironmentContext(ctx, accountID)
if err != nil || environment.RuntimeID == "" {
t.Fatalf("start must activate the environment runtime: %#v err=%v", environment, err)
}
var startAudits int
if err := auditDB.QueryRowContext(ctx,
`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)
}
// start 冲突分支:吊销账号后 start → 409 readiness blocked
if response := do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/revoke", ""); response.Code != http.StatusNoContent {
t.Fatalf("revoke failed: %d: %s", response.Code, response.Body.String())
}
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/start", "")
if response.Code != http.StatusConflict {
t.Fatalf("expected 409 start on revoked account, got %d: %s", response.Code, response.Body.String())
}
var conflict map[string]string
if err := json.Unmarshal(response.Body.Bytes(), &conflict); err != nil || conflict["reason_code"] != "account_revoked" || conflict["readiness"] != "blocked" {
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)
}
}