191 lines
7.8 KiB
JavaScript
191 lines
7.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
api,
|
|
HarborApiError,
|
|
request,
|
|
} from '../../.test-dist/src/web/api/harborClient.js';
|
|
|
|
const response = (status, error) => ({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => ({ success: false, error }),
|
|
});
|
|
|
|
test('frontend exposes retry only for retryable structured errors', async () => {
|
|
await assert.rejects(
|
|
request('/api/test', {}, async () => response(502, {
|
|
code: 'PROVIDER_UNAVAILABLE',
|
|
message: 'untrusted server copy',
|
|
retryable: false,
|
|
correlationId: 'provider-reference',
|
|
})),
|
|
(error) => {
|
|
assert.ok(error instanceof HarborApiError);
|
|
assert.equal(error.message, 'Провайдер подписки временно недоступен.');
|
|
assert.equal(error.retryable, true);
|
|
assert.equal(error.correlationId, 'provider-reference');
|
|
return true;
|
|
},
|
|
);
|
|
|
|
await assert.rejects(
|
|
request('/api/test', {}, async () => response(400, {
|
|
code: 'SUBSCRIPTION_INVALID',
|
|
retryable: true,
|
|
})),
|
|
(error) => error.retryable === false,
|
|
);
|
|
});
|
|
|
|
test('network failures become retryable control errors', async () => {
|
|
await assert.rejects(
|
|
request('/api/state', {}, async () => { throw new TypeError('fetch failed'); }),
|
|
(error) => error.code === 'CONTROL_UNREACHABLE' && error.retryable === true,
|
|
);
|
|
});
|
|
|
|
test('local unknown errors get a safe message and diagnostic reference', () => {
|
|
const error = new HarborApiError({ code: 'NOT_A_REAL_CODE' });
|
|
|
|
assert.equal(error.code, 'UNKNOWN');
|
|
assert.equal(error.message, 'Не удалось выполнить действие.');
|
|
assert.equal(typeof error.correlationId, 'string');
|
|
assert.ok(error.correlationId.length >= 8);
|
|
});
|
|
|
|
test('typed endpoint facade preserves exact request contracts and raw payload identity', async () => {
|
|
const calls = [];
|
|
const payload = { success: true, marker: 'raw' };
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = async (url, options) => {
|
|
calls.push([url, options]);
|
|
return { ok: true, status: 200, json: async () => payload };
|
|
};
|
|
const signal = new AbortController().signal;
|
|
try {
|
|
const cases = [
|
|
[() => api.version(), '/api/version', {}],
|
|
[() => api.subscription.validate('https://sub', { signal }), '/api/subscription/validate', {
|
|
method: 'POST', body: JSON.stringify({ url: 'https://sub' }), signal,
|
|
}],
|
|
[() => api.subscription.fetch('https://sub'), '/api/subscription/fetch', {
|
|
method: 'POST', body: JSON.stringify({ url: 'https://sub' }),
|
|
}],
|
|
[() => api.subscription.refresh(), '/api/subscription/refresh', { method: 'POST' }],
|
|
[() => api.subscription.forget(), '/api/subscription', { method: 'DELETE' }],
|
|
[() => api.profiles.add('Личный', 'https://sub', 4), '/api/profiles', {
|
|
method: 'POST', body: JSON.stringify({ label: 'Личный', url: 'https://sub', expectedRevision: 4 }),
|
|
}],
|
|
[() => api.profiles.rename('profile-1', 'Работа', 5), '/api/profiles/profile-1', {
|
|
method: 'PATCH', body: JSON.stringify({ label: 'Работа', expectedRevision: 5 }),
|
|
}],
|
|
[() => api.profiles.selectServer('profile-1', 'server-1', 6), '/api/profiles/profile-1/server', {
|
|
method: 'PUT', body: JSON.stringify({ serverId: 'server-1', expectedRevision: 6 }),
|
|
}],
|
|
[() => api.profiles.activate('profile-1', 7), '/api/profiles/profile-1/activate', {
|
|
method: 'POST', body: JSON.stringify({ expectedRevision: 7 }),
|
|
}],
|
|
[() => api.apply('profile-1', 'server-1', 8), '/api/apply', {
|
|
method: 'POST', body: JSON.stringify({ profileId: 'profile-1', serverId: 'server-1', expectedRevision: 8 }),
|
|
}],
|
|
[() => api.gatewayAuto.setEnabled(true), '/api/gateway-auto', {
|
|
method: 'POST', body: JSON.stringify({ enabled: true }),
|
|
}],
|
|
[() => api.routeRules.update([{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }], 7), '/api/route-rules/v2', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
rules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }],
|
|
expectedRulesRevision: 7,
|
|
rulesContractVersion: 2,
|
|
}),
|
|
}],
|
|
[() => api.devices.list(), '/api/devices', {}],
|
|
[() => api.devices.refresh(), '/api/devices/refresh', { method: 'POST' }],
|
|
[() => api.devices.resetTraffic(8), '/api/devices/traffic', {
|
|
method: 'DELETE', body: JSON.stringify({ expectedRevision: 8 }),
|
|
}],
|
|
[() => api.devices.update('dev_1', { alias: 'TV' }, 8), '/api/devices/dev_1', {
|
|
method: 'PUT', body: JSON.stringify({ alias: 'TV', expectedRevision: 8 }),
|
|
}],
|
|
[() => api.devices.setPolicy('dev_1', 'direct', 9), '/api/devices/dev_1/policy', {
|
|
method: 'PUT', body: JSON.stringify({ mode: 'direct', expectedRevision: 9 }),
|
|
}],
|
|
[() => api.devices.createTag('Дом', 10), '/api/device-tags', {
|
|
method: 'POST', body: JSON.stringify({ name: 'Дом', expectedRevision: 10 }),
|
|
}],
|
|
[() => api.devices.renameTag('tag_1', 'Работа', 11), '/api/device-tags/tag_1', {
|
|
method: 'PUT', body: JSON.stringify({ name: 'Работа', expectedRevision: 11 }),
|
|
}],
|
|
[() => api.devices.deleteTag('tag_1', 12), '/api/device-tags/tag_1', {
|
|
method: 'DELETE', body: JSON.stringify({ expectedRevision: 12 }),
|
|
}],
|
|
[() => api.diagnostics.connectivity(), '/api/diagnostics/connectivity', {
|
|
method: 'POST', body: JSON.stringify({ target: null }),
|
|
}],
|
|
[() => api.diagnostics.updateSettings({ customServices: [], hiddenServiceIds: [] }, 10), '/api/diagnostics/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
settings: { customServices: [], hiddenServiceIds: [] },
|
|
expectedRevision: 10,
|
|
}),
|
|
}],
|
|
[() => api.traffic.live(), '/api/traffic/live', {}],
|
|
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
|
|
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
|
|
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', {
|
|
method: 'POST', body: JSON.stringify({ serverIds: ['one', 'two'] }),
|
|
}],
|
|
];
|
|
|
|
for (const [invoke, url, options] of cases) {
|
|
assert.equal(await invoke(), payload);
|
|
const [actualUrl, actualOptions] = calls.at(-1);
|
|
assert.equal(actualUrl, url);
|
|
assert.deepEqual(actualOptions, {
|
|
...options,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('request preserves caller headers, AbortError identity and JSON fallbacks', async () => {
|
|
let received;
|
|
const value = { ok: 'raw' };
|
|
assert.equal(await request('/api/test', {
|
|
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
|
}, async (url, options) => {
|
|
received = [url, options];
|
|
return { ok: true, status: 200, json: async () => value };
|
|
}), value);
|
|
assert.deepEqual(received, ['/api/test', {
|
|
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
|
}]);
|
|
|
|
const aborted = Object.assign(new Error('cancelled'), { name: 'AbortError' });
|
|
await assert.rejects(
|
|
request('/api/test', {}, async () => { throw aborted; }),
|
|
(error) => error === aborted,
|
|
);
|
|
await assert.rejects(
|
|
request('/api/test', {}, async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => { throw new Error('invalid json'); },
|
|
})),
|
|
(error) => error.code === 'UNKNOWN' && error.status === 500,
|
|
);
|
|
await assert.rejects(
|
|
request('/api/test', {}, async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
json: async () => { throw new Error('invalid json'); },
|
|
})),
|
|
(error) => error.code === 'CONTROL_UNREACHABLE' && error.status === 503,
|
|
);
|
|
});
|