// Exercises the compiled nodes against the mock router: real HTTP, real LangChain model. import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createMockServer, TINY_PNG_BASE64 } from './mock-server.mjs'; const require = createRequire(import.meta.url); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const { LmChatTeamRouter } = require(join(root, 'dist/nodes/LmChatTeamRouter/LmChatTeamRouter.node.js')); const { Teamorouter } = require(join(root, 'dist/nodes/Teamorouter/Teamorouter.node.js')); const { TeamorouterApi } = require(join(root, 'dist/credentials/TeamorouterApi.credentials.js')); const apiKey = 'test-key'; const mock = createMockServer({ apiKey }); const baseUrl = await mock.listen(); /** Mirrors n8n's httpRequestWithAuthentication closely enough for the code paths under test. */ async function httpRequest(_credentialName, options) { const url = `${options.baseURL ?? baseUrl}${options.url}`; const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : undefined; let response; try { response = await fetch(url, { method: options.method ?? 'GET', headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }, body: options.body === undefined ? undefined : JSON.stringify(options.body), signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout), }); } catch (error) { const wrapped = new Error(error?.cause?.message ?? error.message); wrapped.httpCode = 504; throw wrapped; } const text = await response.text(); let parsed = text; try { parsed = JSON.parse(text); } catch { // keep the raw text } if (!response.ok) { const error = new Error(`Request failed with status code ${response.status}`); error.httpCode = response.status; error.response = { status: response.status, data: parsed }; throw error; } return parsed; } function makeContext(parameters, { continueOnFail = false } = {}) { return { getCredentials: async () => ({ apiKey, baseUrl: `${baseUrl}/v1` }), getNodeParameter: (name, _itemIndex, fallback) => parameters[name] === undefined ? fallback : parameters[name], getNode: () => ({ name: 'TeamoRouter', type: 'n8n-nodes-teamorouter', typeVersion: 1, position: [0, 0], parameters, }), continueOnFail: () => continueOnFail, getInputData: () => [{ json: {} }], logger: { debug() {}, info() {}, warn() {}, error() {} }, helpers: { httpRequestWithAuthentication: httpRequest, prepareBinaryData: async (data, fileName, mimeType) => { const buffer = Buffer.from(data); return { data: buffer.toString('base64'), fileName, mimeType, fileExtension: 'png', fileSize: buffer.length, }; }, returnJsonArray: (data) => (Array.isArray(data) ? data : [data]).map((json) => ({ json })), constructExecutionMetaData: (items) => items, }, }; } const lastRequest = () => mock.requests[mock.requests.length - 1]; const chatModel = new LmChatTeamRouter(); const actionNode = new Teamorouter(); let failures = 0; async function test(name, fn) { try { await fn(); console.log(`ok ${name}`); } catch (error) { failures += 1; console.log(`FAIL ${name}\n ${error.message}`); } } await test('credential points at GET /v1/models and the endpoint enforces the key', async () => { const test = new TeamorouterApi().test; assert.equal(test.request.url, '/models'); assert.match(test.request.baseURL, /\$credentials\.baseUrl/); const ok = await fetch(`${baseUrl}/v1/models`, { headers: { authorization: `Bearer ${apiKey}` } }); assert.equal(ok.status, 200); const denied = await fetch(`${baseUrl}/v1/models`, { headers: { authorization: 'Bearer wrong' } }); assert.equal(denied.status, 401); }); await test('chat model dropdown lists chat models with prices and hides image models', async () => { const options = await chatModel.methods.loadOptions.getChatModels.call(makeContext({})); const ids = options.map((option) => option.value); assert.ok(ids.includes('gpt-5.6-sol'), 'gpt-5.6-sol missing'); assert.ok(ids.includes('gemini-3.1-pro-preview'), 'gemini missing'); assert.ok(!ids.includes('gpt-image-2'), 'image model leaked into the chat list'); assert.ok(!ids.includes('nano-banana-2.1'), 'image model leaked into the chat list'); const sol = options.find((option) => option.value === 'gpt-5.6-sol'); assert.match(sol.description, /openai/); assert.match(sol.description, /\$0\.53 in \/ \$3\.18 out per 1M tokens/); const gemini = options.find((option) => option.value === 'gemini-3.1-pro-preview'); assert.match(gemini.description, /\$0\.29 in \/ \$1\.73 out per 1M tokens/); assert.deepEqual(ids, [...ids].sort(), 'options must be sorted'); }); await test('image dropdown lists only image models', async () => { const options = await actionNode.methods.loadOptions.getImageModels.call(makeContext({})); assert.deepEqual( options.map((option) => option.value), ['gpt-image-2', 'nano-banana-2.1'], ); }); await test('supplyData returns a working LangChain chat model', async () => { const { response: model } = await chatModel.supplyData.call( makeContext({ model: 'claude-sonnet-5', options: {} }), 0, ); const answer = await model.invoke('ping'); assert.equal(answer.content, 'echo: ping'); const request = lastRequest(); assert.equal(request.url, '/v1/chat/completions'); assert.equal(request.headers.authorization, `Bearer ${apiKey}`); assert.equal(request.body.model, 'claude-sonnet-5'); assert.equal(request.body.messages.at(-1).content, 'ping'); assert.equal(request.body.max_tokens, undefined, 'maxTokens -1 must not be sent'); }); await test('supplyData works for GPT models served through the Responses API', async () => { const { response: model } = await chatModel.supplyData.call( makeContext({ model: 'gpt-5.6-sol', options: {} }), 0, ); const answer = await model.invoke('ping'); // The Responses API path returns content blocks instead of a plain string. const text = Array.isArray(answer.content) ? answer.content.map((block) => block.text).join('') : answer.content; assert.equal(text, 'echo: ping'); const request = lastRequest(); // LangChain routes gpt-5.6* to /v1/responses on its own; TeamoRouter serves that endpoint for GPT models. assert.ok( ['/v1/chat/completions', '/v1/responses'].includes(request.url), `unexpected endpoint ${request.url}`, ); assert.equal(request.body.model, 'gpt-5.6-sol'); // The router answers both endpoints, so the model must not care which one was used. assert.match(request.url, /^\/v1\/(chat\/completions|responses)$/); }); await test('options reach the request body (service tier, extra body, limits)', async () => { const { response: model } = await chatModel.supplyData.call( makeContext({ model: 'claude-sonnet-5', options: { temperature: 0.2, maxTokens: 128, topP: 0.5, serviceTier: 'fast', forceJsonResponse: true, extraBody: '{"top_k":7}', }, }), 0, ); await model.invoke('json please'); const body = lastRequest().body; assert.equal(body.model, 'claude-sonnet-5'); assert.equal(body.temperature, 0.2); assert.equal(body.top_p, 0.5); assert.equal(body.max_tokens, 128); assert.equal(body.service_tier, 'fast'); assert.deepEqual(body.response_format, { type: 'json_object' }); assert.equal(body.top_k, 7); }); await test('chat model rejects a broken Extra Body JSON', async () => { await assert.rejects( async () => { await chatModel.supplyData.call( makeContext({ model: 'gpt-5.6-sol', options: { extraBody: 'not-json' } }), 0, ); }, (error) => { assert.match(error.message, /Extra Body must be a JSON object/); return true; }, ); }); await test('chat model rejects an empty model selection', async () => { await assert.rejects( async () => { await chatModel.supplyData.call(makeContext({ model: '', options: {} }), 0); }, (error) => { assert.match(error.message, /No model selected/); return true; }, ); }); await test('action node: message a model returns the completion with usage', async () => { const context = makeContext({ resource: 'chat', operation: 'message', model: 'claude-sonnet-5', prompt: 'привет', options: { systemPrompt: 'be brief' }, }); const [items] = await actionNode.execute.call(context); assert.equal(items.length, 1); assert.equal(items[0].json.choices[0].message.content, 'echo: привет'); assert.equal(items[0].json.usage.total_tokens, 15); const body = lastRequest().body; assert.deepEqual(body.messages[0], { role: 'system', content: 'be brief' }); assert.deepEqual(body.messages[1], { role: 'user', content: 'привет' }); assert.equal(lastRequest().url, '/v1/chat/completions'); }); await test('action node: list models returns the router payload', async () => { const [items] = await actionNode.execute.call( makeContext({ resource: 'model', operation: 'list' }), ); assert.equal(items.length, 6); assert.equal(items[0].json.id, 'gpt-5.6-sol'); assert.equal(lastRequest().url, '/v1/models'); }); await test('action node: generate image writes a PNG binary', async () => { const [items] = await actionNode.execute.call( makeContext({ resource: 'image', operation: 'generate', imageModel: 'gpt-image-2', imagePrompt: 'orange cat', binaryPropertyName: 'data', }), ); const binary = items[0].binary.data; assert.equal(binary.mimeType, 'image/png'); assert.equal(binary.fileExtension, 'png'); assert.equal(binary.data, TINY_PNG_BASE64); assert.equal(Buffer.from(binary.data, 'base64').subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); assert.deepEqual(lastRequest().body, { model: 'gpt-image-2', prompt: 'orange cat' }); }); await test('action node: router errors become actionable messages', async () => { const cases = [ ['fail-401', /API key/], ['fail-402', /insufficient balance/], ['fail-404', /not found/], ]; for (const [model, expected] of cases) { await assert.rejects( async () => { await actionNode.execute.call( makeContext({ resource: 'chat', operation: 'message', model, prompt: 'hi', options: {} }), ); }, (error) => { assert.match(error.message, expected); return true; }, `${model} did not produce a hint`, ); } }); await test('action node: image without b64_json fails clearly', async () => { await assert.rejects( async () => { await actionNode.execute.call( makeContext({ resource: 'image', operation: 'generate', imageModel: 'gpt-image-2', imagePrompt: 'noimage please', binaryPropertyName: 'data', }), ); }, // 400 from the router: no hint, but the request must fail instead of returning empty binary. (error) => { assert.match(error.message, /400/); return true; }, ); }); await test('action node: continueOnFail returns the error as item data', async () => { const [items] = await actionNode.execute.call( makeContext( { resource: 'chat', operation: 'message', model: 'fail-500', prompt: 'hi', options: {} }, { continueOnFail: true }, ), ); assert.equal(items.length, 1); assert.match(items[0].json.error, /500/); }); await test('action node: timeout is applied to the request', async () => { await assert.rejects(async () => { await actionNode.execute.call( makeContext({ resource: 'chat', operation: 'message', model: 'slow-model', prompt: 'hi', options: { timeout: 300 }, }), ); }); }); await mock.close(); console.log(`\n${failures === 0 ? 'All node tests passed' : `${failures} test(s) failed`}`); process.exitCode = failures === 0 ? 0 : 1;