Files
iptvnator/apps/web-backend/src/app/provider-error.spec.ts
T
4grayandClaude Fable 5 d73551d780 fix(pwa): tolerate broken IPv6 routes and surface provider error codes (#1404)
* fix(pwa): tolerate broken IPv6 routes and surface provider error codes

Node's happy-eyeballs racing gives each address attempt only 250 ms, so a
dual-stack provider hostname behind an IPv4-only VPN namespace (Gluetun,
WireGuard) exhausts every attempt and the web backend answered with a bare
502. Raise the per-attempt budget to 2500 ms at startup — keeping the
IPv6->IPv4 fallback automatic — while an explicit
--network-family-autoselection-attempt-timeout from NODE_OPTIONS still wins.

Provider proxy failures now log the target hostname plus the underlying
Node error codes (never the URL query, which carries credentials) and
return the primary code in the error body, so the app shows
"Bad Gateway (ETIMEDOUT)" instead of an unexplained 502.

Closes #1400

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pwa): surface proxy network codes in import/refresh toasts, document runtime contract

Codex: /parse connection failures arrive as HTTP 500 whose body carries the
new code field, but fetchFromUrl()/refreshPlaylist() mapped only the HTTP
status, so the toast stayed generic. Append the code to the translated
message (regression-covered for both flows).

Greptile: record the web-backend happy-eyeballs/diagnostics runtime contract
in CLAUDE.md's monorepo structure section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pwa): drop provider reason phrases from logs, honor underscore flag spellings

Codex round 2: the HTTP reason phrase is provider-controlled and can echo
the credential-bearing request URL, so the failure log now carries only the
numeric status; and Node treats underscores and dashes interchangeably in
flag names, so the explicit-override check normalizes spelling before
matching (verified live: --network_family_autoselection_attempt_timeout
applies in both CLI and NODE_OPTIONS forms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pwa): match the timeout flag as a complete NODE_OPTIONS token

Codex round 3 (P3): a raw substring search also fired on the flag text
embedded in another option's value, silently skipping the 2500 ms default.
Tokenize NODE_OPTIONS on whitespace and match the normalized option name
exactly or with '='.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:09:05 +02:00

181 lines
5.8 KiB
TypeScript

import {
collectProviderErrorCodes,
logProviderRequestFailure,
normalizeProviderError,
} from './provider-error';
function errorWithCode(message: string, code: string): Error {
return Object.assign(new Error(message), { code });
}
describe('collectProviderErrorCodes', () => {
it('collects the aggregate code and the per-address member codes', () => {
const aggregate = Object.assign(
new AggregateError([
errorWithCode('connect ETIMEDOUT 104.21.0.1:80', 'ETIMEDOUT'),
errorWithCode(
'connect ENETUNREACH 2606:4700::1:80',
'ENETUNREACH'
),
]),
{ code: 'ETIMEDOUT' }
);
expect(collectProviderErrorCodes(aggregate)).toEqual([
'ETIMEDOUT',
'ENETUNREACH',
]);
});
it('walks the cause chain', () => {
const wrapped = new Error('request failed', {
cause: errorWithCode('getaddrinfo ENOTFOUND host', 'ENOTFOUND'),
});
expect(collectProviderErrorCodes(wrapped)).toEqual(['ENOTFOUND']);
});
it('survives cyclic error graphs', () => {
const first = errorWithCode('first', 'ECONNRESET') as Error & {
cause?: unknown;
};
const second = new Error('second', { cause: first });
first.cause = second;
expect(collectProviderErrorCodes(second)).toEqual(['ECONNRESET']);
});
it('ignores non-string and empty codes', () => {
expect(
collectProviderErrorCodes(
Object.assign(new Error('numeric'), { code: 502 })
)
).toEqual([]);
expect(
collectProviderErrorCodes(
Object.assign(new Error('empty'), { code: '' })
)
).toEqual([]);
expect(collectProviderErrorCodes(undefined)).toEqual([]);
expect(collectProviderErrorCodes('ETIMEDOUT')).toEqual([]);
});
});
describe('normalizeProviderError', () => {
it('keeps the HTTP response status and statusText untouched', () => {
const httpError = Object.assign(new Error('Forbidden'), {
code: 'ERR_BAD_REQUEST',
response: { status: 403, statusText: 'Forbidden' },
});
expect(normalizeProviderError(httpError)).toEqual({
message: 'Forbidden',
status: 403,
});
});
it('keeps a statusText-only response on the HTTP branch', () => {
const partial = Object.assign(new Error('partial'), {
response: { statusText: 'Gateway Timeout' },
});
expect(normalizeProviderError(partial)).toEqual({
message: 'Gateway Timeout',
status: 502,
});
});
it('names the network code in message and body for non-HTTP failures', () => {
expect(
normalizeProviderError(errorWithCode('timeout', 'ETIMEDOUT'))
).toEqual({
message: 'Bad Gateway (ETIMEDOUT)',
status: 502,
code: 'ETIMEDOUT',
});
});
it('falls back to a bare bad gateway when no code exists', () => {
expect(normalizeProviderError(new Error('boom'))).toEqual({
message: 'Bad Gateway',
status: 502,
});
});
});
describe('logProviderRequestFailure', () => {
it('logs hostname and codes but never the URL query string', () => {
const logger = jest.fn();
logProviderRequestFailure({
error: errorWithCode('timeout', 'ETIMEDOUT'),
route: '/xtream',
url: 'http://provider.example/player_api.php?username=user&password=pass',
logger,
});
expect(logger).toHaveBeenCalledTimes(1);
const line = logger.mock.calls[0][0] as string;
expect(line).toContain('/xtream');
expect(line).toContain('provider.example');
expect(line).toContain('ETIMEDOUT');
expect(line).not.toContain('username');
expect(line).not.toContain('password');
expect(line).not.toContain('player_api.php');
});
it('describes HTTP failures by numeric status only — the reason phrase is provider-controlled', () => {
const logger = jest.fn();
logProviderRequestFailure({
error: Object.assign(new Error('Unauthorized'), {
response: {
status: 401,
statusText:
'Unauthorized username=alice&password=secret-pass',
},
}),
route: '/stalker',
url: new URL('http://portal.example/portal.php'),
logger,
});
const line = logger.mock.calls[0][0] as string;
expect(line).toContain('HTTP 401');
expect(line).not.toContain('alice');
expect(line).not.toContain('secret-pass');
});
it('logs a generic HTTP error for a statusText-only response', () => {
const logger = jest.fn();
logProviderRequestFailure({
error: Object.assign(new Error('partial'), {
response: { statusText: 'Gateway Timeout' },
}),
route: '/stalker',
url: new URL('http://portal.example/portal.php'),
logger,
});
const line = logger.mock.calls[0][0] as string;
expect(line).toContain('HTTP error');
expect(line).not.toContain('Gateway Timeout');
});
it('tolerates unparseable URLs and codeless errors', () => {
const logger = jest.fn();
logProviderRequestFailure({
error: new Error('boom'),
route: '/parse',
url: 'not a url',
logger,
});
const line = logger.mock.calls[0][0] as string;
expect(line).toContain('<invalid url>');
expect(line).toContain('unknown network error');
});
});