306 lines
12 KiB
Go
306 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// TestDouyinCollectorCarriesSourceIdentity 校验采集器构造透传来源标识。
|
|
func TestDouyinCollectorCarriesSourceIdentity(t *testing.T) {
|
|
browser := creatorGatewayBrowser{gateway: hub.Gateway{Name: "gw-1"}, environment: hub.EnvironmentContext{RuntimeID: "runtime-a"}}
|
|
collector := douyinCollector(browser, "sec_uid_x", "owned", "account-a")
|
|
if collector.AccountKey != "sec_uid_x" || collector.SourceType != "owned" || collector.SourceID != "account-a" {
|
|
t.Fatalf("collector fields: %+v", collector)
|
|
}
|
|
}
|
|
|
|
// TestCreatorUpdateHubSubscribePublish 驱动 SSE 变更通知的订阅/广播纯逻辑。
|
|
func TestCreatorUpdateHubSubscribePublish(t *testing.T) {
|
|
updates, unsubscribe := creatorUpdates.subscribe()
|
|
if updates == nil || unsubscribe == nil {
|
|
t.Fatal("subscribe must return channel and cancel function")
|
|
}
|
|
creatorUpdates.publish()
|
|
select {
|
|
case <-updates:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("publish did not notify the subscriber")
|
|
}
|
|
// 缓冲为 1:再次 publish 不阻塞(非阻塞发送语义)。
|
|
creatorUpdates.publish()
|
|
creatorUpdates.publish()
|
|
unsubscribe()
|
|
unsubscribe() // 幂等
|
|
}
|
|
|
|
// TestRuntimeCreateSpecMatchesGate 校验恢复运行时的绑定一致性门槛。
|
|
func TestRuntimeCreateSpecMatchesGate(t *testing.T) {
|
|
ctx := context.Background()
|
|
current := hub.EnvironmentContext{BindingVersion: 3}
|
|
current.Exit.ID = "exit-1"
|
|
if runtimeCreateSpecMatches(ctx, nil, current, current, nil) {
|
|
t.Fatal("nil prepared spec must not match")
|
|
}
|
|
previous := current
|
|
if !runtimeCreateSpecMatches(ctx, nil, current, previous, &runtimeCreateSpec{}) {
|
|
t.Fatal("same binding version and exit must match")
|
|
}
|
|
previous.BindingVersion = 2
|
|
if runtimeCreateSpecMatches(ctx, nil, current, previous, &runtimeCreateSpec{}) {
|
|
t.Fatal("stale binding version must not match")
|
|
}
|
|
previous.BindingVersion = 3
|
|
previous.Exit.ID = "exit-2"
|
|
if runtimeCreateSpecMatches(ctx, nil, current, previous, &runtimeCreateSpec{}) {
|
|
t.Fatal("changed exit must not match")
|
|
}
|
|
}
|
|
|
|
// TestCreatorGatewayBrowserGetImage 驱动封面拉取链路(成功 + 2xx 校验 + base64 解码)。
|
|
func TestCreatorGatewayBrowserGetImage(t *testing.T) {
|
|
encoded := base64.StdEncoding.EncodeToString([]byte("png-bytes"))
|
|
imageBody := `{"status":200,"content_type":"image/png","body_base64":"` + encoded + `"}`
|
|
cleanup := startFakeChannelGateway(t, 21, "creator-coverage-key-0021", map[string]string{
|
|
"POST /v1/browsers/browser/douyin/image": imageBody,
|
|
})
|
|
defer cleanup()
|
|
browser := creatorGatewayBrowser{
|
|
gateway: hub.Gateway{ID: 21, Name: "gw-test"},
|
|
environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", BindingVersion: 1},
|
|
}
|
|
contentType, data, err := browser.GetImage(context.Background(), "https://p.example/cover.png")
|
|
if err != nil || contentType != "image/png" || string(data) != "png-bytes" {
|
|
t.Fatalf("get image: type=%q data=%q err=%v", contentType, data, err)
|
|
}
|
|
|
|
// 网关返回非图片 content_type → 拒绝
|
|
rejectCleanup := startFakeChannelGateway(t, 22, "creator-coverage-key-0022", map[string]string{
|
|
"POST /v1/browsers/browser/douyin/image": `{"status":200,"content_type":"text/html","body_base64":"PGh0bWw+"}`,
|
|
})
|
|
defer rejectCleanup()
|
|
browser.gateway = hub.Gateway{ID: 22, Name: "gw-test"}
|
|
if _, _, err := browser.GetImage(context.Background(), "https://p.example/x"); err == nil {
|
|
t.Fatal("non-image response must be rejected")
|
|
}
|
|
}
|
|
|
|
// TestRunCreatorSchedulerContextCancel 驱动调度器入口:ctx 取消后立即返回。
|
|
func TestRunCreatorSchedulerContextCancel(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
RunCreatorScheduler(ctx, nil, nil, nil)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("scheduler must honor a cancelled context")
|
|
}
|
|
}
|
|
|
|
// TestReconcileRuntimeLeasesExported 用缺失网关的内存桩驱动导出的租约对账入口。
|
|
func TestReconcileRuntimeLeasesExported(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)
|
|
hubStore, err := hub.Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = hubStore.Close() })
|
|
if err := ReconcileRuntimeLeases(ctx, hubStore); err != nil {
|
|
t.Fatalf("empty reconcile must succeed: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCacheCompetitorWorkCovers 在隔离 PG 上驱动封面回填链路:
|
|
// 缺封面作品经浏览器 GetImage 拉取并保存为静态文件。
|
|
func TestCacheCompetitorWorkCovers(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()
|
|
store, _, ctx := openCreatorIntegrationStoreForAPITest(t, databaseURL)
|
|
competitor, err := store.CreateCompetitor(ctx, creator.CompetitorInput{
|
|
Platform: creator.PlatformDouyin, PlatformAccountKey: "sec_uid_covers_" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
UniqueID: "covers_unique", Nickname: "封面竞品", HomepageURL: "https://www.douyin.com/user/sec_uid_covers",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
work, _, err := store.UpsertWork(ctx, creator.WorkInput{
|
|
Platform: creator.PlatformDouyin, WorkKey: "cover-work-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
SourceType: creator.SourceCompetitor, SourceID: competitor.ID, Title: "封面作品", CoverURL: "https://p.example/cover.png",
|
|
}, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
encoded := base64.StdEncoding.EncodeToString([]byte("cover-bytes"))
|
|
cleanup := startFakeChannelGateway(t, 23, "creator-coverage-key-0023", map[string]string{
|
|
"POST /v1/browsers/browser/douyin/image": `{"status":200,"content_type":"image/png","body_base64":"` + encoded + `"}`,
|
|
})
|
|
defer cleanup()
|
|
browser := creatorGatewayBrowser{
|
|
gateway: hub.Gateway{ID: 23, Name: "gw-test"},
|
|
environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", BindingVersion: 1},
|
|
}
|
|
if err := cacheWorkCovers(ctx, store, browser, creator.SourceCompetitor, competitor.ID); err != nil {
|
|
t.Fatalf("cache covers: %v", err)
|
|
}
|
|
path, err := store.GetWorkCover(ctx, work.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || filepath.Ext(path) != ".png" || string(data) != "cover-bytes" {
|
|
t.Fatalf("cover: path=%q data=%q err=%v", path, data, err)
|
|
}
|
|
|
|
// 网关故障 → 报错且不落库(新增一件缺封面作品,否则无可拉取对象直接成功)
|
|
if _, _, err := store.UpsertWork(ctx, creator.WorkInput{
|
|
Platform: creator.PlatformDouyin, WorkKey: "cover-work-fail-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
SourceType: creator.SourceCompetitor, SourceID: competitor.ID, Title: "缺封面作品", CoverURL: "https://p.example/missing.png",
|
|
}, time.Now().UTC()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 网关故障 → 报错且不落库:用全新竞品保证其全部作品拉取失败
|
|
other, err := store.CreateCompetitor(ctx, creator.CompetitorInput{
|
|
Platform: creator.PlatformDouyin, PlatformAccountKey: "sec_uid_covers_fail_" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
UniqueID: "covers_fail_unique", Nickname: "失败竞品", HomepageURL: "https://www.douyin.com/user/sec_uid_fail",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := store.UpsertWork(ctx, creator.WorkInput{
|
|
Platform: creator.PlatformDouyin, WorkKey: "cover-work-fail-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
SourceType: creator.SourceCompetitor, SourceID: other.ID, Title: "缺封面作品", CoverURL: "https://p.example/missing.png",
|
|
}, time.Now().UTC()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
failCleanup := startFakeChannelGateway(t, 24, "creator-coverage-key-0024", map[string]string{
|
|
"POST /v1/browsers/browser/douyin/image": `{"status":500,"error":"boom"}`,
|
|
})
|
|
defer failCleanup()
|
|
browser.gateway = hub.Gateway{ID: 24, Name: "gw-test"}
|
|
if err := cacheWorkCovers(ctx, store, browser, creator.SourceCompetitor, other.ID); err == nil {
|
|
t.Fatal("failing gateway must surface an error")
|
|
}
|
|
missing, err := store.ListWorksMissingCover(ctx, creator.SourceCompetitor, other.ID)
|
|
if err != nil || len(missing) != 1 {
|
|
t.Fatalf("failed fetch must not save covers: missing=%d err=%v", len(missing), err)
|
|
}
|
|
}
|
|
|
|
func openCreatorIntegrationStoreForAPITest(t *testing.T, databaseURL string) (*creator.Store, *account.Store, context.Context) {
|
|
t.Helper()
|
|
t.Setenv("CREATOR_COVER_DIR", t.TempDir())
|
|
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
|
|
ctx := context.Background()
|
|
accountStore, err := account.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() })
|
|
creatorStore, err := creator.Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = creatorStore.Close() })
|
|
if err := creatorStore.EnsureSchema(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
creatorStore.SetSecretBridge(nil)
|
|
return creatorStore, accountStore, ctx
|
|
}
|
|
|
|
// TestNewAnonymousBrowserCreateFailureCleanup 驱动匿名浏览器创建失败路径:
|
|
// 网关 create 返回冲突 → cleanup 对账删除残留容器 → 错误上抛、名额释放。
|
|
func TestNewAnonymousBrowserCreateFailureCleanup(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)
|
|
hubStore, err := hub.Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = hubStore.Close() })
|
|
|
|
var cleanupCalls int
|
|
var createdAlias string
|
|
anonChannelStore := newMemoryChannelStore(hub.Gateway{ID: 91, Name: "gw-1", AccessKey: "anon-cleanup-access-key-00091"})
|
|
SetGatewayChannelStore(anonChannelStore)
|
|
gatewayServer := (&gatewayChannelTestServer{store: anonChannelStore, URL: startChannelServer(t, anonChannelStore)})
|
|
anonDeadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(anonDeadline) && sessionByGateway(91) == nil {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
connectFakeGateway(t, gatewayServer.URL, "anon-cleanup-access-key-00091", func(task taskFrame) *TaskResult {
|
|
mu := &sync.Mutex{}
|
|
_ = mu
|
|
switch {
|
|
case task.Method == "POST" && task.Path == "/v1/browsers":
|
|
var payload struct {
|
|
Alias string `json:"alias"`
|
|
}
|
|
_ = json.Unmarshal(task.Payload, &payload)
|
|
createdAlias = payload.Alias
|
|
return &TaskResult{Status: 201, Body: []byte(`{"alias":"anon-other","binding_version":1}`)}
|
|
case task.Method == "GET" && task.Path == "/v1/browsers":
|
|
// 对账列表:返回刚创建失败残留的匿名容器
|
|
return &TaskResult{Status: 200, Body: []byte(`[{"id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","alias":"` + createdAlias + `","state":"running","binding_version":1}]`)}
|
|
case task.Method == "DELETE" && strings.HasPrefix(task.Path, "/v1/browsers/"):
|
|
cleanupCalls++
|
|
return &TaskResult{Status: 204}
|
|
default:
|
|
return &TaskResult{Status: 404, Error: "unexpected " + task.Method + " " + task.Path}
|
|
}
|
|
})
|
|
if _, err := hubStore.CreateGateway(ctx, "gw-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = newAnonymousBrowser(ctx, hubStore)
|
|
if err == nil {
|
|
t.Fatal("invalid create generation must fail")
|
|
}
|
|
if cleanupCalls != 1 {
|
|
t.Fatalf("cleanup must reconcile the leftover container: calls=%d", cleanupCalls)
|
|
}
|
|
// 名额已释放:可再次非阻塞占满全部槽位
|
|
for i := 0; i < cap(anonymousBrowserSlots); i++ {
|
|
select {
|
|
case anonymousBrowserSlots <- struct{}{}:
|
|
default:
|
|
t.Fatalf("slot %d not released after failure", i)
|
|
}
|
|
<-anonymousBrowserSlots
|
|
}
|
|
}
|