'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'); }); test('sites.json: www/eu -> EUR, us -> USD', () => { const sites = require('../config/sites.json'); assert.equal(sites.profiles.www.currencyCode, 'EUR'); assert.equal(sites.profiles.eu.currencyCode, 'EUR'); assert.equal(sites.profiles.us.currencyCode, 'USD'); }); test('search_products: passes the site currency to the product RPC', async () => { const rpc = fakeRpc(() => []); const cfg = { ...baseCfg, sites: { defaultSite: 'www', profiles: { www: { site: 'www', tenantId: 2, languageId: 1, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=' } }, inboxes: { '1': 'www' }, }, }; const tools = createTools(cfg, { rpc, twenty: fakeTwenty() }); const tool = tools.find((t) => t.name === 'search_products'); await tool.handler({ query: 'rings' }, { inboxId: '1' }); const call = rpc.calls[0]; assert.equal(call.method, '/product/list'); assert.equal(call.params[0].currencyCode, 'EUR'); assert.equal(call.params[0].tenantBaseCurrency, 'EUR'); assert.equal(call.params[0].reqContext.currencyCode, 'EUR'); });