package api import ( "context" "encoding/base64" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "git.ipao.vip/rogee/creator-hub/internal/creator" hub "git.ipao.vip/rogee/creator-hub/internal/environment" "github.com/gofiber/fiber/v3" ) func TestCreatorHelperBranches(t *testing.T) { if got := flattenActionEvidence(map[string]string{}, "evidence", "text"); got != 1 { t.Fatalf("flatten string count = %d", got) } values := map[string]string{} if got := flattenActionEvidence(values, "evidence", map[string]any{"nested": "value", "empty": "", "number": 1}); got != 1 || values["evidence.nested"] != "value" { t.Fatalf("flatten map = %d, %#v", got, values) } if got := flattenActionEvidence(values, "evidence", []any{"ignored"}); got != 0 { t.Fatalf("flatten unsupported count = %d", got) } if _, err := verifyCreatorPlatformIdentity(context.Background(), "unsupported", hub.Gateway{}, hub.EnvironmentContext{}, "key"); !errors.Is(err, creator.ErrUnavailable) { t.Fatalf("unsupported identity platform = %v", err) } if _, _, err := newCreatorCollector(context.Background(), creator.PlatformDouyin, hub.Gateway{}, hub.EnvironmentContext{}, "", "target", "", creator.SourceCompetitor, "id"); !errors.Is(err, creator.ErrInvalid) { t.Fatalf("missing collector key = %v", err) } if _, err := decodeBase64("not-base64"); err == nil { t.Fatal("invalid base64 must fail") } encoded := base64.StdEncoding.EncodeToString([]byte("media")) if data, err := decodeBase64(encoded); err != nil || string(data) != "media" { t.Fatalf("decode media = %q, %v", data, err) } if err := validateDouyinCompetitor(creator.CompetitorInput{Platform: creator.PlatformDouyin}); err != nil { t.Fatal(err) } if err := validateDouyinCompetitor(creator.CompetitorInput{Platform: "unsupported"}); !errors.Is(err, creator.ErrInvalid) { t.Fatalf("unsupported competitor platform = %v", err) } } func TestCreatorAccountMetricSnapshotFailurePropagates(t *testing.T) { // 浏览器不可用(网关零值)时快照拉取失败必须向上返回错误,绝不静默吞掉; // 调度器对 syncCreatorOwned 的失败会记 warning 日志(runCreatorScheduleOnce)。 SetGatewayChannelStore(newMemoryChannelStore()) if err := recordCreatorAccountMetricSnapshot(context.Background(), nil, creatorGatewayBrowser{}, "account-1"); err == nil { t.Fatal("account metric snapshot failure must propagate error") } } func TestCreatorPageQueryValidation(t *testing.T) { app := fiber.New() app.Get("/", func(c fiber.Ctx) error { page, pageSize, enabled, err := creatorPageQuery(c) if err != nil { return creatorError(c, err) } return c.JSON(map[string]any{"page": page, "page_size": pageSize, "enabled": enabled}) }) for _, query := range []string{"", "?page=2&page_size=10&enabled=false"} { response, err := app.Test(httptest.NewRequest(http.MethodGet, "/"+query, nil)) if err != nil || response.StatusCode != http.StatusOK { t.Fatalf("valid query %s: %d %v", query, response.StatusCode, err) } } for _, query := range []string{"?page=bad"} { response, err := app.Test(httptest.NewRequest(http.MethodGet, "/"+query, nil)) if err != nil || response.StatusCode != http.StatusBadRequest { t.Fatalf("invalid query %s: %d %v", query, response.StatusCode, err) } } } func TestCreatorControlPlaneGuards(t *testing.T) { ctx := context.Background() if _, _, err := newCreatorCollector(ctx, "unsupported", hub.Gateway{}, hub.EnvironmentContext{}, "viewer", "target", "", creator.SourceOwned, "id"); !errors.Is(err, creator.ErrUnavailable) { t.Fatalf("unsupported collector platform = %v", err) } } func TestCreatorSchedulerAndPreviewGuards(t *testing.T) { ctx := context.Background() checks := []struct { name string call func() error }{ {"verify account", func() error { _, err := verifyCreatorAccount(ctx, nil, nil, nil, "account"); return err }}, {"login QR", func() error { _, err := creatorLoginQRCode(ctx, nil, nil, nil, "account"); return err }}, {"preview competitor", func() error { _, err := previewDouyinCompetitor(ctx, nil, nil, nil, "account", creator.CompetitorInput{}) return err }}, {"sync competitor", func() error { _, err := syncCreatorCompetitor(ctx, nil, nil, "competitor"); return err }}, {"sync due competitor", func() error { _, err := syncCreatorCompetitorDue(ctx, nil, nil, "competitor") return err }}, {"sync owned", func() error { return syncCreatorOwned(ctx, nil, nil, nil, "account", time.Now()) }}, {"select collection account", func() error { _, err := creatorCollectionAccount(ctx, nil, nil, nil, creator.PlatformDouyin) return err }}, } for _, check := range checks { if err := check.call(); err == nil { t.Fatalf("%s unexpectedly succeeded", check.name) } } } func TestCreatorGatewayBrowserHistoryAndMedia(t *testing.T) { cleanup := startFakeChannelGateway(t, 1, "creator-helper-key-0001", map[string]string{ "POST /v1/browsers/browser/douyin/messages": "200 " + `{"status":"succeeded","account_uid":"account","history_source":"douyin","messages":[]}`, "POST /v1/browsers/browser/douyin/media": "200 " + `{"status":200,"content_type":"video/mp4","body_base64":"` + base64.StdEncoding.EncodeToString([]byte("video")) + `"}`, }) defer cleanup() browser := creatorGatewayBrowser{gateway: hub.Gateway{ID: 1, Name: "gw-test"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-dddddddddddddddddddddddddddddddd", BindingVersion: 1}} history, err := browser.MessageHistory(context.Background(), "account", "peer", "", 10) if err != nil || history.Status != "succeeded" || history.HistorySource != "douyin" { t.Fatalf("history = %+v, %v", history, err) } if _, err := browser.MessageHistory(context.Background(), "", "peer", "", 10); err == nil { t.Fatal("empty history identity must fail") } path := filepath.Join(t.TempDir(), "media.mp4") if err := browser.Media(context.Background(), "https://video.example/media", path); err != nil { t.Fatal(err) } if data, err := os.ReadFile(path); err != nil || string(data) != "video" { t.Fatalf("media output = %q, %v", data, err) } } func TestCreatorGatewayBrowserIdentity(t *testing.T) { cleanup := startFakeChannelGateway(t, 2, "creator-helper-key-0002", map[string]string{ "POST /v1/browsers/browser/douyin/identity": `{"uid":"verified-uid"}`, }) defer cleanup() browser := creatorGatewayBrowser{ gateway: hub.Gateway{ID: 2, Name: "gw-test"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-dddddddddddddddddddddddddddddddd", BindingVersion: 1}, } uid, err := browser.Identity(context.Background(), "expected-key") if err != nil || uid != "verified-uid" { t.Fatalf("identity = %q, %v", uid, err) } } // startFakeChannelGateway 启动平台侧会话服务器并接入假网关,返回收尾函数。 // routes 值为 " "(缺省 status=200);onTask 可选用于断言收到的请求帧。 func startFakeChannelGateway(t *testing.T, gatewayID int64, accessKey string, routes map[string]string, onTask ...func(task taskFrame)) (cleanup func()) { t.Helper() store := newMemoryChannelStore(hub.Gateway{ID: gatewayID, Name: "gw-test", AccessKey: accessKey}) SetGatewayChannelStore(store) server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)}) var fake *fakeChannelGateway fake = connectFakeGateway(t, server.URL, accessKey, func(task taskFrame) *TaskResult { for _, hook := range onTask { hook(task) } route, ok := routes[task.Method+" "+task.Path] if !ok { return &TaskResult{Status: 404, Error: "no route for " + task.Method + " " + task.Path} } if route == "disconnect" { // 模拟网关在任务处理中断开出站连接:会话失败,任务由平台终态化。 _ = fake.conn.Close() return nil } status, body := 200, route if space := strings.Index(route, " "); space > 0 && isAllDigits(route[:space]) { status = int(routeInt(route[:space])) body = route[space+1:] } return &TaskResult{Status: status, Body: []byte(body)} }) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && sessionByGateway(gatewayID) == nil { time.Sleep(5 * time.Millisecond) } return func() { } } func isAllDigits(raw string) bool { for _, c := range raw { if c < '0' || c > '9' { return false } } return len(raw) > 0 } func routeInt(raw string) int64 { value, _ := strconv.ParseInt(raw, 10, 32) return value }