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

209 lines
8.6 KiB
Go

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> <body>"(缺省 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
}