479 lines
17 KiB
React
479 lines
17 KiB
React
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { Refine } from "@refinedev/core";
|
|
import { MemoryRouter } from "react-router";
|
|
import { CreatorAccountsPage } from "./CreatorAccountsPage";
|
|
import { CreatorCompetitorsPage } from "./CreatorCompetitorsPage";
|
|
import { CreatorSettingsPage } from "./CreatorSettingsPage";
|
|
import { CreatorWorkbenchPage } from "./CreatorWorkbenchPage";
|
|
|
|
const profile = {
|
|
id: "account-a",
|
|
name: "主账号",
|
|
platform: "douyin",
|
|
platform_account_key: "sec-a",
|
|
authorization_status: "authorized",
|
|
runtime_status: "running",
|
|
business_status: "normal",
|
|
login_status: "logged_in",
|
|
real_name_status: "recorded",
|
|
big_account: false,
|
|
cooldown_seconds: 86400,
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
const settings = {
|
|
lookback_days: 30,
|
|
new_work_interval_seconds: 1800,
|
|
metric_initial_interval_seconds: 3600,
|
|
metric_multiplier: 2,
|
|
metric_max_interval_seconds: 86400,
|
|
metric_age_seconds: 2592000,
|
|
ai_provider: "",
|
|
ai_model: "",
|
|
ai_configured: false,
|
|
transcription_provider: "",
|
|
transcription_model: "",
|
|
transcription_configured: false,
|
|
};
|
|
|
|
function provider(overrides = {}) {
|
|
return {
|
|
getList: vi.fn(({ resource }) =>
|
|
Promise.resolve({
|
|
data:
|
|
resource === "creator-accounts"
|
|
? [profile]
|
|
: resource === "creator-competitors"
|
|
? []
|
|
: resource === "creator-works"
|
|
? []
|
|
: [],
|
|
total: resource === "creator-accounts" ? 1 : 0,
|
|
}),
|
|
),
|
|
creatorGet: vi.fn((path) =>
|
|
Promise.resolve(path === "/creator/settings" ? settings : []),
|
|
),
|
|
creatorUpdate: vi.fn().mockResolvedValue(settings),
|
|
creatorAction: vi.fn().mockResolvedValue({ ...profile, big_account: true }),
|
|
creatorCreate: vi.fn().mockResolvedValue({ id: "created", ...profile }),
|
|
create: vi.fn().mockResolvedValue({
|
|
data: {
|
|
id: "competitor-a",
|
|
account_id: "account-a",
|
|
platform: "douyin",
|
|
platform_account_key: "sec-b",
|
|
homepage_url: "https://www.douyin.com/user/sec-b",
|
|
enabled: true,
|
|
},
|
|
}),
|
|
getOne: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
getMany: vi.fn(),
|
|
getManyReference: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderPage(element, dataProvider) {
|
|
return render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<Refine dataProvider={dataProvider}>
|
|
<MemoryRouter>{element}</MemoryRouter>
|
|
</Refine>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("creator pages", () => {
|
|
it("loads and saves collection settings", async () => {
|
|
const dataProvider = provider();
|
|
renderPage(<CreatorSettingsPage />, dataProvider);
|
|
expect(await screen.findByText("采集设置")).toBeTruthy();
|
|
fireEvent.change(screen.getByLabelText("竞品回溯天数"), {
|
|
target: { value: "14" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
|
|
await waitFor(() =>
|
|
expect(dataProvider.creatorUpdate).toHaveBeenCalledWith(
|
|
"/creator/settings",
|
|
expect.objectContaining({ lookback_days: 14 }),
|
|
),
|
|
);
|
|
expect(dataProvider.creatorUpdate).not.toHaveBeenCalledWith(
|
|
"/creator/settings",
|
|
expect.objectContaining({ updated_at: expect.anything() }),
|
|
);
|
|
});
|
|
|
|
it("verifies account identity and toggles big-account mode", async () => {
|
|
const dataProvider = provider({
|
|
creatorAction: vi.fn((path) =>
|
|
Promise.resolve(
|
|
path.endsWith("/verify")
|
|
? { ...profile, login_status: "logged_in" }
|
|
: { ...profile, big_account: true },
|
|
),
|
|
),
|
|
});
|
|
renderPage(<CreatorAccountsPage />, dataProvider);
|
|
expect(
|
|
await screen.findByRole("button", { name: "开启大号模式" }),
|
|
).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "核验浏览器身份" }));
|
|
await waitFor(() =>
|
|
expect(dataProvider.creatorAction).toHaveBeenCalledWith(
|
|
"/creator/accounts/account-a/verify",
|
|
),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "开启大号模式" }));
|
|
await waitFor(() =>
|
|
expect(dataProvider.creatorAction).toHaveBeenCalledWith(
|
|
"/creator/accounts/account-a/big-account",
|
|
{ enabled: true },
|
|
),
|
|
);
|
|
expect(await screen.findByText("已开启大号模式。")).toBeTruthy();
|
|
});
|
|
|
|
it("shows retryable account and strategy loading failures", async () => {
|
|
const dataProvider = provider({
|
|
getList: vi.fn().mockRejectedValue(new Error("accounts offline")),
|
|
creatorGet: vi.fn().mockRejectedValue(new Error("creator offline")),
|
|
});
|
|
renderPage(<CreatorAccountsPage />, dataProvider);
|
|
expect(await screen.findByText("accounts offline")).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "重试" }));
|
|
await waitFor(() => expect(dataProvider.getList).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it("shows settings save conflicts", async () => {
|
|
const dataProvider = provider({
|
|
creatorUpdate: vi.fn().mockRejectedValue({
|
|
status: 409,
|
|
body: { reason: "version changed" },
|
|
}),
|
|
});
|
|
renderPage(<CreatorSettingsPage />, dataProvider);
|
|
expect(await screen.findByText("采集设置")).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
|
|
expect(await screen.findByText("冲突(409):设置保存失败")).toBeTruthy();
|
|
});
|
|
|
|
it("saves settings successfully", async () => {
|
|
const dataProvider = provider({
|
|
creatorUpdate: vi.fn().mockResolvedValue({}),
|
|
});
|
|
renderPage(<CreatorSettingsPage />, dataProvider);
|
|
expect(await screen.findByText("采集设置")).toBeTruthy();
|
|
fireEvent.click(screen.getByLabelText("已完成 AI 配置审批"));
|
|
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
|
|
expect(await screen.findByText("采集与 AI 配置已保存。")).toBeTruthy();
|
|
});
|
|
|
|
it("does not render stale data using another tab's shape", async () => {
|
|
const dataProvider = provider({
|
|
getList: vi.fn(({ resource }) =>
|
|
Promise.resolve({
|
|
data:
|
|
resource === "creator-comments"
|
|
? [
|
|
{
|
|
id: "comment-a",
|
|
author_name: "评论用户",
|
|
author_uid: "peer-a",
|
|
content: "你好",
|
|
platform: "douyin",
|
|
work_id: "work-a",
|
|
},
|
|
]
|
|
: resource === "creator-rules"
|
|
? [
|
|
{
|
|
id: "rule-a",
|
|
name: "规则",
|
|
include_keywords: ["你好"],
|
|
enabled: true,
|
|
topic: "主题",
|
|
},
|
|
]
|
|
: resource === "creator-leads"
|
|
? [
|
|
{
|
|
id: "lead-a",
|
|
comment: {
|
|
id: "comment-a",
|
|
author_name: "线索用户",
|
|
author_uid: "peer-a",
|
|
content: "线索",
|
|
},
|
|
rule_ids: ["rule-a"],
|
|
},
|
|
]
|
|
: [],
|
|
}),
|
|
),
|
|
});
|
|
renderPage(<CreatorWorkbenchPage />, dataProvider);
|
|
expect(await screen.findByText("评论用户")).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("tab", { name: "线索" }));
|
|
expect(await screen.findByText("线索用户", { exact: false })).toBeTruthy();
|
|
});
|
|
|
|
it("requires explicit stable competitor identity and shows empty works state", async () => {
|
|
const dataProvider = provider();
|
|
renderPage(<CreatorCompetitorsPage />, dataProvider);
|
|
expect(
|
|
await screen.findByRole("heading", { name: "加入监测" }),
|
|
).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "加入监测" }));
|
|
expect(dataProvider.create).not.toHaveBeenCalled();
|
|
expect(await screen.findByText("暂无符合条件的作品。")).toBeTruthy();
|
|
});
|
|
|
|
it("requires preview confirmation before importing a competitor", async () => {
|
|
const dataProvider = provider();
|
|
renderPage(<CreatorCompetitorsPage />, dataProvider);
|
|
await waitFor(() =>
|
|
expect(dataProvider.getList).toHaveBeenCalledWith(
|
|
expect.objectContaining({ resource: "creator-accounts" }),
|
|
),
|
|
);
|
|
fireEvent.change(screen.getByLabelText("主页 URL", { exact: false }), {
|
|
target: { value: "https://www.douyin.com/user/sec-b" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "解析链接预览" }));
|
|
expect(await screen.findByText("导入预览")).toBeTruthy();
|
|
await waitFor(() =>
|
|
expect(dataProvider.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
resource: "creator-competitor-preview",
|
|
variables: expect.objectContaining({
|
|
account_id: "account-a",
|
|
platform: "douyin",
|
|
platform_account_key: "sec-b",
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "确认预览内容" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "加入监测" }));
|
|
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledTimes(2));
|
|
expect(dataProvider.create).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
resource: "creator-competitors",
|
|
variables: expect.objectContaining({
|
|
platform: "douyin",
|
|
platform_account_key: "sec-b",
|
|
account_id: "account-a",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("parses and confirms a Xiaohongshu profile URL", async () => {
|
|
const dataProvider = provider();
|
|
renderPage(<CreatorCompetitorsPage />, dataProvider);
|
|
fireEvent.click(screen.getAllByRole("combobox")[0]);
|
|
fireEvent.click(screen.getByRole("option", { name: "小红书" }));
|
|
fireEvent.change(screen.getByLabelText("主页 URL", { exact: false }), {
|
|
target: {
|
|
value: "https://www.xiaohongshu.com/user/profile/xhs-b",
|
|
},
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "解析链接预览" }));
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getAllByText("xhs-b", { exact: false }).length,
|
|
).toBeGreaterThan(0),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "确认预览内容" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "加入监测" }));
|
|
await waitFor(() => expect(dataProvider.create).toHaveBeenCalled());
|
|
expect(dataProvider.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
resource: "creator-competitors",
|
|
variables: expect.objectContaining({
|
|
platform: "xiaohongshu",
|
|
platform_account_key: "xhs-b",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps account password out of the returned profile and exposes big-account action", async () => {
|
|
const dataProvider = provider();
|
|
renderPage(<CreatorAccountsPage />, dataProvider);
|
|
expect(await screen.findByRole("heading", { name: "主账号" })).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "开启大号模式" }));
|
|
await waitFor(() =>
|
|
expect(dataProvider.creatorAction).toHaveBeenCalledWith(
|
|
"/creator/accounts/account-a/big-account",
|
|
{ enabled: true },
|
|
),
|
|
);
|
|
expect(screen.getByLabelText("更新密码")).toBeTruthy();
|
|
});
|
|
|
|
it("allows switching private-message accounts across platforms", async () => {
|
|
const xhsProfile = {
|
|
...profile,
|
|
id: "account-xhs",
|
|
name: "小红书账号",
|
|
platform: "xiaohongshu",
|
|
platform_account_key: "xhs-a",
|
|
};
|
|
const conversation = {
|
|
id: "conversation-a",
|
|
account_id: profile.id,
|
|
platform: profile.platform,
|
|
peer_uid: "peer-a",
|
|
peer_name: "客户",
|
|
};
|
|
const dataProvider = provider({
|
|
getList: vi.fn(({ resource }) =>
|
|
Promise.resolve({
|
|
data:
|
|
resource === "creator-accounts"
|
|
? [profile, xhsProfile]
|
|
: resource === "creator-conversations"
|
|
? [conversation]
|
|
: [],
|
|
total: 1,
|
|
}),
|
|
),
|
|
creatorGet: vi.fn(() => Promise.resolve([])),
|
|
});
|
|
renderPage(<CreatorWorkbenchPage />, dataProvider);
|
|
fireEvent.click(await screen.findByRole("tab", { name: "私信" }));
|
|
expect(await screen.findByText("客户")).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("combobox", { name: "发送账号" }));
|
|
expect(screen.getByRole("option", { name: "小红书账号" })).toBeTruthy();
|
|
});
|
|
|
|
it("shows the private-message business update connection state", async () => {
|
|
const creatorSubscribe = vi.fn(() => new Promise(() => {}));
|
|
const conversation = {
|
|
id: "conversation-a",
|
|
account_id: profile.id,
|
|
platform: profile.platform,
|
|
peer_uid: "peer-a",
|
|
peer_name: "客户",
|
|
};
|
|
const dataProvider = provider({
|
|
creatorSubscribe,
|
|
getList: vi.fn(({ resource }) =>
|
|
Promise.resolve({
|
|
data:
|
|
resource === "creator-accounts"
|
|
? [profile]
|
|
: resource === "creator-conversations"
|
|
? [conversation]
|
|
: [],
|
|
total: resource === "creator-conversations" ? 1 : 0,
|
|
}),
|
|
),
|
|
});
|
|
renderPage(<CreatorWorkbenchPage />, dataProvider);
|
|
fireEvent.click(await screen.findByRole("tab", { name: "私信" }));
|
|
expect(await screen.findByText("后台更新连接中")).toBeTruthy();
|
|
expect(creatorSubscribe).toHaveBeenCalledWith(
|
|
"/creator/updates",
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it("subscribes to realtime updates on non-DM operational tabs", async () => {
|
|
const creatorSubscribe = vi.fn(() => new Promise(() => {}));
|
|
const dataProvider = provider({ creatorSubscribe });
|
|
renderPage(<CreatorWorkbenchPage />, dataProvider);
|
|
await waitFor(() => expect(creatorSubscribe).toHaveBeenCalledTimes(1));
|
|
fireEvent.click(await screen.findByRole("tab", { name: "事件监听" }));
|
|
await waitFor(() => expect(creatorSubscribe).toHaveBeenCalledTimes(2));
|
|
fireEvent.click(screen.getByRole("tab", { name: "操作记录" }));
|
|
await waitFor(() => expect(creatorSubscribe).toHaveBeenCalledTimes(3));
|
|
});
|
|
|
|
it("isolates private messages by account and confirms one durable send", async () => {
|
|
const conversation = {
|
|
id: "conversation-a",
|
|
account_id: profile.id,
|
|
platform: profile.platform,
|
|
peer_uid: "peer-a",
|
|
peer_name: "客户",
|
|
};
|
|
const dataProvider = provider({
|
|
getList: vi.fn(({ resource }) =>
|
|
Promise.resolve({
|
|
data:
|
|
resource === "creator-accounts"
|
|
? [profile]
|
|
: resource === "creator-conversations"
|
|
? [conversation]
|
|
: resource === "creator-rules"
|
|
? []
|
|
: [],
|
|
total:
|
|
resource === "creator-conversations" ||
|
|
resource === "creator-accounts"
|
|
? 1
|
|
: 0,
|
|
}),
|
|
),
|
|
creatorGet: vi.fn((path) =>
|
|
Promise.resolve(
|
|
path.includes("/messages")
|
|
? []
|
|
: path === "/creator/settings"
|
|
? settings
|
|
: [],
|
|
),
|
|
),
|
|
creatorAction: vi
|
|
.fn()
|
|
.mockResolvedValue({ state: "succeeded", id: "operation-a" }),
|
|
});
|
|
renderPage(<CreatorWorkbenchPage />, dataProvider);
|
|
fireEvent.click(await screen.findByRole("tab", { name: "私信" }));
|
|
expect(await screen.findByText("客户")).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("combobox", { name: "发送账号" }));
|
|
fireEvent.click(await screen.findByRole("option", { name: "主账号" }));
|
|
fireEvent.change(screen.getByRole("textbox", { name: "人工私信文案" }), {
|
|
target: { value: "你好" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "确认并发送" }));
|
|
fireEvent.click(
|
|
await screen.findByRole("button", { name: "确认并记录操作" }),
|
|
);
|
|
await waitFor(() =>
|
|
expect(dataProvider.creatorCreate).toHaveBeenCalledWith(
|
|
"/creator/operations",
|
|
expect.objectContaining({
|
|
action: "dm",
|
|
account_id: profile.id,
|
|
target_uid: conversation.peer_uid,
|
|
text: "你好",
|
|
idempotency_key: expect.any(String),
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
});
|