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) } } 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) } } // TestAccountAuditEndpointFilters 驱动 GET /api/phase-a/audit 的过滤参数解析(auditFilter)。 func TestAccountAuditEndpointFilters(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() }) // hub.Open 负责把迁移链铺进隔离 schema(account.Open 不建表)。 hubStore, err := hub.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = hubStore.Close() }) app := fiber.New() RegisterAccountRoutes(app, accountStore, nil, &testCredentialBridge{values: map[string]string{}}) auditDB, err := sql.Open("pgx", databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = auditDB.Close() }) if _, err := auditDB.ExecContext(ctx, ` INSERT INTO gateway (name, endpoint, token) VALUES ('gw-1', 'http://gw-1:8081', 'unit-test-gateway-token'); INSERT INTO social_account (account_id, credential_provider, credential_key, name, platform, platform_account_key, status) VALUES ('account-audit-0000000000000000', 'os_keyring', 'creatorhub/account-audit-0000000000000000', '审计账号', 'douyin', 'key-audit', 'paused'); INSERT INTO audit_event (event_type, account_id, actor, reason_code, details) SELECT 'account_created', account.id, 'local-user', 'account_created', '{"platform":"douyin"}' FROM social_account account WHERE account.account_id = 'account-audit-0000000000000000'`); err != nil { t.Fatal(err) } response := do(app, http.MethodGet, "/api/phase-a/audit?account_id=account-audit-0000000000000000&page=1&page_size=10", "") if response.Code != http.StatusOK { t.Fatalf("audit query: %d %s", response.Code, response.Body.String()) } var page struct { Data []map[string]any `json:"data"` Total int `json:"total"` } if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || page.Total != 1 || len(page.Data) != 1 { t.Fatalf("audit page: total=%d data=%d err=%v", page.Total, len(page.Data), err) } if page.Data[0]["event_type"] != "account_created" || page.Data[0]["account_id"] != "account-audit-0000000000000000" { t.Fatalf("audit event fields: %#v", page.Data[0]) } for _, query := range []string{ "/api/phase-a/audit?page=0", "/api/phase-a/audit?page_size=1001", "/api/phase-a/audit?from=not-a-time", "/api/phase-a/audit?event_type=INVALID%20EVENT", } { if response := do(app, http.MethodGet, query, ""); response.Code != http.StatusBadRequest { t.Fatalf("invalid filter %q must 400: %d %s", query, response.Code, response.Body.String()) } } if response := do(app, http.MethodGet, "/api/phase-a/audit?from=2026-01-01T00:00:00Z&to=2027-01-01T00:00:00Z", ""); response.Code != http.StatusOK { t.Fatalf("time-bounded audit query: %d %s", response.Code, response.Body.String()) } }