Files
harbor-net/test/web/api-errors.test.js
T
dokril aa9c959368
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
Refactor VPN proxy components and update related behavior
2026-08-11 01:27:46 +03:00

170 lines
6.7 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' }], 7), '/api/route-rules', {
method: 'PUT',
body: JSON.stringify({
rules: [{ type: 'domain', value: 'example.com' }],
expectedRulesRevision: 7,
}),
}],
[() => api.devices.list(), '/api/devices', {}],
[() => api.devices.refresh(), '/api/devices/refresh', { method: 'POST' }],
[() => 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.diagnostics.connectivity(), '/api/diagnostics/connectivity', {
method: 'POST', body: JSON.stringify({ services: [], target: null }),
}],
[() => 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,
);
});