feat: unify account environments and work collection
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
)
|
||||
|
||||
func TestAccountEnvironmentViewPreservesDatabaseOrderingID(t *testing.T) {
|
||||
view := accountEnvironmentView(hub.EnvironmentContext{Env: hub.Env{ID: 42, Alias: "env-order"}})
|
||||
if view["id"] != int64(42) {
|
||||
t.Fatalf("account environment is missing its shared ordering ID: %#v", view)
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func PhaseAError(c fiber.Ctx, err error) error {
|
||||
func accountEnvironmentView(environment hub.EnvironmentContext) map[string]any {
|
||||
scheduleStatus, scheduleBlockReason := environmentScheduleReadiness(environment)
|
||||
return map[string]any{
|
||||
"alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway,
|
||||
"id": environment.ID, "alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway,
|
||||
"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID,
|
||||
"runtime_cleanup_pending": environment.RuntimeCleanupPending,
|
||||
"network_exit_id": environment.Exit.ID, "network_exit_health": environment.Exit.HealthStatus,
|
||||
|
||||
@@ -95,8 +95,8 @@ func TestPendingEnvironmentLoginRoutesKeepBrowserIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
pending, err := hs.ListPendingEnvironments(ctx)
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("pending browser created account: %v %v", pending, err)
|
||||
if err != nil || len(pending) != 1 || pending[0].ID != env.ID || env.ID < 1 {
|
||||
t.Fatalf("pending browser created account or lost its ordering ID: %v %v", pending, err)
|
||||
}
|
||||
logged = true
|
||||
response := do(app, http.MethodPost, route+"/verify", `{}`)
|
||||
@@ -108,7 +108,7 @@ func TestPendingEnvironmentLoginRoutesKeepBrowserIdentity(t *testing.T) {
|
||||
t.Fatalf("login response: %#v %v", result, err)
|
||||
}
|
||||
bound, err := hs.GetEnvironmentContext(ctx, alias)
|
||||
if err != nil || bound.AccountID != result.AccountID || bound.RuntimeID != "login-runtime" || bound.ProfileID != alias || bound.Fingerprint.Seed != env.Fingerprint.Seed {
|
||||
if err != nil || bound.AccountID != result.AccountID || bound.RuntimeID != "login-runtime" || bound.ProfileID != alias || bound.Fingerprint.Seed != env.Fingerprint.Seed || bound.ID != env.ID {
|
||||
t.Fatalf("binding changed browser: %#v %v", bound, err)
|
||||
}
|
||||
response = do(app, http.MethodPost, route+"/verify", `{}`)
|
||||
|
||||
@@ -283,6 +283,7 @@ func TestControlPlaneRegisteredRouteMatrix(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{http.MethodPost, "/api/not-registered", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/phase-a/accounts", http.StatusNotFound},
|
||||
{http.MethodDelete, "/api/not-registered", http.StatusNotFound},
|
||||
} {
|
||||
t.Run("unregistered "+route.method, func(t *testing.T) {
|
||||
@@ -359,7 +360,6 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase {
|
||||
{http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound},
|
||||
{http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound},
|
||||
|
||||
{http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest},
|
||||
{http.MethodGet, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusOK},
|
||||
{http.MethodGet, "/api/phase-a/accounts/:id", "/api/phase-a/accounts/missing", "", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/phase-a/accounts/:id/pause", "/api/phase-a/accounts/missing/pause", "", http.StatusNotFound},
|
||||
|
||||
@@ -234,8 +234,10 @@ func (s *Store) ownedWorkStats(ctx context.Context) (map[string]ownedWorkStat, e
|
||||
|
||||
// AccountCollectionStatus 自有账号采集状态(checkpoint 形态,对齐竞品的 sync_status 展示语义)。
|
||||
type AccountCollectionStatus struct {
|
||||
Works AccountCheckpointStatus `json:"works"`
|
||||
Comments AccountCheckpointStatus `json:"comments"`
|
||||
WorkCount int64 `json:"work_count"`
|
||||
AwemeCount *int64 `json:"aweme_count"`
|
||||
Works AccountCheckpointStatus `json:"works"`
|
||||
Comments AccountCheckpointStatus `json:"comments"`
|
||||
}
|
||||
|
||||
type AccountCheckpointStatus struct {
|
||||
@@ -254,6 +256,20 @@ func (s *Store) GetAccountCollectionStatus(ctx context.Context, accountID string
|
||||
return AccountCollectionStatus{}, ErrInvalid
|
||||
}
|
||||
status := AccountCollectionStatus{}
|
||||
var total sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM creator_work WHERE source_type = $1 AND source_id = $2),
|
||||
(SELECT metric.aweme_count FROM creator_account_metric metric
|
||||
JOIN social_account account ON account.id = metric.account_id
|
||||
WHERE account.account_id = $2 AND metric.aweme_count IS NOT NULL
|
||||
ORDER BY metric.collected_at DESC LIMIT 1)`, SourceOwned, accountID).Scan(&status.WorkCount, &total)
|
||||
if err != nil {
|
||||
return AccountCollectionStatus{}, databaseError(err)
|
||||
}
|
||||
if total.Valid {
|
||||
status.AwemeCount = &total.Int64
|
||||
}
|
||||
for _, kind := range []struct {
|
||||
name string
|
||||
pointer *AccountCheckpointStatus
|
||||
|
||||
@@ -403,7 +403,9 @@ func (s *Store) CollectSource(ctx context.Context, platform, sourceType, sourceI
|
||||
return err
|
||||
}
|
||||
report.WorksSeen++
|
||||
if work.PublishedAt != nil && work.PublishedAt.Before(report.WindowStart) {
|
||||
// Owned accounts keep their entire work library; the lookback window
|
||||
// still bounds competitor works and the later comments phase.
|
||||
if sourceType == SourceCompetitor && work.PublishedAt != nil && work.PublishedAt.Before(report.WindowStart) {
|
||||
continue
|
||||
}
|
||||
work.Platform, work.SourceType, work.SourceID = platform, sourceType, sourceID
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package creator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ownedHistoryCollector struct {
|
||||
pages map[string]WorkPage
|
||||
workCursors []string
|
||||
commentWorks []string
|
||||
}
|
||||
|
||||
func (c *ownedHistoryCollector) ListWorks(_ context.Context, _, cursor string) (WorkPage, error) {
|
||||
c.workCursors = append(c.workCursors, cursor)
|
||||
page, ok := c.pages[cursor]
|
||||
if !ok {
|
||||
return WorkPage{}, fmt.Errorf("unexpected cursor %q", cursor)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (c *ownedHistoryCollector) ListTopLevelComments(_ context.Context, workKey, _ string) (CommentPage, error) {
|
||||
c.commentWorks = append(c.commentWorks, workKey)
|
||||
return CommentPage{}, nil
|
||||
}
|
||||
|
||||
func TestCreatorPostgresOwnedCollectionIncludesHistoryAndProgress(t *testing.T) {
|
||||
store, accounts, ctx := openCreatorIntegrationStore(t)
|
||||
accountID := createIntegrationAccount(t, ctx, accounts, fmt.Sprint(time.Now().UnixNano()))
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
settings, err := store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := now.Add(-time.Duration(settings.LookbackDays+365) * 24 * time.Hour)
|
||||
recent := now.Add(-time.Hour)
|
||||
pages := map[string]WorkPage{"": {NextCursor: "history", HasMore: true}}
|
||||
for i := 0; i < 28; i++ {
|
||||
published := old
|
||||
if i == 0 {
|
||||
published = recent
|
||||
}
|
||||
work := WorkInput{WorkKey: fmt.Sprintf("owned-history-%02d", i), Title: fmt.Sprintf("作品 %d", i), PublishedAt: &published, PublishedAtStatus: "verified"}
|
||||
cursor := "history"
|
||||
if i < 14 {
|
||||
cursor = ""
|
||||
}
|
||||
page := pages[cursor]
|
||||
page.Items = append(page.Items, work)
|
||||
pages[cursor] = page
|
||||
}
|
||||
collector := &ownedHistoryCollector{pages: pages}
|
||||
total := int64(30)
|
||||
if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: now.Add(-time.Minute), AwemeCount: &total}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A newer snapshot lacking aweme_count must not erase the known platform total.
|
||||
followers := int64(100)
|
||||
if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: now, FollowerCount: &followers}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := store.CollectSource(ctx, PlatformDouyin, SourceOwned, accountID, collector, now)
|
||||
if err != nil || !report.PaginationComplete || report.WorksSeen != 28 || report.WorksSaved != 28 {
|
||||
t.Fatalf("full history collection: report=%+v err=%v", report, err)
|
||||
}
|
||||
if len(collector.workCursors) != 2 || collector.workCursors[1] != "history" {
|
||||
t.Fatalf("history pagination: %v", collector.workCursors)
|
||||
}
|
||||
// History is listed, but comments remain limited to the configured window.
|
||||
if len(collector.commentWorks) != 1 || collector.commentWorks[0] != "owned-history-00" {
|
||||
t.Fatalf("historical comments should not be scheduled: %v", collector.commentWorks)
|
||||
}
|
||||
first, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: accountID}, 1, 25)
|
||||
if err != nil || len(first.Data) != 25 || first.Total != 28 || !first.HasNext {
|
||||
t.Fatalf("first history page: page=%+v err=%v", first, err)
|
||||
}
|
||||
second, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: accountID}, 2, 25)
|
||||
if err != nil || len(second.Data) != 3 || second.Total != 28 || second.HasNext {
|
||||
t.Fatalf("last history page: page=%+v err=%v", second, err)
|
||||
}
|
||||
status, err := store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Read JSON fields to keep the regression test compilable before implementation.
|
||||
assertCollectionCounts(t, status, 28, &total)
|
||||
views, err := store.ListAccountMonitorViews(ctx)
|
||||
if err != nil || len(views) != 1 || views[0].WorkCount != 28 || views[0].AwemeCount == nil || *views[0].AwemeCount != total {
|
||||
t.Fatalf("list/detail count consistency: views=%+v err=%v", views, err)
|
||||
}
|
||||
// A full subsequent run must not count duplicate works twice.
|
||||
if _, err := store.CollectSource(ctx, PlatformDouyin, SourceOwned, accountID, collector, now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCollectionCounts(t, status, 28, &total)
|
||||
}
|
||||
|
||||
func assertCollectionCounts(t *testing.T, status AccountCollectionStatus, collected int64, total *int64) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(fields["work_count"]) != fmt.Sprint(collected) {
|
||||
t.Fatalf("collected count: status=%s want=%d", data, collected)
|
||||
}
|
||||
want := "null"
|
||||
if total != nil {
|
||||
want = fmt.Sprint(*total)
|
||||
}
|
||||
if string(fields["aweme_count"]) != want {
|
||||
t.Fatalf("platform total: status=%s want=%s", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorPostgresCollectionProgressDistinguishesUnknownAndZero(t *testing.T) {
|
||||
store, accounts, ctx := openCreatorIntegrationStore(t)
|
||||
accountID := createIntegrationAccount(t, ctx, accounts, fmt.Sprint(time.Now().UnixNano()))
|
||||
status, err := store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil || status.Works.Status != "pending" {
|
||||
t.Fatalf("pending status: %+v err=%v", status, err)
|
||||
}
|
||||
assertCollectionCounts(t, status, 0, nil)
|
||||
zero := int64(0)
|
||||
if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: time.Now().UTC(), AwemeCount: &zero}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCollectionCounts(t, status, 0, &zero)
|
||||
}
|
||||
@@ -462,7 +462,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
|
||||
var runtimeID, runtimeNetworkID, runtimeNodeID, cleanupRuntimeID, cleanupNetworkID sql.NullString
|
||||
var cleanupBindingVersion sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT environment.alias, environment.name, gateway.name,
|
||||
SELECT environment.id, environment.alias, environment.name, gateway.name,
|
||||
environment.fingerprint, environment.created_at, COALESCE(account.account_id, ''), COALESCE(account.status, ''), environment.profile_id,
|
||||
environment.version,
|
||||
environment.runtime_cleanup_pending, environment.runtime_cleanup_binding_version,
|
||||
@@ -479,7 +479,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
|
||||
JOIN gateway ON gateway.id = environment.gateway_id
|
||||
LEFT JOIN network_exit network ON network.id = environment.exit_id
|
||||
WHERE environment.alias = $1`, alias).
|
||||
Scan(&result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt,
|
||||
Scan(&result.ID, &result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt,
|
||||
&result.AccountID, &result.AccountStatus, &result.ProfileID,
|
||||
&result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion,
|
||||
&cleanupRuntimeID, &cleanupNetworkID,
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *Store) CreateStandaloneEnv(ctx context.Context, gateway string, fingerp
|
||||
}
|
||||
|
||||
func (s *Store) ListPendingEnvironments(ctx context.Context) ([]EnvironmentContext, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT alias FROM browser_env WHERE account_id IS NULL ORDER BY created_at,id`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT alias FROM browser_env WHERE account_id IS NULL ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending browser environments: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPendingEnvironmentDatabaseIDOrder(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 := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL))
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.CreateGateway(ctx, "order-gateway", "http://127.0.0.1:8090", "order-gateway-test-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := store.CreateStandaloneEnv(ctx, "order-gateway", Fingerprint{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.CreateStandaloneEnv(ctx, "order-gateway", Fingerprint{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.ID < 1 || second.ID <= first.ID {
|
||||
t.Fatalf("environment IDs must increase: first=%d second=%d", first.ID, second.ID)
|
||||
}
|
||||
pending, err := store.ListPendingEnvironments(ctx)
|
||||
if err != nil || len(pending) != 2 || pending[0].ID != second.ID || pending[1].ID != first.ID {
|
||||
t.Fatalf("pending environments must be newest first: %#v err=%v", pending, err)
|
||||
}
|
||||
data, err := json.Marshal(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil || response.ID != second.ID {
|
||||
t.Fatalf("database ID missing from environment JSON: %s err=%v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,7 @@ type Gateway struct {
|
||||
|
||||
// Env 是一个浏览器环境;浏览器安装和默认运行时由 gateway 宿主机配置。
|
||||
type Env struct {
|
||||
ID int64 `json:"id"`
|
||||
Alias string `json:"alias"`
|
||||
Name string `json:"name"`
|
||||
Gateway string `json:"gateway"`
|
||||
@@ -452,7 +453,7 @@ func (s *Store) DeleteGateway(ctx context.Context, name string) error {
|
||||
|
||||
func (s *Store) ListEnvs(ctx context.Context) ([]Env, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
SELECT environment.id, environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
FROM browser_env environment
|
||||
JOIN gateway ON gateway.id = environment.gateway_id
|
||||
ORDER BY environment.created_at, environment.alias`)
|
||||
@@ -476,7 +477,7 @@ func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) {
|
||||
return Env{}, ErrInvalid
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
SELECT environment.id, environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
FROM browser_env environment
|
||||
JOIN gateway ON gateway.id = environment.gateway_id
|
||||
WHERE environment.alias = $1`, alias)
|
||||
@@ -536,7 +537,7 @@ func (s *Store) DeleteAccountEnvironment(ctx context.Context, accountID string)
|
||||
func scanEnv(rows *sql.Rows) (Env, error) {
|
||||
var env Env
|
||||
var encoded []byte
|
||||
if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &encoded, &env.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&env.ID, &env.Alias, &env.Name, &env.Gateway, &encoded, &env.CreatedAt); err != nil {
|
||||
return Env{}, errors.New("decode browser env")
|
||||
}
|
||||
if len(encoded) > 0 {
|
||||
|
||||
@@ -237,9 +237,17 @@ func (c CreatorCollector) ListWorks(ctx context.Context, accountKey, cursor stri
|
||||
maxCursor = cursor
|
||||
}
|
||||
query := douyinAPIQuery()
|
||||
query.Set("channel", "channel_pc_web")
|
||||
query.Set("sec_user_id", accountKey)
|
||||
query.Set("count", "20")
|
||||
query.Set("count", "18")
|
||||
query.Set("max_cursor", maxCursor)
|
||||
query.Set("locate_query", "false")
|
||||
query.Set("show_live_replay_strategy", "1")
|
||||
query.Set("need_time_list", "1")
|
||||
query.Set("time_list_query", "0")
|
||||
query.Set("cut_version", "1")
|
||||
query.Set("publish_video_strategy_type", "2")
|
||||
query.Set("from_user_page", "0")
|
||||
response, err := c.Browser.Get(ctx, worksEndpoint+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return creator.WorkPage{}, err
|
||||
|
||||
@@ -101,6 +101,40 @@ func TestCreatorCollectorGuardsAndCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorksUsesCurrentDouyinWebPaginationParameters(t *testing.T) {
|
||||
browser := &collectorBrowser{response: Response{Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"max_cursor":0,"aweme_list":[]}`)}}
|
||||
collector := CreatorCollector{Browser: browser, AccountKey: "MS4wLjABAAAAkey"}
|
||||
if _, err := collector.ListWorks(context.Background(), "ignored", "1746702477000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(browser.url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
want := map[string]string{
|
||||
"channel": "channel_pc_web",
|
||||
"sec_user_id": "MS4wLjABAAAAkey",
|
||||
"count": "18",
|
||||
"max_cursor": "1746702477000",
|
||||
"locate_query": "false",
|
||||
"show_live_replay_strategy": "1",
|
||||
"need_time_list": "1",
|
||||
"time_list_query": "0",
|
||||
"cut_version": "1",
|
||||
"publish_video_strategy_type": "2",
|
||||
"from_user_page": "0",
|
||||
}
|
||||
if parsed.Path != "/aweme/v1/web/aweme/post/" {
|
||||
t.Fatalf("unexpected endpoint: %s", parsed.Path)
|
||||
}
|
||||
for key, value := range want {
|
||||
if query.Get(key) != value {
|
||||
t.Errorf("query %s = %q, want %q (full query: %s)", key, query.Get(key), value, query.Encode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCreatorCommentsPageAllowsEmptyComments(t *testing.T) {
|
||||
page, err := parseCreatorCommentsPage([]byte(`{"status_code":0,"has_more":false,"cursor":20,"comments":null}`))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user