chore: CRM-NEW baseline
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { parseAddress, asList, asObject } = require('../src/rpc');
|
||||
const { createAuther } = require('../src/auth');
|
||||
const { createTools, resolveSiteProfile } = require('../src/tools');
|
||||
const { ToolError } = require('../src/errors');
|
||||
|
||||
const baseCfg = {
|
||||
recommendUrl: 'jsonrpc://127.0.0.1:24503',
|
||||
orderApiUrl: 'jsonrpc://127.0.0.1:24505',
|
||||
inventoryApiUrl: 'jsonrpc://127.0.0.1:24509',
|
||||
rpcTimeoutMs: 500,
|
||||
siteSearchUrl: 'https://www.yehwang.com/search.html?q=',
|
||||
twenty: { baseURL: 'http://localhost:3000', graphqlPath: '/graphql', workspaceId: 'w', apiKey: 'k' },
|
||||
};
|
||||
|
||||
function fakeRpc(handler) {
|
||||
return {
|
||||
calls: [],
|
||||
async call(addr, method, params) {
|
||||
this.calls.push({ addr, method, params });
|
||||
return handler(addr, method, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTwenty(overrides = {}) {
|
||||
const calls = [];
|
||||
const api = {
|
||||
calls,
|
||||
async findPersonByEmail(email) { calls.push(['findPersonByEmail', email]); return overrides.personId || null; },
|
||||
async findOpportunitiesByPersonId(id) {
|
||||
calls.push(['findOpportunitiesByPersonId', id]);
|
||||
if (overrides.oppsError) throw new Error(overrides.oppsError);
|
||||
return overrides.existingOpps || [];
|
||||
},
|
||||
async createPerson(p) { calls.push(['createPerson', p]); return { id: 'person-new' }; },
|
||||
async findCompanyByName(n) { calls.push(['findCompanyByName', n]); return overrides.companyId || null; },
|
||||
async createCompany(n) { calls.push(['createCompany', n]); return { id: 'company-new' }; },
|
||||
async createOpportunity(o) { calls.push(['createOpportunity', o]); return { id: 'opp-1', name: o.name }; },
|
||||
async updateOpportunity(id, patch) { calls.push(['updateOpportunity', id, patch]); return { id }; },
|
||||
async updateOpportunityStage(id, stage) { calls.push(['updateOpportunityStage', id, stage]); return { id, stage }; },
|
||||
async createNote(c) { calls.push(['createNote', c]); return 'note-1'; },
|
||||
async linkNoteToOpportunity(n, o) { calls.push(['linkNoteToOpportunity', n, o]); return { id: 'nt' }; },
|
||||
async createTask(t, b, d, o) { calls.push(['createTask', t, o]); return 'task-1'; },
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
test('parseAddress: jsonrpc/http/plain', () => {
|
||||
assert.equal(parseAddress('jsonrpc://10.0.0.1:24503').protocol, 'jsonrpc');
|
||||
assert.equal(parseAddress('jsonrpc://10.0.0.1:24503').port, 24503);
|
||||
assert.equal(parseAddress('http://x.test:8080').protocol, 'http');
|
||||
assert.equal(parseAddress('x.test:9502').port, 9502);
|
||||
assert.equal(parseAddress('').port, 9502);
|
||||
});
|
||||
|
||||
test('asList/asObject tolerate Hyperf wrapper shapes', () => {
|
||||
assert.equal(asList({ data: { list: [{ id: 1 }] } }).length, 1);
|
||||
assert.equal(asList({ rows: [{ id: 2 }] })[0].id, 2);
|
||||
assert.deepEqual(asObject({ data: { instantStock: 3 } }), { instantStock: 3 });
|
||||
assert.equal(asObject([{ a: 1 }]).a, 1);
|
||||
});
|
||||
|
||||
test('auther: fail-closed and timing-safe comparison', () => {
|
||||
assert.equal(createAuther('').enabled, false);
|
||||
assert.equal(createAuther('').verify('x'), false);
|
||||
const a = createAuther('secret-key');
|
||||
assert.equal(a.verify('secret-key'), true);
|
||||
assert.equal(a.verify('secret-kez'), false);
|
||||
assert.equal(a.verify(''), false);
|
||||
assert.equal(a.verify(undefined), false);
|
||||
});
|
||||
|
||||
test('search_products: maps RPC items and caps results', async () => {
|
||||
const rpc = fakeRpc(() => ({ data: { list: [
|
||||
{ name: 'Gold Hoop', price: 12.5, url: 'https://x/p/1', sku: 'A1' },
|
||||
{ title: 'Steel Ring', url: 'https://x/p/2' },
|
||||
{ name: 'Noise' },
|
||||
] } }));
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: 'gold hoop' }, { verified: false });
|
||||
assert.equal(out.count, 3);
|
||||
assert.equal(out.products[0].name, 'Gold Hoop');
|
||||
assert.equal(out.products[1].name, 'Steel Ring');
|
||||
assert.match(out.search_url, /gold%20hoop/);
|
||||
assert.equal(rpc.calls[0].method, '/product/list');
|
||||
});
|
||||
|
||||
test('search_products: falls back to site map when catalog returns empty', async () => {
|
||||
const tools = createTools(baseCfg, {
|
||||
rpc: fakeRpc(() => []),
|
||||
twenty: fakeTwenty(),
|
||||
siteMap: [{ title: 'Earrings', category: 'jewelry/earrings', url: 'https://www.yehwang.com/jewelry/earrings', level: 2, aliases: ['耳环', 'earrings'] }],
|
||||
});
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: '耳环' }, { verified: false });
|
||||
assert.equal(out.source, 'site-map');
|
||||
assert.equal(out.count, 1);
|
||||
assert.equal(out.products[0].name, 'Earrings');
|
||||
assert.match(out.products[0].url, /\/jewelry\/earrings$/);
|
||||
});
|
||||
|
||||
test('search_products: catalog results also expose category links', async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
sites: {
|
||||
defaultSite: 'www',
|
||||
profiles: { www: { site: 'www', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' } },
|
||||
inboxes: { '1': 'www' },
|
||||
},
|
||||
};
|
||||
const tools = createTools(cfg, { rpc: fakeRpc(() => ({ result: [{ name: 'earrings zirkon' }] })), twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: '耳环' }, { inboxId: '1', verified: false });
|
||||
assert.equal(out.source, 'catalog');
|
||||
assert.ok(out.category_links.length > 0);
|
||||
assert.match(out.category_links[0].url, /yehwang\.com/);
|
||||
});
|
||||
|
||||
test('search_products: falls back to site map when catalog RPC throws', async () => {
|
||||
const rpc = fakeRpc(() => { throw new Error('recommend down'); });
|
||||
const tools = createTools(baseCfg, {
|
||||
rpc,
|
||||
twenty: fakeTwenty(),
|
||||
siteMap: [{ title: 'Hoop Earrings', category: 'jewelry/earrings/hoop-earrings', url: 'https://www.yehwang.com/jewelry/earrings/hoop-earrings', level: 3, aliases: ['圈形耳环'] }],
|
||||
});
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: '圈形耳环' }, { verified: false });
|
||||
assert.equal(out.source, 'site-map');
|
||||
assert.equal(out.products[0].name, 'Hoop Earrings');
|
||||
assert.match(out.catalog_error, /recommend down/);
|
||||
});
|
||||
|
||||
test('search_products: maps price/stock/image/href from product list', async () => {
|
||||
const rpc = fakeRpc(() => ({
|
||||
result: {
|
||||
total: 1,
|
||||
data: {
|
||||
data: [{
|
||||
spuId: 58484,
|
||||
spuName: 'Resin Drop Earrings',
|
||||
masterImage: 'https://img/1.jpg',
|
||||
href: '/drop-earrings-58484.html',
|
||||
minPriceWithCurrency: '$3.71',
|
||||
markPriceWithCurrency: 'MSRP $11.19',
|
||||
skus: [{ productId: 197814, quantity: 131 }, { productId: 197815, quantity: 98 }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: 'earrings' }, { verified: false });
|
||||
assert.equal(out.source, 'catalog');
|
||||
assert.equal(out.total, 1);
|
||||
assert.equal(out.products[0].name, 'Resin Drop Earrings');
|
||||
assert.equal(out.products[0].price, '$3.71');
|
||||
assert.equal(out.products[0].url, 'https://www.yehwang.com/drop-earrings-58484.html');
|
||||
assert.equal(out.products[0].sku, 197814);
|
||||
assert.equal(out.products[0].product_id, 58484);
|
||||
assert.equal(out.products[0].stock, 229);
|
||||
});
|
||||
|
||||
test('search_products: falls back to full sitemap for long-tail product queries', async () => {
|
||||
const rpc = fakeRpc((addr, method) => {
|
||||
if (method === '/product/list') return { result: { total: 0, data: { data: [] } } };
|
||||
return [];
|
||||
});
|
||||
const sitemap = { enabled: true, search: async () => [{ name: 'Irregular Resin Earrings', url: 'https://www.yehwang.com/irregular-resin-earrings-58484.html', is_product: true }] };
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty(), sitemap });
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: 'irregular shape resin earrings' }, { verified: false });
|
||||
assert.equal(out.source, 'sitemap');
|
||||
assert.match(out.products[0].url, /irregular-resin-earrings-58484\.html$/);
|
||||
});
|
||||
|
||||
test('search_orders: unverified contact is rejected (fail-closed)', async () => {
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_orders');
|
||||
await assert.rejects(() => tool.handler({}, { verified: false }), (err) => err instanceof ToolError && err.status === 403);
|
||||
});
|
||||
|
||||
test('search_orders: returns enriched order detail when orderNumber is given', async () => {
|
||||
const rpc = fakeRpc((addr, method) => {
|
||||
if (method === '/erp_order/getList') return { data: { list: [{ orderId: 501, orderNo: 'SO-501' }] } };
|
||||
if (method === '/erp_order/getDetail') {
|
||||
return {
|
||||
result: {
|
||||
orderId: 501,
|
||||
statusText: 'Shipped',
|
||||
paymentMethod: 'Mollie',
|
||||
shippingMethod: 'FedEx',
|
||||
trackingNumber: 'TRACK-1',
|
||||
shippingFirstname: 'Jan',
|
||||
shippingLastname: 'Jansen',
|
||||
shippingAddress_1: 'Main Street 1',
|
||||
shippingCity: 'Amsterdam',
|
||||
shippingPostcode: '1000AA',
|
||||
shippingCountry: 'Netherlands',
|
||||
products: [{ name: 'Hoop Earrings', model: 'SKU-1', quantity: 2, price: '€3.71', total: '€7.42', canReturn: 1 }],
|
||||
totals: [{ title: 'Subtotal', value: '€7.42', code: 'subtotal' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { result: { data: [] } };
|
||||
});
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_orders');
|
||||
const out = await tool.handler({ orderNumber: 'SO-501' }, { verified: true, email: 'jan@example.nl' });
|
||||
assert.equal(out.count, 1);
|
||||
assert.equal(out.orders[0].status, 'Shipped');
|
||||
assert.equal(out.orders[0].tracking_number, 'TRACK-1');
|
||||
assert.equal(out.orders[0].items[0].name, 'Hoop Earrings');
|
||||
assert.equal(out.orders[0].items[0].quantity, 2);
|
||||
assert.match(out.orders[0].shipping_address, /Amsterdam/);
|
||||
assert.equal(out.orders[0].totals[0].title, 'Subtotal');
|
||||
});
|
||||
|
||||
test('search_orders: always scopes email to verified identity header', async () => {
|
||||
const rpc = fakeRpc(() => ({ data: { list: [{ orderNo: 'SO-1', statusText: 'Shipped', total: '€50' }] } }));
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_orders');
|
||||
const out = await tool.handler({ customerEmail: 'attacker@example.com' }, { verified: true, email: 'Customer@Example.com' });
|
||||
assert.equal(out.count, 1);
|
||||
assert.equal(rpc.calls[0].params[0].email, 'customer@example.com');
|
||||
});
|
||||
|
||||
test('search_orders: orderNumber must belong to the verified customer (IDOR guard)', async () => {
|
||||
const rpc = fakeRpc(() => ({ data: { list: [{ orderId: 7, orderNo: 'SO-7' }] } }));
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_orders');
|
||||
const out = await tool.handler({ orderNumber: 'SO-999' }, { verified: true, email: 'c@example.com' });
|
||||
assert.equal(out.count, 0);
|
||||
assert.match(out.message, /No order/);
|
||||
});
|
||||
|
||||
test('search_inventory: unknown product returns 2xx with guidance', async () => {
|
||||
const rpc = fakeRpc((addr, method) => {
|
||||
if (method === '/eta/queryStockSummary') return { data: { instantStock: null } };
|
||||
return { data: { etaDate: '2026-11-01', availableQuantity: 100 } };
|
||||
});
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_inventory');
|
||||
const out = await tool.handler({ sku: 'SKU-123' }, { verified: false });
|
||||
assert.equal(out.product_id, 123);
|
||||
assert.equal(out.products.length, 0);
|
||||
assert.match(out.message, /No inventory data/);
|
||||
});
|
||||
|
||||
test('resolveSiteProfile: maps inbox id to site profile', () => {
|
||||
const cfg = {
|
||||
siteSearchUrl: 'https://www.yehwang.com/search.html?q=',
|
||||
sites: {
|
||||
defaultSite: 'www',
|
||||
profiles: {
|
||||
www: { site: 'www', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
|
||||
us: { site: 'us', tenantId: 2, searchUrl: 'https://us.yehwang.com/search.html?q=', siteMap: 'site-map-us.json' },
|
||||
},
|
||||
inboxes: { '7': 'us' },
|
||||
},
|
||||
};
|
||||
assert.equal(resolveSiteProfile(cfg, { inboxId: '7' }).site, 'us');
|
||||
assert.equal(resolveSiteProfile(cfg, { inboxId: '7' }).tenantId, 2);
|
||||
assert.equal(resolveSiteProfile(cfg, { inboxId: '1' }).site, 'www');
|
||||
});
|
||||
|
||||
test('resolveSiteProfile: language code maps to RPC language_id', () => {
|
||||
const cfg = {
|
||||
siteSearchUrl: 'https://www.yehwang.com/search.html?q=',
|
||||
defaultTenantId: 1,
|
||||
defaultLanguageId: 1,
|
||||
sites: {
|
||||
defaultSite: 'www',
|
||||
languageIds: { en: 1, nl: 2, de: 3 },
|
||||
profiles: {
|
||||
www: { site: 'www', language: 'en', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
|
||||
nl: { site: 'nl', language: 'nl', searchUrl: 'https://nl.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
|
||||
},
|
||||
inboxes: { '9': 'nl' },
|
||||
},
|
||||
};
|
||||
const profile = resolveSiteProfile(cfg, { inboxId: '9' });
|
||||
assert.equal(profile.tenantId, 1);
|
||||
assert.equal(profile.languageId, 2);
|
||||
});
|
||||
|
||||
test('search_products: sends reqContext with tenant/language and class metadata', async () => {
|
||||
const rpc = fakeRpc(() => ({ result: [{ name: 'earrings' }] }));
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
defaultTenantId: 1,
|
||||
defaultLanguageId: 1,
|
||||
sites: {
|
||||
defaultSite: 'www',
|
||||
languageIds: { en: 1, nl: 2 },
|
||||
profiles: { nl: { site: 'nl', language: 'nl', searchUrl: 'https://nl.yehwang.com/search.html?q=', siteMap: 'site-map.json' } },
|
||||
inboxes: { '9': 'nl' },
|
||||
},
|
||||
};
|
||||
const tools = createTools(cfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
await tool.handler({ query: 'earrings' }, { inboxId: '9', verified: false });
|
||||
const p = rpc.calls[0].params[0];
|
||||
assert.equal(p.reqContext.tenantId, 1);
|
||||
assert.equal(p.reqContext.languageId, 2);
|
||||
assert.match(p.reqContext._class, /^Yw%5CCommon%5CDto%5CReqContext/);
|
||||
assert.match(p.deviceInfo._class, /^Yw%5CMember%5CClient%5CDto%5CDeviceInfoDto/);
|
||||
});
|
||||
|
||||
test('search_products: uses the site map of the inbox site', async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
sites: {
|
||||
defaultSite: 'www',
|
||||
profiles: {
|
||||
www: { site: 'www', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
|
||||
us: { site: 'us', searchUrl: 'https://us.yehwang.com/search.html?q=', siteMap: 'site-map-us.json' },
|
||||
},
|
||||
inboxes: { '7': 'us' },
|
||||
},
|
||||
};
|
||||
const tools = createTools(cfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: 'earrings' }, { inboxId: '7', verified: false });
|
||||
assert.equal(out.site, 'us');
|
||||
assert.equal(out.source, 'site-map');
|
||||
assert.match(out.products[0].url, /^https:\/\/us\.yehwang\.com\//);
|
||||
assert.match(out.search_url, /^https:\/\/us\.yehwang\.com\/search\.html\?q=/);
|
||||
});
|
||||
|
||||
test('create_opportunity: note records site and inbox', async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
sites: {
|
||||
defaultSite: 'www',
|
||||
profiles: { us: { site: 'us', tenantId: 2, searchUrl: 'https://us.yehwang.com/search.html?q=', siteMap: 'site-map-us.json' } },
|
||||
inboxes: { '7': 'us' },
|
||||
},
|
||||
};
|
||||
const twenty = fakeTwenty();
|
||||
const tools = createTools(cfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
await tool.handler(
|
||||
{ company: 'Acme', contactName: 'John Doe', productNeed: 'gold hoops' },
|
||||
{ verified: true, email: 'john@acme.test', conversationUuid: 'conv-1', inboxId: '7' }
|
||||
);
|
||||
const note = twenty.calls.find((c) => c[0] === 'createNote');
|
||||
assert.match(note[1], /Site: us \(tenant 2\)/);
|
||||
assert.match(note[1], /inbox 7/);
|
||||
});
|
||||
|
||||
test('collect_info: stores partial slots per conversation', async () => {
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'collect_info');
|
||||
const out = await tool.handler(
|
||||
{ company: 'Acme BV', productNeed: 'gold hoops' },
|
||||
{ conversationUuid: 'conv-collect-1', verified: false }
|
||||
);
|
||||
assert.equal(out.saved, true);
|
||||
assert.equal(out.slots.company, 'Acme BV');
|
||||
assert.equal(out.ready, false);
|
||||
assert.deepEqual(out.missing, ['contactName']);
|
||||
});
|
||||
|
||||
test('create_opportunity: merges collect_info slots', async () => {
|
||||
const twenty = fakeTwenty();
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const collect = tools.find((t) => t.name === 'collect_info');
|
||||
const create = tools.find((t) => t.name === 'create_opportunity');
|
||||
const ctx = { conversationUuid: 'conv-collect-2', verified: true, email: 'john@acme.test' };
|
||||
await collect.handler({ company: 'Acme BV', contactName: 'John Doe', productNeed: 'gold hoops', quantity: '200' }, ctx);
|
||||
const out = await create.handler({}, ctx);
|
||||
assert.equal(out.created, true);
|
||||
const oppCall = twenty.calls.find((c) => c[0] === 'createOpportunity');
|
||||
assert.match(oppCall[1].name, /Acme BV — gold hoops/);
|
||||
const note = twenty.calls.find((c) => c[0] === 'createNote');
|
||||
assert.match(note[1], /Quantity: 200/);
|
||||
});
|
||||
|
||||
test('handoff_to_human: writes private note and assigns human', async () => {
|
||||
const calls = [];
|
||||
const client = {
|
||||
async sendPrivateNote(uuid, message) { calls.push(['note', uuid, message]); },
|
||||
async assignUser(uuid, userId) { calls.push(['assign', uuid, userId]); },
|
||||
};
|
||||
const tools = createTools({ ...baseCfg, handoffUserId: 1 }, { rpc: fakeRpc(() => []), twenty: fakeTwenty(), client });
|
||||
const tool = tools.find((t) => t.name === 'handoff_to_human');
|
||||
const out = await tool.handler({ reason: 'customer asked for human' }, { conversationUuid: 'conv-h1' });
|
||||
assert.equal(out.handoff, true);
|
||||
assert.equal(out.assigned, true);
|
||||
assert.match(calls[0][2], /customer asked for human/);
|
||||
assert.deepEqual(calls[1], ['assign', 'conv-h1', 1]);
|
||||
});
|
||||
|
||||
test('create_opportunity: same fingerprint does not create twice', async () => {
|
||||
const twenty = fakeTwenty();
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
const conv = `conv-fp-${Date.now()}`;
|
||||
const ctx = { verified: true, email: 'fp@acme.test', conversationUuid: conv };
|
||||
const args = { company: 'FP Acme', contactName: 'John Doe', productNeed: 'gold hoops' };
|
||||
const first = await tool.handler(args, ctx);
|
||||
const second = await tool.handler(args, ctx);
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(second.created, false);
|
||||
assert.equal(second.reason, 'already_created');
|
||||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
|
||||
});
|
||||
|
||||
test('create_opportunity: reuses cross-channel opportunity and syncs fields', async () => {
|
||||
const twenty = fakeTwenty({
|
||||
personId: 'person-1',
|
||||
existingOpps: [{ id: 'opp-old', name: 'Acme — gold hoops', stage: 'SCREENING', createdAt: '2026-01-01T00:00:00Z' }],
|
||||
});
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
const conv = `conv-reuse-${Date.now()}`;
|
||||
const out = await tool.handler(
|
||||
{ company: 'Acme', contactName: 'John Doe', productNeed: 'gold hoops', budget: '€500', closeDate: '2026-12-01' },
|
||||
{ verified: true, email: 'reuse@acme.test', conversationUuid: conv }
|
||||
);
|
||||
assert.equal(out.created, false);
|
||||
assert.equal(out.reason, 'cross_channel_existing');
|
||||
assert.equal(out.opportunity_id, 'opp-old');
|
||||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 0);
|
||||
const patch = twenty.calls.find((c) => c[0] === 'updateOpportunity');
|
||||
assert.equal(patch[2].amount, 500);
|
||||
assert.equal(patch[2].closeDate, '2026-12-01');
|
||||
assert.ok(twenty.calls.some((c) => c[0] === 'linkNoteToOpportunity' && c[2] === 'opp-old'));
|
||||
});
|
||||
|
||||
test('create_opportunity: CRM lookup failure is fail-closed', async () => {
|
||||
const twenty = fakeTwenty({ personId: 'person-1', oppsError: 'Twenty timeout' });
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
const out = await tool.handler(
|
||||
{ company: 'Acme', contactName: 'John Doe', productNeed: 'gold hoops' },
|
||||
{ verified: true, email: 'failclosed@acme.test', conversationUuid: `conv-crm-${Date.now()}` }
|
||||
);
|
||||
assert.equal(out.created, false);
|
||||
assert.equal(out.reason, 'crm_unavailable');
|
||||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 0);
|
||||
});
|
||||
|
||||
test('create_opportunity: advances stage when quantity + budget present', async () => {
|
||||
const twenty = fakeTwenty();
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
const out = await tool.handler(
|
||||
{ company: 'Stage Co', contactName: 'Jane Doe', productNeed: 'rings', quantity: '500', budget: '€1000' },
|
||||
{ verified: true, email: 'stage@acme.test', conversationUuid: `conv-stage-${Date.now()}` }
|
||||
);
|
||||
assert.equal(out.created, true);
|
||||
const stageCall = twenty.calls.find((c) => c[0] === 'updateOpportunityStage');
|
||||
assert.ok(stageCall);
|
||||
assert.equal(stageCall[2], 'MEETING');
|
||||
});
|
||||
|
||||
test('search_email_history: unverified is rejected', async () => {
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty(), mailHistory: { enabled: true, search: async () => [] } });
|
||||
const tool = tools.find((t) => t.name === 'search_email_history');
|
||||
await assert.rejects(
|
||||
() => tool.handler({ query: 'rings' }, { verified: false, email: 'a@b.com' }),
|
||||
(err) => err instanceof ToolError && err.status === 403
|
||||
);
|
||||
});
|
||||
|
||||
test('search_email_history: scopes to verified email, ignores args.email', async () => {
|
||||
const calls = [];
|
||||
const tools = createTools(baseCfg, {
|
||||
rpc: fakeRpc(() => []),
|
||||
twenty: fakeTwenty(),
|
||||
mailHistory: { enabled: true, search: async (q, email) => { calls.push([q, email]); return []; } },
|
||||
});
|
||||
const tool = tools.find((t) => t.name === 'search_email_history');
|
||||
const out = await tool.handler(
|
||||
{ query: 'gold hoops', email: 'victim@x.com' },
|
||||
{ verified: true, email: 'owner@y.com' }
|
||||
);
|
||||
assert.deepEqual(calls, [['gold hoops', 'owner@y.com']]);
|
||||
assert.equal(out.email_scope, 'owner@y.com');
|
||||
assert.equal(out.count, 0);
|
||||
});
|
||||
|
||||
test('create_opportunity: requires verified contact', async () => {
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
await assert.rejects(
|
||||
() => tool.handler({ company: 'A', contactName: 'B', productNeed: 'C' }, { verified: false }),
|
||||
(err) => err instanceof ToolError && err.status === 403
|
||||
);
|
||||
});
|
||||
|
||||
test('create_opportunity: dedupes by existing opportunity', async () => {
|
||||
const twenty = fakeTwenty({ personId: 'person-1', existingOpps: [{ id: 'opp-old', name: 'Old', stage: 'NEW' }] });
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
const out = await tool.handler(
|
||||
{ company: 'Acme', contactName: 'John Doe', productNeed: 'gold hoops' },
|
||||
{ verified: true, email: 'john@acme.test', conversationUuid: 'conv-1' }
|
||||
);
|
||||
assert.equal(out.created, false);
|
||||
assert.equal(out.opportunity_id, 'opp-old');
|
||||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 0);
|
||||
});
|
||||
|
||||
test('create_opportunity: creates opportunity + note + follow-up task', async () => {
|
||||
const twenty = fakeTwenty();
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
const out = await tool.handler(
|
||||
{ company: 'Acme', contactName: 'John Doe', productNeed: 'gold hoops', budget: '€500', closeDate: '2026-12-01' },
|
||||
{ verified: true, email: 'john@acme.test', conversationUuid: 'conv-1', inboxId: '2' }
|
||||
);
|
||||
assert.equal(out.created, true);
|
||||
assert.equal(out.note_id, 'note-1');
|
||||
assert.equal(out.task_id, 'task-1');
|
||||
const oppCall = twenty.calls.find((c) => c[0] === 'createOpportunity');
|
||||
assert.equal(oppCall[1].amount, 500);
|
||||
assert.equal(oppCall[1].closeDate, '2026-12-01');
|
||||
assert.equal(oppCall[1].conversationId, 'conv-1');
|
||||
const personCall = twenty.calls.find((c) => c[0] === 'createPerson');
|
||||
assert.equal(personCall[1].companyId, 'company-new');
|
||||
const companyIdx = twenty.calls.findIndex((c) => c[0] === 'createCompany');
|
||||
const personIdx = twenty.calls.findIndex((c) => c[0] === 'createPerson');
|
||||
assert.ok(companyIdx >= 0 && personIdx > companyIdx, 'company must be created before person');
|
||||
});
|
||||
Reference in New Issue
Block a user