feat: ship self-hosted KOC LOOP workflows
This commit is contained in:
@@ -106,11 +106,55 @@ test("collects likes, comments and favorites from the verified MCP shape", async
|
||||
});
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[2].body.params.name, "fetch_content_detail");
|
||||
assert.equal(calls[2].body.params.arguments.include_comments, false);
|
||||
assert.equal(calls[2].body.params.arguments.request.include_comments, false);
|
||||
assert.equal(calls[2].headers.get("mcp-session-id"), "session-test");
|
||||
assert.equal(new URL(calls[0].url).searchParams.get("key"), "test-key");
|
||||
});
|
||||
|
||||
test("collects through a stateless MCP server without a session header", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (_url, init) => {
|
||||
const body = JSON.parse(init.body);
|
||||
calls.push({ body, headers: new Headers(init.headers) });
|
||||
if (body.method === "initialize") {
|
||||
return sse({
|
||||
jsonrpc: "2.0",
|
||||
id: body.id,
|
||||
result: {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "ai-tool-center", version: "production" },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (body.method === "tools/call") {
|
||||
return sse(
|
||||
toolEnvelope({
|
||||
response: {
|
||||
code: 200,
|
||||
success: true,
|
||||
data: { likes: 12, comments: 3, collects: 6 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected MCP method: ${body.method}`);
|
||||
};
|
||||
|
||||
const result = await collectXhsMetricsFromMcp(
|
||||
"https://www.xiaohongshu.com/explore/test",
|
||||
{ endpoint: "https://collector.example/mcp", key: "test-key" },
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { likes: 12, comments: 3, collects: 6 });
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.body.method),
|
||||
["initialize", "tools/call"],
|
||||
);
|
||||
assert.equal(calls[1].headers.get("mcp-session-id"), null);
|
||||
});
|
||||
|
||||
test("resolves the real XHS account profile from a submitted note link", async () => {
|
||||
const { calls, fetchImpl } = createFakeMcp([
|
||||
toolEnvelope({
|
||||
@@ -180,12 +224,12 @@ test("resolves the real XHS account profile from a submitted note link", async (
|
||||
"collect_xhs_wen_note_detail",
|
||||
);
|
||||
assert.equal(
|
||||
calls[2].body.params.arguments.note_id,
|
||||
calls[2].body.params.arguments.request.note_id,
|
||||
"6a671108000000000f004bef",
|
||||
);
|
||||
assert.equal(calls[3].body.params.name, "parse_xhs_user_summary");
|
||||
assert.equal(
|
||||
calls[3].body.params.arguments.url,
|
||||
calls[3].body.params.arguments.request.url,
|
||||
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
|
||||
);
|
||||
assert.equal(
|
||||
@@ -225,6 +269,7 @@ test("resolves followers directly from the supported XHS user summary tool", asy
|
||||
);
|
||||
|
||||
assert.deepEqual(details, {
|
||||
nickname: "555 五",
|
||||
followers: 6,
|
||||
redId: "1020668113",
|
||||
ipLocation: "福建",
|
||||
@@ -293,7 +338,7 @@ test("resolves an xhslink short URL before requesting the author profile", async
|
||||
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
|
||||
);
|
||||
assert.equal(
|
||||
fakeMcp.calls[2].body.params.arguments.note_id,
|
||||
fakeMcp.calls[2].body.params.arguments.request.note_id,
|
||||
"6a572da40000000021018bd2",
|
||||
);
|
||||
});
|
||||
@@ -379,6 +424,7 @@ test("reads the user-visible Xiaohongshu number from a public profile", async ()
|
||||
|
||||
assert.equal(accountId, "1020668113");
|
||||
assert.deepEqual(details, {
|
||||
nickname: "",
|
||||
redId: "1020668113",
|
||||
followers: 10,
|
||||
ipLocation: "",
|
||||
|
||||
@@ -9,11 +9,11 @@ test("exposes authenticated KOC task, recovery, collection, and resource MCP too
|
||||
readFile(new URL("../lib/mcp-tools.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/mcp-operations.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/mcp-export-token.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0008_worried_ultimatum.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/task-service.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../README.md", import.meta.url), "utf8"),
|
||||
readFile(new URL("../.dev.vars.example", import.meta.url), "utf8"),
|
||||
readFile(new URL("../.env.self-hosted.example", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
@@ -53,7 +53,7 @@ test("exposes authenticated KOC task, recovery, collection, and resource MCP too
|
||||
assert.match(tokenService, /SHA-256/);
|
||||
assert.match(tokenService, /expires_at > CURRENT_TIMESTAMP/);
|
||||
assert.doesNotMatch(tokenService, /KOC_MCP_API_KEY/);
|
||||
assert.match(migration, /CREATE TABLE `mcp_export_tokens`/);
|
||||
assert.match(migration, /CREATE TABLE IF NOT EXISTS mcp_export_tokens/);
|
||||
assert.match(migration, /mcp_export_tokens_expires_at_idx/);
|
||||
|
||||
assert.match(taskService, /readFeishuSource/);
|
||||
|
||||
@@ -19,8 +19,8 @@ test("builds the KOC LOOP product shell", async () => {
|
||||
assert.match(adminApp, /获取KOC领取链接/);
|
||||
assert.match(layout, /KOC LOOP|内容分发闭环/);
|
||||
assert.doesNotMatch(adminApp, /codex-preview|Your site is taking shape/);
|
||||
await access(new URL("../dist/server/index.js", import.meta.url));
|
||||
await access(new URL("../dist/client/assets", import.meta.url));
|
||||
await access(new URL("../.next/standalone/server.js", import.meta.url));
|
||||
await access(new URL("../.next/static", import.meta.url));
|
||||
});
|
||||
|
||||
test("stacks user management and securely removes departed accounts", async () => {
|
||||
@@ -40,12 +40,13 @@ test("stacks user management and securely removes departed accounts", async () =
|
||||
assert.match(usersRoute, /DELETE FROM users WHERE id = \? AND role <> 'super_admin'/);
|
||||
});
|
||||
|
||||
test("ships persistence, uploads, metadata, and no starter preview", async () => {
|
||||
const [adminApp, layout, packageJson, hosting] = await Promise.all([
|
||||
test("ships MySQL persistence, local uploads, metadata, and no starter preview", async () => {
|
||||
const [adminApp, layout, packageJson, compose, objectStore] = await Promise.all([
|
||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../docker-compose.self-hosted.yml", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/object-store.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.doesNotMatch(adminApp, /批量回填发布链接/);
|
||||
@@ -54,8 +55,10 @@ test("ships persistence, uploads, metadata, and no starter preview", async () =>
|
||||
assert.match(layout, /KOC LOOP|内容分发闭环/);
|
||||
assert.match(layout, /\/og\.png/);
|
||||
assert.doesNotMatch(packageJson, /react-loading-skeleton/);
|
||||
assert.match(hosting, /"d1": "DB"/);
|
||||
assert.match(hosting, /"r2": "UPLOADS"/);
|
||||
assert.match(packageJson, /"mysql2"/);
|
||||
assert.match(compose, /mysql:8\.4/);
|
||||
assert.match(compose, /upload_data:\/data\/koc\/uploads/);
|
||||
assert.match(objectStore, /class LocalObjectStore/);
|
||||
await access(new URL("../public/og.png", import.meta.url));
|
||||
});
|
||||
|
||||
@@ -140,8 +143,7 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
||||
assert.match(partnerRoute, /publicImageAssets/);
|
||||
assert.match(partnerRoute, /withPartnerCors/);
|
||||
assert.match(partnerRoute, /enrichDistributionAccount/);
|
||||
assert.match(partnerRoute, /getRequestExecutionContext/);
|
||||
assert.match(partnerRoute, /executionContext\.waitUntil\(enrichment\)/);
|
||||
assert.match(partnerRoute, /runInBackground\(enrichment/);
|
||||
assert.match(accountEnrichment, /profile_url = excluded\.profile_url/);
|
||||
assert.match(accountEnrichment, /resolveXhsAccountProfileFromMcp/);
|
||||
assert.match(accountEnrichment, /resolveXhsProfileDetailsFromMcp/);
|
||||
@@ -163,6 +165,10 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
||||
assert.match(uploadRoute, /x-koc-distribution/);
|
||||
assert.match(uploadRoute, /request\.arrayBuffer/);
|
||||
assert.match(imageRoute, /content-assets\//);
|
||||
assert.match(imageRoute, /publish-evidence\//);
|
||||
assert.match(imageRoute, /creator-center\//);
|
||||
assert.match(imageRoute, /imageKind === "publish"/);
|
||||
assert.match(imageRoute, /imageKind === "creator"/);
|
||||
assert.match(imageRoute, /cl\.claim_token/);
|
||||
assert.match(imageUploadRoute, /isAdminRequest/);
|
||||
assert.match(cors, /KOC_PORTAL_URL/);
|
||||
@@ -171,6 +177,15 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
||||
assert.match(cors, /Access-Control-Allow-Origin/);
|
||||
assert.match(adminApp, /hasCreatorMetrics/);
|
||||
assert.match(adminApp, /待KOC填写数据/);
|
||||
assert.match(adminApp, /AdminImageLightbox/);
|
||||
assert.match(adminApp, /CreatorScreenshotPreview/);
|
||||
assert.match(adminApp, /admin-image-lightbox/);
|
||||
assert.match(adminApp, /aria-label="上一张"/);
|
||||
assert.match(adminApp, /aria-label="下一张"/);
|
||||
assert.match(adminApp, /event\.key === "ArrowLeft"/);
|
||||
assert.match(adminApp, /event\.key === "ArrowRight"/);
|
||||
assert.match(adminApp, /activeIndex \+ 1/);
|
||||
assert.doesNotMatch(adminApp, /href=\{`\/api\/task-result-image/);
|
||||
assert.match(creatorScreenshotRoute, /getUploadBucket/);
|
||||
assert.match(creatorScreenshotRoute, /Content-Disposition/);
|
||||
assert.match(creatorScreenshotRoute, /isAdminRequest/);
|
||||
@@ -184,21 +199,21 @@ test("supports task collection schedules and latest public metrics", async () =>
|
||||
actionRoute,
|
||||
bootstrapRoute,
|
||||
collectionService,
|
||||
worker,
|
||||
viteConfig,
|
||||
scheduler,
|
||||
nextConfig,
|
||||
migration,
|
||||
accountMigration,
|
||||
deployConfig,
|
||||
compose,
|
||||
] = await Promise.all([
|
||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/bootstrap/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/collection-service.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../worker/index.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0004_sharp_the_liberteens.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0005_foamy_sage.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../dist/server/wrangler.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/scheduler.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../next.config.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../docker-compose.self-hosted.yml", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
for (const label of [
|
||||
@@ -217,7 +232,11 @@ test("supports task collection schedules and latest public metrics", async () =>
|
||||
assert.match(adminApp, /内容 \/ 发布账号/);
|
||||
assert.match(adminApp, /recovery-title-link/);
|
||||
assert.match(adminApp, /打开小红书笔记/);
|
||||
assert.match(adminApp, /target="_blank"/);
|
||||
assert.match(adminApp, /noopener noreferrer/);
|
||||
assert.match(adminApp, /const noteUrl = xhsPublishUrl\(item\.publish_url\)/);
|
||||
assert.match(adminApp, /noteUrl \? \(/);
|
||||
assert.match(adminApp, /hostname === "xhslink\.cn"/);
|
||||
assert.match(actionRoute, /save_collection_schedule/);
|
||||
assert.match(actionRoute, /collect_now/);
|
||||
assert.match(actionRoute, /backfill_account_profiles/);
|
||||
@@ -236,14 +255,45 @@ test("supports task collection schedules and latest public metrics", async () =>
|
||||
assert.match(collectionService, /等待第\$\{scheduleDay\}天 10:00自动采集/);
|
||||
assert.doesNotMatch(collectionService, /latestDueSchedule/);
|
||||
assert.match(collectionService, /自动追采/);
|
||||
assert.match(worker, /async scheduled/);
|
||||
assert.match(worker, /backfillAccountProfiles/);
|
||||
assert.match(scheduler, /0 10 \* \* \*/);
|
||||
assert.match(scheduler, /backfillAccountProfiles/);
|
||||
assert.match(scheduler, /Asia\/Shanghai/);
|
||||
assert.match(bootstrapRoute, /backfillAccountProfiles/);
|
||||
assert.match(viteConfig, /"0 2 \* \* \*"/);
|
||||
assert.match(nextConfig, /output: "standalone"/);
|
||||
assert.match(migration, /latest_likes/);
|
||||
assert.match(migration, /collection_runs_distribution_date_idx/);
|
||||
assert.match(accountMigration, /public_account_id/);
|
||||
assert.match(deployConfig, /"crons":\["0 2 \* \* \*"\]/);
|
||||
assert.match(compose, /ENABLE_SCHEDULER/);
|
||||
});
|
||||
|
||||
test("supports fixed screenshot collection tasks without publish metrics", async () => {
|
||||
const [adminApp, actionRoute, partnerRoute, uploadRoute, migration, multiMigration, exportRoute] =
|
||||
await Promise.all([
|
||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/partner/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/partner-upload/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0003_screenshot_tasks.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0004_multi_result_screenshots.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/screenshot-task-export/route.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(adminApp, /内容发布/);
|
||||
assert.match(adminApp, /截图回收/);
|
||||
assert.match(adminApp, /小红书搜索关键词/);
|
||||
assert.match(adminApp, /批量下载截图/);
|
||||
assert.match(actionRoute, /create_screenshot_task/);
|
||||
assert.match(partnerRoute, /submit_screenshot_result/);
|
||||
assert.match(partnerRoute, /截图回收任务无需填写发布链接/);
|
||||
assert.match(uploadRoute, /task-results\//);
|
||||
assert.match(uploadRoute, /task-result/);
|
||||
assert.match(uploadRoute, /MAX_RESULT_SCREENSHOTS/);
|
||||
assert.match(migration, /task_type/);
|
||||
assert.match(migration, /result_screenshot_key/);
|
||||
assert.match(multiMigration, /MODIFY COLUMN result_screenshot_key TEXT/);
|
||||
assert.match(adminApp, /admin-result-gallery/);
|
||||
assert.match(exportRoute, /回收清单\.csv/);
|
||||
assert.match(exportRoute, /application\/zip/);
|
||||
});
|
||||
|
||||
test("exports complete task recovery data to Excel with embedded images", async () => {
|
||||
@@ -295,7 +345,7 @@ test("provides simple username-password login and three server-enforced roles",
|
||||
readFile(new URL("../app/api/resources-export/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../db/schema.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/mvp-db.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0007_fantastic_sentinels.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(loginPage, /登录账号/);
|
||||
@@ -321,9 +371,9 @@ test("provides simple username-password login and three server-enforced roles",
|
||||
assert.match(bootstrapRoute, /accounts: \[\]/);
|
||||
assert.match(resourceExportRoute, /isManagerRequest/);
|
||||
assert.match(schema, /authSessions/);
|
||||
assert.match(schema, /users_single_super_admin_idx/);
|
||||
assert.match(schema, /backgroundJobs/);
|
||||
assert.match(runtimeSchema, /CREATE TABLE IF NOT EXISTS auth_sessions/);
|
||||
assert.match(migration, /CREATE TABLE `users`/);
|
||||
assert.match(migration, /CREATE TABLE IF NOT EXISTS users/);
|
||||
assert.match(migration, /users_single_super_admin_idx/);
|
||||
});
|
||||
|
||||
@@ -349,6 +399,24 @@ test("filters and exports the current KOC resource result set", async () => {
|
||||
assert.match(workbook, /relationships\/hyperlink/);
|
||||
});
|
||||
|
||||
test("imports existing KOC resources through a validated spreadsheet preview", async () => {
|
||||
const [adminApp, importRoute, resourceParser, accountMigration] = await Promise.all([
|
||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/resources-import/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/resource-import.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0002_resource_import.sql", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(adminApp, /下载导入模板/);
|
||||
assert.match(adminApp, /校验并预览/);
|
||||
assert.match(adminApp, /确认导入/);
|
||||
assert.match(importRoute, /isManagerRequest/);
|
||||
assert.match(importRoute, /mode !== "commit"/);
|
||||
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 100/);
|
||||
assert.match(resourceParser, /当前自动解析仅支持小红书账号主页/);
|
||||
assert.match(importRoute, /resolveXhsProfileDetailsFromMcp/);
|
||||
assert.match(accountMigration, /cooperation_source/);
|
||||
});
|
||||
|
||||
test("supports anonymous partner delegation without creating a second data flow", async () => {
|
||||
const [
|
||||
adminApp,
|
||||
@@ -367,7 +435,7 @@ test("supports anonymous partner delegation without creating a second data flow"
|
||||
readFile(new URL("../lib/partner-cors.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../db/schema.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../lib/mvp-db.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0006_moaning_dark_phoenix.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(schema, /delegationBundles/);
|
||||
@@ -380,7 +448,7 @@ test("supports anonymous partner delegation without creating a second data flow"
|
||||
assert.match(partnerRoute, /findAccessibleAssignment/);
|
||||
assert.match(partnerRoute, /b\.status = 'active'/);
|
||||
assert.match(partnerRoute, /部分笔记刚刚已被转派/);
|
||||
assert.match(partnerRoute, /分享链接只能用于查看和回填包内笔记/);
|
||||
assert.match(partnerRoute, /分享链接只能用于查看和回填包内任务/);
|
||||
assert.match(partnerRoute, /private, no-store/);
|
||||
assert.match(uploadRoute, /x-koc-delegation/);
|
||||
assert.match(uploadRoute, /delegation_bundles/);
|
||||
|
||||
66
tests/resource-import.test.mjs
Normal file
66
tests/resource-import.test.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts";
|
||||
import {
|
||||
mergeCooperationSources,
|
||||
normalizeProfileUrl,
|
||||
parseResourceImportFile,
|
||||
resourcePlatformUid,
|
||||
} from "../lib/resource-import.ts";
|
||||
|
||||
test("parses CSV resources and normalizes public profile data", () => {
|
||||
const csv = [
|
||||
"账号主页,合作来源",
|
||||
'"主页:https://www.xiaohongshu.com/user/profile/abc123?xsec_token=secret",林林KOC社群',
|
||||
].join("\n");
|
||||
const rows = parseResourceImportFile("resources.csv", new TextEncoder().encode(csv));
|
||||
assert.equal(rows.length, 1);
|
||||
assert.deepEqual(rows[0], {
|
||||
rowNumber: 2,
|
||||
platform: "小红书",
|
||||
nickname: "",
|
||||
publicAccountId: "",
|
||||
profileUrl: "https://www.xiaohongshu.com/user/profile/abc123",
|
||||
ipLocation: "待识别",
|
||||
followers: 0,
|
||||
cooperationSource: "林林KOC社群",
|
||||
errors: [],
|
||||
});
|
||||
assert.equal(resourcePlatformUid(rows[0]), "abc123");
|
||||
});
|
||||
|
||||
test("parses the first matching worksheet from an XLSX workbook", () => {
|
||||
const workbook = buildRecoveryWorkbook({
|
||||
sheetName: "KOC资源导入",
|
||||
headers: ["账号主页", "合作来源"],
|
||||
columnWidths: [48, 24],
|
||||
rows: [
|
||||
{
|
||||
cells: ["https://www.xiaohongshu.com/user/profile/abc123", "存量资源包"],
|
||||
images: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const rows = parseResourceImportFile("resources.xlsx", workbook);
|
||||
assert.equal(rows[0].platform, "小红书");
|
||||
assert.equal(rows[0].cooperationSource, "存量资源包");
|
||||
assert.equal(rows[0].errors.length, 0);
|
||||
});
|
||||
|
||||
test("reports invalid required fields without hiding valid rows", () => {
|
||||
const csv = "账号主页,合作来源\n,历史资源\nhttps://www.douyin.com/user/demo,社群";
|
||||
const rows = parseResourceImportFile("resources.csv", new TextEncoder().encode(csv));
|
||||
assert.match(rows[0].errors.join(";"), /账号主页不能为空/);
|
||||
assert.match(rows[1].errors.join(";"), /仅支持小红书账号主页/);
|
||||
});
|
||||
|
||||
test("normalizes profile URLs and merges cooperation sources", () => {
|
||||
assert.equal(
|
||||
normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),
|
||||
"https://www.xiaohongshu.com/user/profile/abc",
|
||||
);
|
||||
assert.equal(
|
||||
mergeCooperationSources("林林社群、木子", "木子;历史表格"),
|
||||
"林林社群、木子、历史表格",
|
||||
);
|
||||
});
|
||||
31
tests/result-screenshots.test.mjs
Normal file
31
tests/result-screenshots.test.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
MAX_RESULT_SCREENSHOTS,
|
||||
parseResultScreenshotKeys,
|
||||
serializeResultScreenshotKeys,
|
||||
} from "../lib/result-screenshots.ts";
|
||||
|
||||
test("keeps legacy task result screenshots readable", () => {
|
||||
assert.deepEqual(
|
||||
parseResultScreenshotKeys("task-results/dist-1/shot-1.png"),
|
||||
["task-results/dist-1/shot-1.png"],
|
||||
);
|
||||
});
|
||||
|
||||
test("serializes multiple task screenshots safely and caps their count", () => {
|
||||
const keys = Array.from(
|
||||
{ length: MAX_RESULT_SCREENSHOTS + 3 },
|
||||
(_, index) => `task-results/dist-1/shot-${index + 1}.png`,
|
||||
);
|
||||
const serialized = serializeResultScreenshotKeys([
|
||||
...keys,
|
||||
"content-assets/not-allowed.png",
|
||||
]);
|
||||
assert.equal(parseResultScreenshotKeys(serialized).length, MAX_RESULT_SCREENSHOTS);
|
||||
assert.ok(
|
||||
parseResultScreenshotKeys(serialized).every((key) =>
|
||||
key.startsWith("task-results/"),
|
||||
),
|
||||
);
|
||||
});
|
||||
75
tests/self-hosted-runtime.test.mjs
Normal file
75
tests/self-hosted-runtime.test.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { DatabaseClient, normalizeSqlForMysql } from "../lib/database.ts";
|
||||
import { LocalObjectStore, normalizeObjectKey } from "../lib/object-store.ts";
|
||||
import { requestUsesHttps } from "../lib/user-auth.ts";
|
||||
|
||||
test("normalizes the limited SQLite syntax still used by business queries", () => {
|
||||
assert.match(
|
||||
normalizeSqlForMysql("INSERT OR IGNORE INTO collection_runs (id) VALUES (?)"),
|
||||
/^INSERT IGNORE INTO/,
|
||||
);
|
||||
const upsert = normalizeSqlForMysql(
|
||||
"INSERT INTO accounts (id, nickname) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET nickname = excluded.nickname",
|
||||
);
|
||||
assert.match(upsert, /ON DUPLICATE KEY UPDATE/);
|
||||
assert.match(upsert, /nickname = VALUES\(nickname\)/);
|
||||
assert.equal(
|
||||
normalizeSqlForMysql("SELECT datetime(publish_time, '+7 days')"),
|
||||
"SELECT DATE_ADD(publish_time, INTERVAL 7 DAY)",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes ISO timestamps before binding to MySQL DATETIME", async () => {
|
||||
let receivedParams;
|
||||
const database = new DatabaseClient({
|
||||
async execute(_sql, params) {
|
||||
receivedParams = params;
|
||||
return [{ affectedRows: 1, insertId: 0 }];
|
||||
},
|
||||
});
|
||||
await database
|
||||
.prepare("INSERT INTO auth_sessions (expires_at) VALUES (?)")
|
||||
.bind("2026-08-11T02:03:04.567Z")
|
||||
.run();
|
||||
assert.deepEqual(receivedParams, ["2026-08-11 02:03:04.567"]);
|
||||
});
|
||||
|
||||
test("marks login cookies secure behind the Nginx HTTPS proxy", () => {
|
||||
assert.equal(
|
||||
requestUsesHttps(
|
||||
new Request("http://app:3000/api/auth/login", {
|
||||
headers: { "x-forwarded-proto": "https" },
|
||||
}),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(requestUsesHttps(new Request("http://localhost/login")), false);
|
||||
});
|
||||
|
||||
test("stores uploaded objects under a safe persistent directory", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "koc-object-store-"));
|
||||
const store = new LocalObjectStore(directory);
|
||||
await store.put("creator-center/dist-1/shot.png", Uint8Array.from([1, 2, 3]), {
|
||||
httpMetadata: { contentType: "image/png" },
|
||||
});
|
||||
const object = await store.get("creator-center/dist-1/shot.png");
|
||||
assert.ok(object);
|
||||
assert.deepEqual([...new Uint8Array(await object.arrayBuffer())], [1, 2, 3]);
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
assert.equal(headers.get("Content-Type"), "image/png");
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
path.join(directory, "creator-center/dist-1/shot.png.metadata.json"),
|
||||
"utf8",
|
||||
),
|
||||
).contentType,
|
||||
"image/png",
|
||||
);
|
||||
assert.throws(() => normalizeObjectKey("../secret"), /不安全/);
|
||||
});
|
||||
Reference in New Issue
Block a user