feat: ship self-hosted KOC LOOP workflows

This commit is contained in:
巫凤萍
2026-08-11 23:07:26 +08:00
parent 1f1887c860
commit ddad4b7659
88 changed files with 6056 additions and 8938 deletions

View File

@@ -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: "",

View File

@@ -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/);

View File

@@ -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/);

View 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("林林社群、木子", "木子;历史表格"),
"林林社群、木子、历史表格",
);
});

View 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/"),
),
);
});

View 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"), /不安全/);
});