- 迁移 043 一次到位:runtime_instance/environment_binding 并入 browser_env(账号即环境)、 credential_reference 并入 social_account、work_source/metric_plan 并入 creator_work、 sync 租约并入 creator_collection_checkpoint(kind='sync')、audit_event 瘦列、 creator_settings 去转写列、TRUNCATE 账号域(竞品采集数据无损保留) - 统一迁移 runner:environment.Open 串起 001 账号基座 + hub 链 + 038 creator 版本回填 (v+1000) + creator 1017-1042 + 043;account/creator Open 不再迁移 - 三域 store SQL 全量适配:CreateBoundEnv 就绪门禁、SetRuntimeCleanupPending fence 语义(未知代 MissingRuntimeID 允许登记并释放活跃实例)、metric_plan 列并入 work、 checkpoint sync 租约、deletion 链路重写 - api 适配:envView 去 runtime_instance_id、audit filter 去任务列、browser_env 单表查询 - 测试:迁移 043 形态探针、迁移不可变映射新路径、legacy 迁移重放子测试重写、 fence/出口生命周期/单来源 upsert 语义覆盖;CREATORHUB_POSTGRES_TEST_URL 全绿, environment 70%/creator 67.5% 覆盖率,tsc 通过
265 lines
10 KiB
Go
265 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
|
douyin "git.ipao.vip/rogee/creator-hub/internal/platform/douyin"
|
|
)
|
|
|
|
func testRunnableEnvironment() hub.EnvironmentContext {
|
|
return hub.EnvironmentContext{
|
|
Env: hub.Env{Alias: "account-1"},
|
|
AccountID: "account-1",
|
|
AccountStatus: "active",
|
|
AuthorizationStatus: "authorized",
|
|
RuntimeID: "runtime-1",
|
|
RuntimeNetworkID: "network-1",
|
|
Exit: hub.NetworkExit{ID: "exit-1", HealthStatus: "healthy"},
|
|
BindingVersion: 1,
|
|
}
|
|
}
|
|
|
|
func TestPurgeAccountProfileTreatsMissingRuntimeAsIdempotent(t *testing.T) {
|
|
environment := testRunnableEnvironment()
|
|
for _, status := range []int{http.StatusNoContent, http.StatusNotFound} {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete || r.URL.Path != "/v1/browsers/account-1" {
|
|
t.Fatalf("purge request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
w.WriteHeader(status)
|
|
}))
|
|
if err := purgeAccountProfile(context.Background(), hub.Gateway{Endpoint: server.URL}, environment); err != nil {
|
|
t.Errorf("status %d: %v", status, err)
|
|
}
|
|
server.Close()
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("gateway failure"))
|
|
}))
|
|
if err := purgeAccountProfile(context.Background(), hub.Gateway{Endpoint: server.URL}, environment); err == nil {
|
|
t.Fatal("gateway rejection was reported as successful")
|
|
}
|
|
server.Close()
|
|
if err := purgeAccountProfile(context.Background(), hub.Gateway{Endpoint: "http://127.0.0.1:1"}, environment); err == nil {
|
|
t.Fatal("unreachable gateway was reported as successful")
|
|
}
|
|
}
|
|
|
|
func TestCreatorGatewayBrowserOperationsValidateResponses(t *testing.T) {
|
|
response := map[string]string{
|
|
"identity": `{"uid":"123"}`,
|
|
"messages": `{"status":"succeeded","history_source":"live","history_cursor":"next","account_uid":"123","messages":[]}`,
|
|
"get": `{"status":200,"body":"ok"}`,
|
|
"resolve": `{"url":"https://www.douyin.com/video/1"}`,
|
|
"media": `{"status":200,"content_type":"video/mp4","body_base64":"aGk="}`,
|
|
"login-qr": `{"content_type":"image/png","body_base64":"aGk=","qr_detected":true}`,
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
key := ""
|
|
switch r.URL.Path {
|
|
case "/v1/browsers/account-1/douyin/identity":
|
|
key = "identity"
|
|
case "/v1/browsers/account-1/douyin/messages":
|
|
key = "messages"
|
|
case "/v1/browsers/account-1/douyin/get":
|
|
key = "get"
|
|
case "/v1/browsers/account-1/douyin/resolve":
|
|
key = "resolve"
|
|
case "/v1/browsers/account-1/douyin/media":
|
|
key = "media"
|
|
case "/v1/browsers/account-1/douyin/login-qr":
|
|
key = "login-qr"
|
|
default:
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(response[key]))
|
|
}))
|
|
defer server.Close()
|
|
|
|
environment := testRunnableEnvironment()
|
|
browser := creatorGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL}, environment: environment}
|
|
if uid, err := browser.Identity(context.Background(), "douyin-uid"); err != nil || uid != "123" {
|
|
t.Fatalf("identity = %q, %v", uid, err)
|
|
}
|
|
history, err := browser.MessageHistory(context.Background(), "123", "456", "", 20)
|
|
if err != nil || history.HistoryCursor != "next" {
|
|
t.Fatalf("history = %#v, %v", history, err)
|
|
}
|
|
if got, err := browser.Get(context.Background(), "https://www.douyin.com/video/1"); err != nil || got.Status != http.StatusOK || string(got.Body) != "ok" || got.Challenge != douyin.ChallengeNone {
|
|
t.Fatalf("get = %#v, %v", got, err)
|
|
}
|
|
if got, err := browser.Resolve(context.Background(), "https://www.douyin.com/video/1"); err != nil || got == "" {
|
|
t.Fatalf("resolve = %q, %v", got, err)
|
|
}
|
|
destination := filepath.Join(t.TempDir(), "video.mp4")
|
|
if err := browser.Media(context.Background(), "https://www.douyin.com/video/1", destination); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data, err := os.ReadFile(destination); err != nil || string(data) != "hi" {
|
|
t.Fatalf("media = %q, %v", data, err)
|
|
}
|
|
|
|
douyinBrowser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "test-token"}, environment: environment}
|
|
qr, err := douyinBrowser.LoginQR(context.Background())
|
|
if err != nil || qr.ContentType != "image/png" || !qr.QRDetected {
|
|
t.Fatalf("login QR = %#v, %v", qr, err)
|
|
}
|
|
if got, err := douyinBrowser.Get(context.Background(), "https://www.douyin.com/video/1"); err != nil || got.Status != http.StatusOK {
|
|
t.Fatalf("douyin get = %#v, %v", got, err)
|
|
}
|
|
if _, err := douyinBrowser.request(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestWriteCreatorMediaRejectsInvalidAndPublishesAtomically(t *testing.T) {
|
|
if err := writeCreatorMedia("", []byte("data")); err == nil {
|
|
t.Fatal("empty destination was accepted")
|
|
}
|
|
if err := writeCreatorMedia(filepath.Join(t.TempDir(), "video"), nil); err == nil {
|
|
t.Fatal("empty media was accepted")
|
|
}
|
|
if err := writeCreatorMedia(filepath.Join(t.TempDir(), "video"), make([]byte, maxCreatorMediaBytes+1)); err == nil {
|
|
t.Fatal("oversized media was accepted")
|
|
}
|
|
if err := writeCreatorMedia(filepath.Join(t.TempDir(), "missing", "video"), []byte("data")); err == nil {
|
|
t.Fatal("unwritable media destination was accepted")
|
|
}
|
|
destination := filepath.Join(t.TempDir(), "video")
|
|
if err := writeCreatorMedia(destination, []byte("data")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(destination)
|
|
if err != nil || string(data) != "data" {
|
|
t.Fatalf("published media = %q, err = %v", data, err)
|
|
}
|
|
}
|
|
|
|
func TestDouyinWorkKeyFromURLValidatesCanonicalVideoURLs(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
value string
|
|
want string
|
|
valid bool
|
|
}{
|
|
{value: "https://www.douyin.com/video/123", want: "123", valid: true},
|
|
{value: "https://www.douyin.com/video/", valid: false},
|
|
{value: "https://www.douyin.com/video/0", valid: false},
|
|
{value: "https://v.douyin.com/video/123", valid: false},
|
|
{value: "https://www.douyin.com/user/123", valid: false},
|
|
{value: "https://www.douyin.com/video/abc", valid: false},
|
|
} {
|
|
got, err := douyinWorkKeyFromURL(testCase.value)
|
|
if testCase.valid && (err != nil || got != testCase.want) {
|
|
t.Errorf("douyinWorkKeyFromURL(%q) = %q, %v", testCase.value, got, err)
|
|
}
|
|
if !testCase.valid && err == nil {
|
|
t.Errorf("douyinWorkKeyFromURL(%q) was accepted", testCase.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreatorGatewayBrowserRejectsMalformedGatewayData(t *testing.T) {
|
|
response := `{"uid":""}`
|
|
status := http.StatusOK
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(response))
|
|
}))
|
|
defer server.Close()
|
|
environment := testRunnableEnvironment()
|
|
browser := creatorGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL}, environment: environment}
|
|
if _, err := browser.Identity(context.Background(), "expected"); err == nil {
|
|
t.Fatal("empty identity was accepted")
|
|
}
|
|
status = http.StatusBadGateway
|
|
if _, err := browser.Identity(context.Background(), "expected"); err == nil {
|
|
t.Fatal("rejected identity was accepted")
|
|
}
|
|
status = http.StatusBadGateway
|
|
if _, err := browser.MessageHistory(context.Background(), "expected", "target", "", 1); err == nil {
|
|
t.Fatal("rejected message history was accepted")
|
|
}
|
|
if _, err := browser.Get(context.Background(), "target"); err == nil {
|
|
t.Fatal("rejected browser request was accepted")
|
|
}
|
|
if _, err := browser.Resolve(context.Background(), "target"); err == nil {
|
|
t.Fatal("rejected share resolution was accepted")
|
|
}
|
|
if err := browser.Media(context.Background(), "target", filepath.Join(t.TempDir(), "video")); err == nil {
|
|
t.Fatal("rejected media request was accepted")
|
|
}
|
|
douyinBrowser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "test-token"}, environment: environment}
|
|
if _, err := douyinBrowser.LoginQR(context.Background()); err == nil {
|
|
t.Fatal("rejected login QR request was accepted")
|
|
}
|
|
if _, err := douyinBrowser.Get(context.Background(), "target"); err == nil {
|
|
t.Fatal("rejected native browser request was accepted")
|
|
}
|
|
status = http.StatusOK
|
|
for _, args := range [][4]any{
|
|
{"", "target", "", 1},
|
|
{"expected", "", "", 1},
|
|
{"expected", "target", "cursor", 0},
|
|
{"expected", "target", "cursor", 201},
|
|
} {
|
|
if _, err := browser.MessageHistory(context.Background(), args[0].(string), args[1].(string), args[2].(string), args[3].(int)); err == nil {
|
|
t.Fatalf("invalid history args %#v were accepted", args)
|
|
}
|
|
}
|
|
|
|
response = `{`
|
|
if _, err := browser.Get(context.Background(), "target"); err == nil {
|
|
t.Fatal("malformed get response was accepted")
|
|
}
|
|
if _, err := browser.Resolve(context.Background(), "target"); err == nil {
|
|
t.Fatal("malformed resolve response was accepted")
|
|
}
|
|
if err := browser.Media(context.Background(), "target", filepath.Join(t.TempDir(), "video")); err == nil {
|
|
t.Fatal("malformed media response was accepted")
|
|
}
|
|
|
|
if _, err := douyinBrowser.LoginQR(context.Background()); err == nil {
|
|
t.Fatal("malformed login QR response was accepted")
|
|
}
|
|
invalid := environment
|
|
invalid.RuntimeID = ""
|
|
if _, err := (douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL}, environment: invalid}).request(); err == nil {
|
|
t.Fatal("non-running environment was accepted")
|
|
}
|
|
|
|
response = `{"status":200,"body":"ok","challenge":"unexpected"}`
|
|
if _, err := douyinBrowser.Get(context.Background(), "target"); err == nil {
|
|
t.Fatal("invalid challenge was accepted")
|
|
}
|
|
response = `{"content_type":"text/plain","body_base64":"aGk="}`
|
|
if _, err := douyinBrowser.LoginQR(context.Background()); err == nil {
|
|
t.Fatal("non-image login QR was accepted")
|
|
}
|
|
response = `{"content_type":"image/png","body_base64":"!!!"}`
|
|
if _, err := douyinBrowser.LoginQR(context.Background()); err == nil {
|
|
t.Fatal("invalid login QR base64 was accepted")
|
|
}
|
|
response = `{"content_type":"image/png","body_base64":""}`
|
|
if _, err := douyinBrowser.LoginQR(context.Background()); err == nil {
|
|
t.Fatal("empty login QR was accepted")
|
|
}
|
|
response = `{"status":200,"content_type":"text/plain","body_base64":"aGk="}`
|
|
if err := browser.Media(context.Background(), "target", filepath.Join(t.TempDir(), "video")); err == nil {
|
|
t.Fatal("non-video media was accepted")
|
|
}
|
|
response = `{"status":200,"content_type":"video/mp4","body_base64":"!!!"}`
|
|
if err := browser.Media(context.Background(), "target", filepath.Join(t.TempDir(), "video")); err == nil {
|
|
t.Fatal("invalid media base64 was accepted")
|
|
}
|
|
|
|
}
|