348 lines
12 KiB
JavaScript
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;
|