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.apply('server-1'), '/api/apply', { method: 'POST', body: JSON.stringify({ serverId: 'server-1', selectedTag: 'server-1' }), }], [() => 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(['one', 'two']), '/api/servers/ping-all', { 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, ); });