Files
crm-ai/bridge/test/tools.test.js

572 lines
26 KiB
JavaScript

'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: unverified lead is created from contact name (policy A)', 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: 'Anon Co', contactName: 'Anna Klein', productNeed: '500 rings' },
{ verified: false, conversationUuid: `conv-anon-${Date.now()}` }
);
assert.equal(out.created, true);
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
const note = twenty.calls.find((c) => c[0] === 'createNote');
assert.match(note[1], /Contact: Anna Klein/);
assert.doesNotMatch(note[1], /<>/);
});
test('create_opportunity: unverified lead keeps the email provided in chat', 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: 'Anon Co', contactName: 'Anna Klein', email: 'anna@anon.test', productNeed: '500 rings' },
{ verified: false, conversationUuid: `conv-anon-mail-${Date.now()}` }
);
assert.equal(out.created, true);
const person = twenty.calls.find((c) => c[0] === 'createPerson');
assert.equal(person[1].email, 'anna@anon.test');
});
test('create_opportunity: still requires company + contact + need', 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', productNeed: 'C' }, { verified: false, conversationUuid: 'conv-missing' }),
(err) => err instanceof ToolError && err.status === 400
);
});
test('collect_info: stores the email slot', 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', contactName: 'John Doe', email: 'john@acme.test', productNeed: 'gold hoops' },
{ conversationUuid: 'conv-collect-email', verified: false }
);
assert.equal(out.slots.email, 'john@acme.test');
assert.deepEqual(out.missing, []);
});
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');
});