e9939ef9dc
Add GitAtlas deploy assets, DATA_FILE support for isolated server data, and port documentation for sourcing.simosen.cn. Co-authored-by: Cursor <cursoragent@cursor.com>
1356 lines
50 KiB
JavaScript
1356 lines
50 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { test } from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { hashToken } from "../src/auth.js";
|
|
import { createServer } from "../src/server.js";
|
|
import { JsonStore } from "../src/store.js";
|
|
|
|
const ROOT_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
|
async function startServer(options = {}) {
|
|
const dir = join(tmpdir(), `sourcing-api-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const store = new JsonStore(join(dir, "items.json"));
|
|
const server = createServer({ store, publicDir: join(dir, "public"), ...options });
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
return {
|
|
baseUrl,
|
|
async close() {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
async function jsonRequest(baseUrl, path, { method = "GET", token, body } = {}) {
|
|
const headers = {};
|
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const contentType = response.headers.get("content-type") || "";
|
|
const payload = contentType.includes("application/json") ? await response.json() : await response.text();
|
|
return { response, payload };
|
|
}
|
|
|
|
test("local API accepts extension captures and exports CSV", async () => {
|
|
const dir = join(tmpdir(), `sourcing-api-${Date.now()}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const store = new JsonStore(join(dir, "items.json"));
|
|
const server = createServer({ store, publicDir: join(dir, "public") });
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const captureResponse = await fetch(`${baseUrl}/api/captures`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "藏式编绳手链 直播民族风",
|
|
url: "https://detail.1688.com/offer/100.html",
|
|
image: "https://img.example/100.jpg",
|
|
priceText: "¥6.80",
|
|
supplier: "义乌源头饰品厂",
|
|
metrics: { visibleText: "现货 混批 1000+人付款" },
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
assert.equal(captureResponse.status, 201);
|
|
const capturePayload = await captureResponse.json();
|
|
assert.equal(capturePayload.items.length, 1);
|
|
assert.equal(capturePayload.items[0].decision, "拿样");
|
|
|
|
const listResponse = await fetch(`${baseUrl}/api/items?platform=1688`);
|
|
assert.equal(listResponse.status, 200);
|
|
const listPayload = await listResponse.json();
|
|
assert.equal(listPayload.items.length, 1);
|
|
|
|
const id = listPayload.items[0].id;
|
|
const updateResponse = await fetch(`${baseUrl}/api/items/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "ordered_sample", notes: "先拿样 3 件" }),
|
|
});
|
|
assert.equal(updateResponse.status, 200);
|
|
const updated = await updateResponse.json();
|
|
assert.equal(updated.item.status, "ordered_sample");
|
|
|
|
const csvResponse = await fetch(`${baseUrl}/api/export.csv`);
|
|
assert.equal(csvResponse.status, 200);
|
|
assert.match(await csvResponse.text(), /藏式编绳手链/);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("health API exposes recommended extension version for update prompts", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const health = await jsonRequest(app.baseUrl, "/api/health");
|
|
assert.equal(health.response.status, 200);
|
|
assert.equal(health.payload.ok, true);
|
|
assert.equal(health.payload.extension.name, "选品采购采集器");
|
|
assert.match(health.payload.extension.recommendedVersion, /^\d+\.\d+\.\d+$/);
|
|
assert.equal(health.payload.extension.installUrl, "/downloads/product-sourcing-capture-extension.zip");
|
|
assert.match(health.payload.extension.fileName, /^product-sourcing-capture-extension-\d+\.\d+\.\d+\.zip$/);
|
|
assert.equal(health.payload.extension.reloadRequiredForUnpacked, true);
|
|
assert.ok(health.payload.extension.updateNote.includes("storage"));
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("server packages browser extension as a downloadable zip", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const headResponse = await fetch(`${app.baseUrl}/downloads/product-sourcing-capture-extension.zip`, { method: "HEAD" });
|
|
assert.equal(headResponse.status, 200);
|
|
assert.equal(headResponse.headers.get("content-type"), "application/zip");
|
|
assert.match(
|
|
headResponse.headers.get("content-disposition") || "",
|
|
/attachment; filename="product-sourcing-capture-extension-\d+\.\d+\.\d+\.zip"/,
|
|
);
|
|
|
|
const response = await fetch(`${app.baseUrl}/downloads/product-sourcing-capture-extension.zip`);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("content-type"), "application/zip");
|
|
assert.match(
|
|
response.headers.get("content-disposition") || "",
|
|
/attachment; filename="product-sourcing-capture-extension-\d+\.\d+\.\d+\.zip"/,
|
|
);
|
|
|
|
const zip = Buffer.from(await response.arrayBuffer());
|
|
assert.ok(zip.length > 1000);
|
|
assert.equal(zip.slice(0, 4).toString("binary"), "PK\u0003\u0004");
|
|
assert.ok(zip.includes(Buffer.from("manifest.json")));
|
|
assert.ok(zip.includes(Buffer.from("popup.html")));
|
|
assert.ok(zip.includes(Buffer.from("content.js")));
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("download routes do not fall back to the dashboard HTML", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const response = await fetch(`${app.baseUrl}/downloads/missing-extension.zip`);
|
|
assert.equal(response.status, 404);
|
|
assert.match(response.headers.get("content-type") || "", /application\/json/);
|
|
assert.deepEqual(await response.json(), { error: "Download not found" });
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("dashboard exposes browser extension package download in plugin install areas", async () => {
|
|
const dir = join(tmpdir(), `sourcing-api-ui-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
const server = createServer({ store: new JsonStore(join(dir, "items.json")), publicDir: join(ROOT_DIR, "public") });
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const homepage = await fetch(`${baseUrl}/`);
|
|
const html = await homepage.text();
|
|
assert.equal(homepage.status, 200);
|
|
assert.match(html, /\/downloads\/product-sourcing-capture-extension\.zip/);
|
|
assert.match(html, /下载插件安装包/);
|
|
|
|
const appJs = await fetch(`${baseUrl}/app.js`);
|
|
const js = await appJs.text();
|
|
assert.equal(appJs.status, 200);
|
|
assert.match(js, /\/downloads\/product-sourcing-capture-extension\.zip/);
|
|
assert.match(js, /下载插件安装包/);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("homepage exposes absolute social sharing metadata and brand assets", async () => {
|
|
const dir = join(tmpdir(), `sourcing-api-brand-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
const publicDir = join(dir, "public");
|
|
await mkdir(join(publicDir, "assets"), { recursive: true });
|
|
await writeFile(
|
|
join(publicDir, "index.html"),
|
|
[
|
|
"<!doctype html>",
|
|
'<html><head><title>选品采购台|竞品采集、货源匹配与采购决策</title>',
|
|
'<link rel="icon" href="/favicon.ico" />',
|
|
'<meta name="description" content="采集淘宝、小红书等平台商品线索,匹配 1688 货源,完成筛选、利润测算、拿样与采购决策。" />',
|
|
'<meta property="og:title" content="选品采购台|竞品采集、货源匹配与采购决策" />',
|
|
'<meta property="og:image" content="__SHARE_ORIGIN__/assets/share-card.png" />',
|
|
'<meta property="og:url" content="__SHARE_ORIGIN__/" />',
|
|
"</head><body></body></html>",
|
|
].join(""),
|
|
"utf8",
|
|
);
|
|
await writeFile(join(publicDir, "favicon.ico"), "icon", "utf8");
|
|
await writeFile(join(publicDir, "assets", "share-card.png"), "share", "utf8");
|
|
const server = createServer({ store: new JsonStore(join(dir, "items.json")), publicDir });
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const homepage = await fetch(`${baseUrl}/`);
|
|
const html = await homepage.text();
|
|
assert.equal(homepage.status, 200);
|
|
assert.match(html, /选品采购台|竞品采集、货源匹配与采购决策/);
|
|
assert.match(html, /rel="icon" href="\/favicon\.ico"/);
|
|
assert.match(html, /property="og:image"/);
|
|
assert.ok(html.includes(`content="${baseUrl}/assets/share-card.png"`));
|
|
assert.ok(html.includes(`content="${baseUrl}/"`));
|
|
assert.doesNotMatch(html, /__SHARE_ORIGIN__/);
|
|
|
|
assert.equal((await fetch(`${baseUrl}/favicon.ico`)).status, 200);
|
|
assert.equal((await fetch(`${baseUrl}/assets/share-card.png`)).status, 200);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("SaaS auth isolates captures and rules by user", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const aliceRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "alice@example.com", password: "secret123", name: "Alice 买手" },
|
|
});
|
|
assert.equal(aliceRegister.response.status, 201);
|
|
assert.ok(aliceRegister.payload.token);
|
|
assert.equal(aliceRegister.payload.user.email, "alice@example.com");
|
|
assert.equal(aliceRegister.payload.user.passwordHash, undefined);
|
|
|
|
const bobRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "bob@example.com", password: "secret123", name: "Bob 买手" },
|
|
});
|
|
assert.equal(bobRegister.response.status, 201);
|
|
|
|
const unauthenticated = await jsonRequest(app.baseUrl, "/api/items");
|
|
assert.equal(unauthenticated.response.status, 401);
|
|
|
|
const aliceToken = aliceRegister.payload.token;
|
|
const bobToken = bobRegister.payload.token;
|
|
|
|
const capture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: aliceToken,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "Alice 藏式编绳手链",
|
|
url: "https://detail.1688.com/offer/alice-auth.html",
|
|
priceText: "¥6.80",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(capture.response.status, 201);
|
|
assert.equal(capture.payload.items[0].title, "Alice 藏式编绳手链");
|
|
|
|
const aliceItems = await jsonRequest(app.baseUrl, "/api/items", { token: aliceToken });
|
|
assert.equal(aliceItems.response.status, 200);
|
|
assert.deepEqual(
|
|
aliceItems.payload.items.map((item) => item.title),
|
|
["Alice 藏式编绳手链"],
|
|
);
|
|
|
|
const bobItems = await jsonRequest(app.baseUrl, "/api/items", { token: bobToken });
|
|
assert.equal(bobItems.response.status, 200);
|
|
assert.deepEqual(bobItems.payload.items, []);
|
|
|
|
const aliceRules = await jsonRequest(app.baseUrl, "/api/rules", { token: aliceToken });
|
|
assert.equal(aliceRules.response.status, 200);
|
|
assert.ok(aliceRules.payload.rules.tracks.some((track) => track.name === "藏式/民族风"));
|
|
|
|
const updateRules = await jsonRequest(app.baseUrl, "/api/rules", {
|
|
method: "PUT",
|
|
token: aliceToken,
|
|
body: {
|
|
rules: {
|
|
riskKeywords: ["过敏包治"],
|
|
profitModel: {
|
|
shippingFee: 5,
|
|
packagingFee: 2,
|
|
platformFeeRate: 0.06,
|
|
promoFee: 4,
|
|
maxRunningPrice: 99,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
assert.equal(updateRules.response.status, 200);
|
|
assert.deepEqual(updateRules.payload.rules.riskKeywords, ["过敏包治"]);
|
|
assert.equal(updateRules.payload.rules.profitModel.shippingFee, 5);
|
|
|
|
const bobRules = await jsonRequest(app.baseUrl, "/api/rules", { token: bobToken });
|
|
assert.equal(bobRules.response.status, 200);
|
|
assert.notDeepEqual(bobRules.payload.rules.riskKeywords, ["过敏包治"]);
|
|
|
|
const login = await jsonRequest(app.baseUrl, "/api/auth/login", {
|
|
method: "POST",
|
|
body: { email: "alice@example.com", password: "secret123" },
|
|
});
|
|
assert.equal(login.response.status, 200);
|
|
assert.ok(login.payload.token);
|
|
assert.equal(login.payload.user.email, "alice@example.com");
|
|
|
|
const badLogin = await jsonRequest(app.baseUrl, "/api/auth/login", {
|
|
method: "POST",
|
|
body: { email: "alice@example.com", password: "wrong-password" },
|
|
});
|
|
assert.equal(badLogin.response.status, 401);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("admin can manage users and disabled users cannot sign in", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "admin@example.com", password: "secret123", name: "平台管理员" },
|
|
});
|
|
assert.equal(adminRegister.response.status, 201);
|
|
assert.equal(adminRegister.payload.user.role, "admin");
|
|
assert.equal(adminRegister.payload.user.status, "active");
|
|
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "buyer@example.com", password: "secret123", name: "买手用户" },
|
|
});
|
|
assert.equal(buyerRegister.response.status, 201);
|
|
assert.equal(buyerRegister.payload.user.role, "buyer");
|
|
|
|
const buyerAdminRead = await jsonRequest(app.baseUrl, "/api/admin/users", { token: buyerRegister.payload.token });
|
|
assert.equal(buyerAdminRead.response.status, 403);
|
|
|
|
const adminUsers = await jsonRequest(app.baseUrl, "/api/admin/users", { token: adminRegister.payload.token });
|
|
assert.equal(adminUsers.response.status, 200);
|
|
assert.equal(adminUsers.payload.users.length, 2);
|
|
assert.deepEqual(
|
|
adminUsers.payload.users.map((user) => [user.email, user.role, user.status, user.itemCount, user.pluginTokenCount]),
|
|
[
|
|
["admin@example.com", "admin", "active", 0, 0],
|
|
["buyer@example.com", "buyer", "active", 0, 0],
|
|
],
|
|
);
|
|
|
|
const disableBuyer = await jsonRequest(app.baseUrl, `/api/admin/users/${buyerRegister.payload.user.id}/status`, {
|
|
method: "PATCH",
|
|
token: adminRegister.payload.token,
|
|
body: { status: "disabled" },
|
|
});
|
|
assert.equal(disableBuyer.response.status, 200);
|
|
assert.equal(disableBuyer.payload.user.status, "disabled");
|
|
|
|
const disabledLogin = await jsonRequest(app.baseUrl, "/api/auth/login", {
|
|
method: "POST",
|
|
body: { email: "buyer@example.com", password: "secret123" },
|
|
});
|
|
assert.equal(disabledLogin.response.status, 403);
|
|
|
|
const disabledSessionRead = await jsonRequest(app.baseUrl, "/api/items", { token: buyerRegister.payload.token });
|
|
assert.equal(disabledSessionRead.response.status, 403);
|
|
|
|
const disableSelf = await jsonRequest(app.baseUrl, `/api/admin/users/${adminRegister.payload.user.id}/status`, {
|
|
method: "PATCH",
|
|
token: adminRegister.payload.token,
|
|
body: { status: "disabled" },
|
|
});
|
|
assert.equal(disableSelf.response.status, 400);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("plugin tokens can capture to one user but cannot read private library", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const register = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "plugin-owner@example.com", password: "secret123", name: "插件用户" },
|
|
});
|
|
const sessionToken = register.payload.token;
|
|
|
|
const createdToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", {
|
|
method: "POST",
|
|
token: sessionToken,
|
|
body: { name: "Chrome 云采集插件" },
|
|
});
|
|
assert.equal(createdToken.response.status, 201);
|
|
assert.ok(createdToken.payload.token.startsWith("pst_"));
|
|
assert.equal(createdToken.payload.pluginToken.name, "Chrome 云采集插件");
|
|
assert.equal(createdToken.payload.pluginToken.tokenHash, undefined);
|
|
|
|
const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: sessionToken,
|
|
body: { credits: 3 },
|
|
});
|
|
const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: sessionToken,
|
|
body: { code: createdCode.payload.code },
|
|
});
|
|
assert.equal(redeemed.response.status, 200);
|
|
|
|
const pluginCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: createdToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "xiaohongshu",
|
|
title: "插件云端采集珍珠耳饰",
|
|
url: "https://www.xiaohongshu.com/explore/plugin-token",
|
|
priceText: "¥59",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(pluginCapture.response.status, 201);
|
|
assert.equal(pluginCapture.payload.items[0].title, "插件云端采集珍珠耳饰");
|
|
|
|
const pluginRead = await jsonRequest(app.baseUrl, "/api/items", { token: createdToken.payload.token });
|
|
assert.equal(pluginRead.response.status, 403);
|
|
|
|
const ownerRead = await jsonRequest(app.baseUrl, "/api/items", { token: sessionToken });
|
|
assert.equal(ownerRead.response.status, 200);
|
|
assert.deepEqual(
|
|
ownerRead.payload.items.map((item) => item.title),
|
|
["插件云端采集珍珠耳饰"],
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("plugin tokens track usage and can be disabled or deleted by owner", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const register = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "token-owner@example.com", password: "secret123", name: "Token 用户" },
|
|
});
|
|
const sessionToken = register.payload.token;
|
|
|
|
const createdToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", {
|
|
method: "POST",
|
|
token: sessionToken,
|
|
body: { name: "直播间采集插件" },
|
|
});
|
|
assert.equal(createdToken.response.status, 201);
|
|
assert.equal(createdToken.payload.pluginToken.status, "active");
|
|
assert.equal(createdToken.payload.pluginToken.usageCount, 0);
|
|
assert.equal(createdToken.payload.pluginToken.lastUsedAt, null);
|
|
|
|
const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: sessionToken,
|
|
body: { credits: 2 },
|
|
});
|
|
const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: sessionToken,
|
|
body: { code: createdCode.payload.code },
|
|
});
|
|
assert.equal(redeemed.response.status, 200);
|
|
|
|
const pluginCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: createdToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "Token 统计藏式手链",
|
|
url: "https://detail.1688.com/offer/token-usage.html",
|
|
priceText: "¥6.80",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(pluginCapture.response.status, 201);
|
|
|
|
const tokenListAfterUse = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: sessionToken });
|
|
assert.equal(tokenListAfterUse.response.status, 200);
|
|
assert.equal(tokenListAfterUse.payload.pluginTokens[0].usageCount, 1);
|
|
assert.ok(tokenListAfterUse.payload.pluginTokens[0].lastUsedAt);
|
|
|
|
const disabledToken = await jsonRequest(app.baseUrl, `/api/plugin-tokens/${createdToken.payload.pluginToken.id}`, {
|
|
method: "PATCH",
|
|
token: sessionToken,
|
|
body: { status: "disabled" },
|
|
});
|
|
assert.equal(disabledToken.response.status, 200);
|
|
assert.equal(disabledToken.payload.pluginToken.status, "disabled");
|
|
|
|
const disabledCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: createdToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "禁用 token 不应采集",
|
|
url: "https://detail.1688.com/offer/token-disabled.html",
|
|
priceText: "¥7.80",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(disabledCapture.response.status, 403);
|
|
|
|
const deleteToken = await jsonRequest(app.baseUrl, `/api/plugin-tokens/${createdToken.payload.pluginToken.id}`, {
|
|
method: "DELETE",
|
|
token: sessionToken,
|
|
});
|
|
assert.equal(deleteToken.response.status, 200);
|
|
assert.equal(deleteToken.payload.deleted, 1);
|
|
|
|
const tokenListAfterDelete = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: sessionToken });
|
|
assert.equal(tokenListAfterDelete.response.status, 200);
|
|
assert.deepEqual(tokenListAfterDelete.payload.pluginTokens, []);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("plugin token owner can copy token again from token list", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const ownerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "copy-token-owner@example.com", password: "secret123", name: "可复制 Token 用户" },
|
|
});
|
|
const otherRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "copy-token-other@example.com", password: "secret123", name: "其他用户" },
|
|
});
|
|
const createdToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", {
|
|
method: "POST",
|
|
token: ownerRegister.payload.token,
|
|
body: { name: "可再次复制的插件" },
|
|
});
|
|
assert.equal(createdToken.response.status, 201);
|
|
assert.ok(createdToken.payload.token.startsWith("pst_"));
|
|
|
|
const ownerList = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: ownerRegister.payload.token });
|
|
assert.equal(ownerList.response.status, 200);
|
|
assert.equal(ownerList.payload.pluginTokens.length, 1);
|
|
assert.equal(ownerList.payload.pluginTokens[0].token, createdToken.payload.token);
|
|
assert.equal(ownerList.payload.pluginTokens[0].tokenHash, undefined);
|
|
|
|
const otherList = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: otherRegister.payload.token });
|
|
assert.equal(otherList.response.status, 200);
|
|
assert.deepEqual(otherList.payload.pluginTokens, []);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("admin can issue credit codes and buyers redeem them into account credits", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "credit-admin@example.com", password: "secret123", name: "积分管理员" },
|
|
});
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "credit-buyer@example.com", password: "secret123", name: "积分买手" },
|
|
});
|
|
|
|
assert.equal(adminRegister.payload.user.credits, 0);
|
|
assert.equal(buyerRegister.payload.user.credits, 0);
|
|
|
|
const buyerCannotIssue = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { credits: 30 },
|
|
});
|
|
assert.equal(buyerCannotIssue.response.status, 403);
|
|
|
|
const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: adminRegister.payload.token,
|
|
body: { credits: 30, note: "首批采集额度" },
|
|
});
|
|
assert.equal(createdCode.response.status, 201);
|
|
assert.equal(createdCode.payload.creditCode.credits, 30);
|
|
assert.equal(createdCode.payload.creditCode.status, "active");
|
|
assert.match(createdCode.payload.code, /^CDX-[A-Z0-9-]+$/);
|
|
|
|
const codeList = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { token: adminRegister.payload.token });
|
|
assert.equal(codeList.response.status, 200);
|
|
assert.equal(codeList.payload.creditCodes.length, 1);
|
|
assert.equal(codeList.payload.creditCodes[0].code, createdCode.payload.code);
|
|
assert.equal(codeList.payload.creditCodes[0].redeemedBy, null);
|
|
|
|
const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { code: createdCode.payload.code.toLowerCase() },
|
|
});
|
|
assert.equal(redeemed.response.status, 200);
|
|
assert.equal(redeemed.payload.user.credits, 30);
|
|
assert.equal(redeemed.payload.creditCode.status, "redeemed");
|
|
|
|
const me = await jsonRequest(app.baseUrl, "/api/me", { token: buyerRegister.payload.token });
|
|
assert.equal(me.response.status, 200);
|
|
assert.equal(me.payload.user.credits, 30);
|
|
|
|
const redeemAgain = await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { code: createdCode.payload.code },
|
|
});
|
|
assert.equal(redeemAgain.response.status, 409);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("admin manages AI settings without exposing API keys to buyers", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-admin@example.com", password: "secret123", name: "AI 管理员" },
|
|
});
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-buyer@example.com", password: "secret123", name: "AI 买手" },
|
|
});
|
|
|
|
const buyerRead = await jsonRequest(app.baseUrl, "/api/admin/ai-settings", { token: buyerRegister.payload.token });
|
|
assert.equal(buyerRead.response.status, 403);
|
|
|
|
const saved = await jsonRequest(app.baseUrl, "/api/admin/ai-settings", {
|
|
method: "PUT",
|
|
token: adminRegister.payload.token,
|
|
body: {
|
|
enabled: true,
|
|
provider: "deepseek",
|
|
baseUrl: "https://api.deepseek.com/v1",
|
|
apiKey: "sk-secret-value",
|
|
model: "deepseek-chat",
|
|
temperature: 0.2,
|
|
maxOutputTokens: 900,
|
|
timeoutMs: 15000,
|
|
tokenUnitCost: 2,
|
|
prompts: {
|
|
itemAnalysis: "请分析这个商品",
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(saved.response.status, 200);
|
|
assert.equal(saved.payload.aiSettings.enabled, true);
|
|
assert.equal(saved.payload.aiSettings.provider, "deepseek");
|
|
assert.equal(saved.payload.aiSettings.model, "deepseek-chat");
|
|
assert.equal(saved.payload.aiSettings.apiKeyMasked, "sk-...alue");
|
|
assert.equal(saved.payload.aiSettings.apiKey, undefined);
|
|
|
|
const readBack = await jsonRequest(app.baseUrl, "/api/admin/ai-settings", { token: adminRegister.payload.token });
|
|
assert.equal(readBack.response.status, 200);
|
|
assert.equal(readBack.payload.aiSettings.apiKeyMasked, "sk-...alue");
|
|
assert.equal(readBack.payload.aiSettings.apiKey, undefined);
|
|
assert.equal(readBack.payload.aiSettings.prompts.itemAnalysis, "请分析这个商品");
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("admin can issue AI token codes and buyers redeem them into AI token balance", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-token-admin@example.com", password: "secret123", name: "AI Token 管理员" },
|
|
});
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-token-buyer@example.com", password: "secret123", name: "AI Token 买手" },
|
|
});
|
|
assert.equal(buyerRegister.payload.user.aiTokens, 0);
|
|
|
|
const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: adminRegister.payload.token,
|
|
body: { credits: 5, aiTokens: 12000, note: "AI 分析包" },
|
|
});
|
|
assert.equal(createdCode.response.status, 201);
|
|
assert.equal(createdCode.payload.creditCode.credits, 5);
|
|
assert.equal(createdCode.payload.creditCode.aiTokens, 12000);
|
|
|
|
const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { code: createdCode.payload.code },
|
|
});
|
|
assert.equal(redeemed.response.status, 200);
|
|
assert.equal(redeemed.payload.user.credits, 5);
|
|
assert.equal(redeemed.payload.user.aiTokens, 12000);
|
|
|
|
const me = await jsonRequest(app.baseUrl, "/api/me", { token: buyerRegister.payload.token });
|
|
assert.equal(me.payload.user.aiTokens, 12000);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("AI item analysis uses admin model settings and deducts user AI tokens", async () => {
|
|
const calls = [];
|
|
const fakeAiClient = {
|
|
async chat({ settings, messages }) {
|
|
calls.push({ settings, messages });
|
|
return {
|
|
text: JSON.stringify({
|
|
summary: "适合直播测试的低客单商品",
|
|
purchaseAdvice: "建议先拿样 3 件",
|
|
sellingPoints: ["视觉记忆点强", "适合组合购"],
|
|
risks: ["确认材质和售后"],
|
|
supplierQuestions: ["是否现货?"],
|
|
}),
|
|
usage: { inputTokens: 320, outputTokens: 180, totalTokens: 500 },
|
|
raw: { id: "chatcmpl_fake" },
|
|
};
|
|
},
|
|
};
|
|
const app = await startServer({ aiClient: fakeAiClient });
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-use-admin@example.com", password: "secret123", name: "AI 使用管理员" },
|
|
});
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-use-buyer@example.com", password: "secret123", name: "AI 使用买手" },
|
|
});
|
|
|
|
await jsonRequest(app.baseUrl, "/api/admin/ai-settings", {
|
|
method: "PUT",
|
|
token: adminRegister.payload.token,
|
|
body: {
|
|
enabled: true,
|
|
provider: "openai-compatible",
|
|
baseUrl: "https://proxy.example.com/v1",
|
|
apiKey: "sk-proxy-secret",
|
|
model: "gpt-4o-mini",
|
|
tokenUnitCost: 1,
|
|
},
|
|
});
|
|
|
|
const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: adminRegister.payload.token,
|
|
body: { aiTokens: 1000, note: "AI 测试额度" },
|
|
});
|
|
await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { code: createdCode.payload.code },
|
|
});
|
|
|
|
const capture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "藏式编绳手机链 低价跑量",
|
|
url: "https://detail.1688.com/offer/ai-analysis.html",
|
|
priceText: "¥5.80",
|
|
supplier: "义乌源头饰品厂",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const analysis = await jsonRequest(app.baseUrl, "/api/ai/analyze", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { type: "item_analysis", itemId: capture.payload.items[0].id },
|
|
});
|
|
|
|
assert.equal(analysis.response.status, 200);
|
|
assert.equal(analysis.payload.analysis.summary, "适合直播测试的低客单商品");
|
|
assert.deepEqual(analysis.payload.analysis.sellingPoints, ["视觉记忆点强", "适合组合购"]);
|
|
assert.equal(analysis.payload.usage.totalTokens, 500);
|
|
assert.equal(analysis.payload.billing.deductedAiTokens, 500);
|
|
assert.equal(analysis.payload.billing.remainingAiTokens, 500);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].settings.apiKey, "sk-proxy-secret");
|
|
assert.equal(calls[0].settings.model, "gpt-4o-mini");
|
|
assert.match(calls[0].messages.at(-1).content, /藏式编绳手机链/);
|
|
|
|
const me = await jsonRequest(app.baseUrl, "/api/me", { token: buyerRegister.payload.token });
|
|
assert.equal(me.payload.user.aiTokens, 500);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("AI analysis requires enabled settings and enough AI tokens", async () => {
|
|
const app = await startServer({
|
|
aiClient: {
|
|
async chat() {
|
|
throw new Error("AI client should not be called without enough tokens");
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-limit-admin@example.com", password: "secret123", name: "AI 限额管理员" },
|
|
});
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "ai-limit-buyer@example.com", password: "secret123", name: "AI 限额买手" },
|
|
});
|
|
|
|
const disabled = await jsonRequest(app.baseUrl, "/api/ai/analyze", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { type: "item_analysis", item: { title: "未启用 AI" } },
|
|
});
|
|
assert.equal(disabled.response.status, 409);
|
|
|
|
await jsonRequest(app.baseUrl, "/api/admin/ai-settings", {
|
|
method: "PUT",
|
|
token: adminRegister.payload.token,
|
|
body: {
|
|
enabled: true,
|
|
baseUrl: "https://proxy.example.com/v1",
|
|
apiKey: "sk-secret",
|
|
model: "gpt-4o-mini",
|
|
},
|
|
});
|
|
|
|
const noBalance = await jsonRequest(app.baseUrl, "/api/ai/analyze", {
|
|
method: "POST",
|
|
token: buyerRegister.payload.token,
|
|
body: { type: "item_analysis", item: { title: "无 AI Token" } },
|
|
});
|
|
assert.equal(noBalance.response.status, 402);
|
|
assert.equal(noBalance.payload.aiTokens.remaining, 0);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("plugin capture requires shared account credits and deducts per saved item", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "quota-admin@example.com", password: "secret123", name: "额度管理员" },
|
|
});
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "quota-buyer@example.com", password: "secret123", name: "额度买手" },
|
|
});
|
|
const buyerToken = buyerRegister.payload.token;
|
|
|
|
const firstPluginToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", {
|
|
method: "POST",
|
|
token: buyerToken,
|
|
body: { name: "淘宝采集插件" },
|
|
});
|
|
const secondPluginToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", {
|
|
method: "POST",
|
|
token: buyerToken,
|
|
body: { name: "1688 采集插件" },
|
|
});
|
|
assert.ok(firstPluginToken.payload.token.startsWith("pst_"));
|
|
assert.ok(secondPluginToken.payload.token.startsWith("pst_"));
|
|
|
|
const noCreditCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: firstPluginToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "taobao",
|
|
title: "无积分不应采集",
|
|
url: "https://item.taobao.com/item.htm?id=no-credit",
|
|
priceText: "¥39",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(noCreditCapture.response.status, 402);
|
|
|
|
const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: adminRegister.payload.token,
|
|
body: { credits: 3 },
|
|
});
|
|
const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: buyerToken,
|
|
body: { code: createdCode.payload.code },
|
|
});
|
|
assert.equal(redeemed.payload.user.credits, 3);
|
|
|
|
const firstCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: firstPluginToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "taobao",
|
|
title: "积分采集淘宝耳饰",
|
|
url: "https://item.taobao.com/item.htm?id=credit-a",
|
|
priceText: "¥39",
|
|
},
|
|
{
|
|
platform: "xiaohongshu",
|
|
title: "积分采集小红书手链",
|
|
url: "https://www.xiaohongshu.com/explore/credit-b",
|
|
priceText: "¥49",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(firstCapture.response.status, 201);
|
|
assert.equal(firstCapture.payload.credits.remaining, 1);
|
|
assert.equal(firstCapture.payload.credits.deducted, 2);
|
|
|
|
const secondCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: secondPluginToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "第二个 Token 共用积分",
|
|
url: "https://detail.1688.com/offer/shared-credit.html",
|
|
priceText: "¥6.80",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(secondCapture.response.status, 201);
|
|
assert.equal(secondCapture.payload.credits.remaining, 0);
|
|
|
|
const meAfterCapture = await jsonRequest(app.baseUrl, "/api/me", { token: buyerToken });
|
|
assert.equal(meAfterCapture.payload.user.credits, 0);
|
|
|
|
const insufficientCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: firstPluginToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "积分耗尽不应采集",
|
|
url: "https://detail.1688.com/offer/no-more-credit.html",
|
|
priceText: "¥8.80",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(insufficientCapture.response.status, 402);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("plugin capture charges duplicate urls in one batch as one saved lead", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "dedupe-admin@example.com", password: "secret123", name: "去重管理员" },
|
|
});
|
|
const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "dedupe-buyer@example.com", password: "secret123", name: "去重买手" },
|
|
});
|
|
const buyerToken = buyerRegister.payload.token;
|
|
|
|
const pluginToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", {
|
|
method: "POST",
|
|
token: buyerToken,
|
|
body: { name: "重复链接采集插件" },
|
|
});
|
|
const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", {
|
|
method: "POST",
|
|
token: adminRegister.payload.token,
|
|
body: { credits: 1 },
|
|
});
|
|
await jsonRequest(app.baseUrl, "/api/credits/redeem", {
|
|
method: "POST",
|
|
token: buyerToken,
|
|
body: { code: createdCode.payload.code },
|
|
});
|
|
|
|
const duplicateCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: pluginToken.payload.token,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "taobao",
|
|
title: "重复采集的藏式手链 A",
|
|
url: "https://item.taobao.com/item.htm?id=duplicate-credit",
|
|
priceText: "¥49",
|
|
},
|
|
{
|
|
platform: "taobao",
|
|
title: "重复采集的藏式手链 B",
|
|
url: "https://item.taobao.com/item.htm?id=duplicate-credit",
|
|
priceText: "¥45",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
assert.equal(duplicateCapture.response.status, 201);
|
|
assert.equal(duplicateCapture.payload.items.length, 1);
|
|
assert.equal(duplicateCapture.payload.items[0].title, "重复采集的藏式手链 B");
|
|
assert.equal(duplicateCapture.payload.credits.deducted, 1);
|
|
assert.equal(duplicateCapture.payload.credits.remaining, 0);
|
|
|
|
const items = await jsonRequest(app.baseUrl, "/api/items", { token: buyerToken });
|
|
assert.equal(items.payload.items.length, 1);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("plugin capture keeps credits unchanged when saving fails", async () => {
|
|
const pluginTokenValue = "pst_failure_test_token";
|
|
const fakeStore = {
|
|
user: {
|
|
id: "usr_failure",
|
|
email: "failure-buyer@example.com",
|
|
name: "失败买手",
|
|
role: "buyer",
|
|
status: "active",
|
|
credits: 1,
|
|
},
|
|
pluginToken: {
|
|
id: "ptk_failure",
|
|
userId: "usr_failure",
|
|
tokenHash: hashToken(pluginTokenValue),
|
|
status: "active",
|
|
usageCount: 0,
|
|
},
|
|
async userCount() {
|
|
return 1;
|
|
},
|
|
async findPluginTokenByHash(tokenHash) {
|
|
if (tokenHash !== this.pluginToken.tokenHash) return null;
|
|
return { user: this.user, pluginToken: this.pluginToken };
|
|
},
|
|
async deductCredits(userId, amount) {
|
|
this.user.credits -= amount;
|
|
return { deducted: amount, remaining: this.user.credits };
|
|
},
|
|
async insertMany() {
|
|
const error = new Error("Simulated save failure");
|
|
error.status = 503;
|
|
throw error;
|
|
},
|
|
async insertManyWithCreditDeduction() {
|
|
const error = new Error("Simulated save failure");
|
|
error.status = 503;
|
|
throw error;
|
|
},
|
|
};
|
|
const dir = join(tmpdir(), `sourcing-api-failed-save-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const server = createServer({ store: fakeStore, publicDir: join(dir, "public") });
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const failedCapture = await jsonRequest(baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
token: pluginTokenValue,
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "保存失败不应扣积分",
|
|
url: "https://detail.1688.com/offer/failure-credit.html",
|
|
priceText: "¥6.80",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
assert.equal(failedCapture.response.status, 503);
|
|
assert.equal(fakeStore.user.credits, 1);
|
|
assert.equal(fakeStore.pluginToken.usageCount, 0);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("legacy users without role or status are normalized for admin access", async () => {
|
|
const dir = join(tmpdir(), `sourcing-api-legacy-users-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const filePath = join(dir, "items.json");
|
|
const store = new JsonStore(filePath);
|
|
const server = createServer({ store, publicDir: join(dir, "public") });
|
|
|
|
const admin = await store.createUser({
|
|
email: "legacy-admin@example.com",
|
|
name: "旧管理员",
|
|
passwordHash: "legacy-admin-hash",
|
|
});
|
|
const buyer = await store.createUser({
|
|
email: "legacy-buyer@example.com",
|
|
name: "旧买手",
|
|
passwordHash: "legacy-buyer-hash",
|
|
});
|
|
|
|
await writeFile(
|
|
filePath,
|
|
JSON.stringify(
|
|
{
|
|
users: [
|
|
{ id: admin.id, email: admin.email, name: admin.name, passwordHash: admin.passwordHash, createdAt: admin.createdAt },
|
|
{ id: buyer.id, email: buyer.email, name: buyer.name, passwordHash: buyer.passwordHash, createdAt: buyer.createdAt },
|
|
],
|
|
items: [{ id: "legacy-item", userId: buyer.id, title: "旧数据饰品", platform: "1688", createdAt: "2026-01-01T00:00:00.000Z" }],
|
|
pluginTokens: [{ id: "legacy-token", userId: buyer.id, name: "旧插件", tokenHash: "hash", createdAt: "2026-01-01T00:00:00.000Z" }],
|
|
sessions: [],
|
|
rules: {},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const adminToken = "sess_legacy_admin_access";
|
|
const adminSession = await store.createSession({ userId: admin.id, tokenHash: hashToken(adminToken) });
|
|
assert.ok(adminSession.id);
|
|
|
|
const adminUsers = await jsonRequest(baseUrl, "/api/admin/users", { token: adminToken });
|
|
assert.equal(adminUsers.response.status, 200);
|
|
assert.deepEqual(
|
|
adminUsers.payload.users.map((user) => [user.email, user.role, user.status, user.itemCount, user.pluginTokenCount]),
|
|
[
|
|
["legacy-admin@example.com", "admin", "active", 0, 0],
|
|
["legacy-buyer@example.com", "buyer", "active", 1, 1],
|
|
],
|
|
);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("first SaaS user claims existing local captures during migration", async () => {
|
|
const app = await startServer();
|
|
|
|
try {
|
|
const localCapture = await jsonRequest(app.baseUrl, "/api/captures", {
|
|
method: "POST",
|
|
body: {
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "注册前本地采集藏式手链",
|
|
url: "https://detail.1688.com/offer/local-before-register.html",
|
|
priceText: "¥5.80",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(localCapture.response.status, 201);
|
|
|
|
const register = await jsonRequest(app.baseUrl, "/api/auth/register", {
|
|
method: "POST",
|
|
body: { email: "first-user@example.com", password: "secret123", name: "首个用户" },
|
|
});
|
|
assert.equal(register.response.status, 201);
|
|
|
|
const ownedItems = await jsonRequest(app.baseUrl, "/api/items", { token: register.payload.token });
|
|
assert.equal(ownedItems.response.status, 200);
|
|
assert.deepEqual(
|
|
ownedItems.payload.items.map((item) => item.title),
|
|
["注册前本地采集藏式手链"],
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("local API deletes selected captures in one bulk request", async () => {
|
|
const dir = join(tmpdir(), `sourcing-api-bulk-${Date.now()}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const store = new JsonStore(join(dir, "items.json"));
|
|
const server = createServer({ store, publicDir: join(dir, "public") });
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const captureResponse = await fetch(`${baseUrl}/api/captures`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: [
|
|
{
|
|
platform: "1688",
|
|
title: "误采集 A",
|
|
url: "https://detail.1688.com/offer/api-bulk-a.html",
|
|
priceText: "¥3.80",
|
|
},
|
|
{
|
|
platform: "1688",
|
|
title: "误采集 B",
|
|
url: "https://detail.1688.com/offer/api-bulk-b.html",
|
|
priceText: "¥4.80",
|
|
},
|
|
{
|
|
platform: "1688",
|
|
title: "保留 C",
|
|
url: "https://detail.1688.com/offer/api-bulk-c.html",
|
|
priceText: "¥5.80",
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const { items } = await captureResponse.json();
|
|
const deleteResponse = await fetch(`${baseUrl}/api/items/bulk-delete`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ids: [items[0].id, items[1].id, "missing-id"] }),
|
|
});
|
|
|
|
assert.equal(deleteResponse.status, 200);
|
|
const deleted = await deleteResponse.json();
|
|
assert.equal(deleted.deleted, 2);
|
|
assert.deepEqual(deleted.missing, ["missing-id"]);
|
|
|
|
const listResponse = await fetch(`${baseUrl}/api/items`);
|
|
const listPayload = await listResponse.json();
|
|
assert.equal(listPayload.items.length, 1);
|
|
assert.equal(listPayload.items[0].title, "保留 C");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("local API exposes dashboard, groups, sample queue, and bulk updates", async () => {
|
|
const dir = join(tmpdir(), `sourcing-api-workbench-${Date.now()}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const store = new JsonStore(join(dir, "items.json"));
|
|
const server = createServer({ store, publicDir: join(dir, "public") });
|
|
|
|
await new Promise((resolve) => server.listen(0, resolve));
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
try {
|
|
const captureResponse = await fetch(`${baseUrl}/api/captures`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: [
|
|
{
|
|
platform: "taobao",
|
|
title: "藏式绿松石编绳手链 民族风",
|
|
url: "https://item.taobao.com/item.htm?id=api-style-a",
|
|
priceText: "¥39.90",
|
|
},
|
|
{
|
|
platform: "1688",
|
|
title: "民族风绿松石色编织手链 藏式",
|
|
url: "https://detail.1688.com/offer/api-style-b.html",
|
|
priceText: "¥6.80",
|
|
supplier: "义乌源头厂",
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
const { items } = await captureResponse.json();
|
|
|
|
const bulkResponse = await fetch(`${baseUrl}/api/items/bulk-update`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
ids: [items[1].id],
|
|
patch: { status: "asking_supplier" },
|
|
addTags: ["低价跑量"],
|
|
}),
|
|
});
|
|
assert.equal(bulkResponse.status, 200);
|
|
assert.equal((await bulkResponse.json()).updated, 1);
|
|
|
|
const dashboardResponse = await fetch(`${baseUrl}/api/dashboard`);
|
|
assert.equal(dashboardResponse.status, 200);
|
|
const dashboard = await dashboardResponse.json();
|
|
assert.equal(dashboard.dashboard.totals.items, 2);
|
|
|
|
const groupsResponse = await fetch(`${baseUrl}/api/style-groups`);
|
|
assert.equal(groupsResponse.status, 200);
|
|
const groups = await groupsResponse.json();
|
|
assert.equal(groups.groups.length, 1);
|
|
assert.equal(groups.groups[0].items.length, 2);
|
|
|
|
const sampleResponse = await fetch(`${baseUrl}/api/sample-queue`);
|
|
assert.equal(sampleResponse.status, 200);
|
|
const sampleQueue = await sampleResponse.json();
|
|
assert.equal(sampleQueue.items.length, 1);
|
|
assert.equal(sampleQueue.items[0].status, "asking_supplier");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|