Files
wangliang 39986f10f2 初始化 LibreDesk 本地实例部署仓库
- docker compose 部署:app / db / redis / tls(nginx) / bridge-tools,宿主端口错开避免与本机服务冲突
- bridge-tools 内联进仓库(compose build 路径改为 ./bridge-tools),仓库自包含可部署
- 部署说明 README、crm-new 对照与 bridge-tools 方案文档、备份与隧道脚本
- .gitignore 排除 .env、config.toml、uploads/、tls 私钥/证书、backups/、logs/
2026-10-29 13:24:13 +08:00

196 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// 单测:mock RPC,覆盖 4 个工具 + HTTP 层(鉴权/健康检查/身份头)
const { test } = require('node:test');
const assert = require('node:assert');
const { loadConfig } = require('../src/config');
const { createTools, resolveSiteProfile } = require('../src/tools');
const { createServer } = require('../src/server');
function baseConfig(env = {}) {
return loadConfig({
TOOLS_SHARED_KEY: 'test-key',
RECOMMEND_URL: 'jsonrpc://rpc:9632',
ORDER_API_URL: 'jsonrpc://rpc:9612',
INVENTORY_API_URL: 'jsonrpc://rpc:9509',
MEMBER_RPC_URL: 'jsonrpc://rpc:9602',
DEFAULT_TENANT_ID: '2',
DEFAULT_LANGUAGE_ID: '1',
...env,
});
}
function mockRpc(handlers = {}) {
const calls = [];
return {
calls,
async call(addr, method, params, opts, timeout) {
calls.push({ addr, method, params, timeout });
const h = handlers[method];
if (h === undefined) throw new Error(`unexpected rpc method: ${method}`);
return typeof h === 'function' ? h(params) : h;
},
};
}
const verifiedCtx = {
verified: true,
email: 'customer@example.com',
inboxId: '1',
conversationUuid: 'conv-1',
};
test('站点 profile:inbox 1 映射到 www / tenant 2 / EUR', () => {
const profile = resolveSiteProfile(baseConfig(), { inboxId: '1' });
assert.equal(profile.site, 'www');
assert.equal(profile.tenantId, 2);
assert.equal(profile.currencyCode, 'EUR');
assert.equal(profile.searchUrl, 'https://www.yehwang.com/search.html?q=');
});
test('工具清单只暴露 4 个业务工具', () => {
const tools = createTools(baseConfig(), { rpc: mockRpc() });
assert.deepEqual(tools.map((t) => t.name).sort(), ['get_customer_info', 'search_inventory', 'search_orders', 'search_products']);
assert.equal(tools.find((t) => t.name === 'search_orders').requiresVerification, true);
assert.equal(tools.find((t) => t.name === 'get_customer_info').requiresVerification, true);
});
test('search_products:catalog 命中,返回价格/库存/URL,并注入 RPC 上下文', async () => {
const rpc = mockRpc({
'/product/list': {
result: {
data: [{ spuName: 'Gold Hoop Earrings', href: '/p/gold-hoop.html', spuId: 11, minPriceWithCurrency: '€9.99', skus: [{ quantity: 2 }], masterImage: '/img/1.jpg' }],
total: 1,
},
},
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_products').handler({ query: 'gold hoop earrings' }, verifiedCtx);
assert.equal(out.count, 1);
assert.equal(out.source, 'catalog');
assert.equal(out.products[0].name, 'Gold Hoop Earrings');
assert.equal(out.products[0].price, '€9.99');
assert.equal(out.products[0].stock, 2);
assert.equal(out.products[0].product_id, 11);
assert.equal(out.products[0].url, 'https://www.yehwang.com/p/gold-hoop.html');
const sent = rpc.calls[0].params[0];
assert.equal(sent.reqContext.tenantId, 2);
assert.equal(sent.reqContext.languageId, 1);
assert.ok(sent.deviceInfo._class);
});
test('search_products:catalog 空时退回 site-map 类目链接', async () => {
const rpc = mockRpc({
'/product/list': { result: { data: [] } },
'/product_new/autocomplete': { result: [] },
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_products').handler({ query: 'rings' }, verifiedCtx);
assert.equal(out.count > 0, true);
assert.equal(out.source, 'site-map');
assert.ok(out.products[0].url);
});
test('search_orders:未验证直接 403,不触发 RPC', async () => {
const rpc = mockRpc({});
const tools = createTools(baseConfig(), { rpc });
await assert.rejects(
() => tools.find((t) => t.name === 'search_orders').handler({}, { ...verifiedCtx, verified: false }),
(err) => err.status === 403 && err.code === 'verification_required'
);
assert.equal(rpc.calls.length, 0);
});
test('search_orders:已验证返回订单摘要(邮箱只取身份头)', async () => {
const rpc = mockRpc({
'/erp_order/getList': { result: [{ orderId: 5, orderNo: 'YW1001', statusText: 'Shipped', total: '€42.00', dateAdded: '2026-10-01', shippingMethod: 'DHL' }] },
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_orders').handler({}, verifiedCtx);
assert.equal(out.count, 1);
assert.equal(out.orders[0].order_no, 'YW1001');
assert.equal(out.orders[0].status, 'Shipped');
assert.equal(rpc.calls[0].params[0].email, 'customer@example.com');
});
test('search_inventory:合并库存与 ETA', async () => {
const rpc = mockRpc({
'/eta/queryStockSummary': { result: { instantStock: 10, reservedQuantity: 2, inTransitQuantity: 3 } },
'/eta/queryProductEta': { result: { etaDate: '2026-11-01', availableQuantity: 5, status: 'in_transit' } },
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'search_inventory').handler({ product_id: 11 }, verifiedCtx);
assert.equal(out.product_id, 11);
assert.equal(out.instant_stock, 10);
assert.equal(out.available, 8);
assert.equal(out.in_transit, 3);
assert.equal(out.eta_date, '2026-11-01');
});
test('get_customer_info:邮箱查 customerId 后返回资料', async () => {
const rpc = mockRpc({
'/customer/checkEmailExist': { result: { customerId: 7 } },
'/customer/getByCustomerId': {
result: { firstname: 'Ada', lastname: 'Lovelace', email: 'customer@example.com', company: 'ACME', countryName: 'Netherlands', dial: '31', telephone: '612345678', gradeName: 'VIP', groupName: 'G1', status: 1, point: 100 },
},
});
const tools = createTools(baseConfig(), { rpc });
const out = await tools.find((t) => t.name === 'get_customer_info').handler({}, verifiedCtx);
assert.equal(out.found, true);
assert.equal(out.customer_id, 7);
assert.equal(out.name, 'Ada Lovelace');
assert.equal(out.phone, '+31612345678');
assert.equal(out.member_grade, 'VIP');
});
test('HTTP:healthz 公开、无 key 401、有 key 可调工具、未验证 403', async () => {
const rpc = mockRpc({
'/erp_order/getList': { result: [{ orderId: 5, orderNo: 'YW1001', statusText: 'Shipped' }] },
});
const cfg = baseConfig();
const server = createServer({ config: cfg, tools: createTools(cfg, { rpc }) });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
try {
const health = await fetch(`${base}/healthz`);
assert.equal(health.status, 200);
const healthBody = await health.json();
assert.equal(healthBody.service, 'bridge-tools');
assert.equal(healthBody.tools.length, 4);
const noKey = await fetch(`${base}/tools/manifest`);
assert.equal(noKey.status, 401);
const manifest = await fetch(`${base}/tools/manifest`, { headers: { 'x-tools-key': 'test-key' } });
assert.equal(manifest.status, 200);
const manifestBody = await manifest.json();
assert.equal(manifestBody.tools.length, 4);
const unverified = await fetch(`${base}/tools/search_orders`, {
method: 'POST',
headers: { 'x-tools-key': 'test-key', 'content-type': 'application/json' },
body: '{}',
});
assert.equal(unverified.status, 403);
const verified = await fetch(`${base}/tools/search_orders`, {
method: 'POST',
headers: {
'x-tools-key': 'test-key',
'content-type': 'application/json',
'x-libredesk-contact-verified': 'true',
'x-libredesk-contact-email': 'customer@example.com',
'x-libredesk-inbox-id': '1',
},
body: '{}',
});
assert.equal(verified.status, 200);
const body = await verified.json();
assert.equal(body.tool, 'search_orders');
assert.equal(body.orders[0].order_no, 'YW1001');
} finally {
await new Promise((resolve) => server.close(resolve));
}
});