n8n-nodes-teamorouter/test/run-node-tests.mjs

348 lines
12 KiB
JavaScript

// 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;