diff --git a/app/shared/src/hub/hubClientTransportCatch.test.ts b/app/shared/src/hub/hubClientTransportCatch.test.ts new file mode 100644 index 000000000..6a76d8e9a --- /dev/null +++ b/app/shared/src/hub/hubClientTransportCatch.test.ts @@ -0,0 +1,554 @@ +// real_tested=true — every export is exercised directly against the real +// AppError / DOMException / Headers implementations. Only side-effect sinks +// are doubled: console.error and globalErrorReporter.report are vi.spyOn'd +// for the default-effects path, and timers are faked (vi.useFakeTimers) for +// abort-timeout behavior. No fetch stub is needed: this module never +// resolves fetch. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AppError, globalErrorReporter } from '../errors'; +import { + applyDefaultHubRequestCatchEffects, + applyHubRequestCatchEffects, + applyTokenRefreshFailureReport, + buildTokenRefreshFailedLogPrefix, + buildTokenRefreshReportContext, + classifyHubRequestCatch, + createHubAbortTimeout, + hasTokenRefreshHandler, + planHubRequestCatchEffects, + planRefreshedTokenRetry, + planTokenRefreshFailureReport, + prepareHubRequestContext, + prepareHubRequestContextFromClient, + prepareMultipartUploadContext, + prepareMultipartUploadContextFromClient, + resolveHubRequestCatch, + shouldEnterTokenRefreshRecovery, + shouldRetryWithRefreshedToken, + withHubAbortTimeout, +} from './hubClientTransportCatch'; + +const CATCH_CONTEXT = { + timeoutMs: 12_000, + method: 'POST', + path: '/web/projects', +}; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('hubClientTransportCatch (#1102)', () => { + it('classifies AbortError DOMExceptions as timeout catches', () => { + const abort = new DOMException('Aborted', 'AbortError'); + expect(classifyHubRequestCatch(abort)).toEqual({ kind: 'timeout' }); + expect(classifyHubRequestCatch(new DOMException('other reason', 'AbortError'))).toEqual({ + kind: 'timeout', + }); + // Non-AbortError DOMExceptions fall through to other. + expect(classifyHubRequestCatch(new DOMException('late', 'TimeoutError')).kind).toBe( + 'other', + ); + }); + + it('classifies AppError instances as app catches', () => { + const appError = new AppError({ error: { code: 'UNAUTHORIZED', message: 'nope' } }, 401); + const classified = classifyHubRequestCatch(appError); + expect(classified.kind).toBe('app'); + if (classified.kind === 'app') { + expect(classified.error).toBe(appError); + } + }); + + it('classifies fetch TypeErrors as network catches with their message', () => { + const fetchError = new TypeError('Failed to fetch'); + const classified = classifyHubRequestCatch(fetchError); + expect(classified.kind).toBe('network'); + if (classified.kind === 'network') { + expect(classified.message).toBe('Failed to fetch'); + } + const second = classifyHubRequestCatch(new TypeError('network fetch failed')); + expect(second.kind).toBe('network'); + if (second.kind === 'network') { + expect(second.message).toBe('network fetch failed'); + } + // TypeErrors without the fetch marker fall through to other. + expect(classifyHubRequestCatch(new TypeError('connection refused')).kind).toBe('other'); + }); + + it('classifies everything else as other catches', () => { + const boom = new Error('boom'); + const values: unknown[] = [boom, 'boom', null, undefined, { code: 'X' }]; + for (const value of values) { + const classified = classifyHubRequestCatch(value); + expect(classified.kind).toBe('other'); + if (classified.kind === 'other') { + expect(classified.error).toBe(value); + } + } + }); + + it('fires the abort signal on the timer and skips it when cleared', () => { + vi.useFakeTimers(); + + const cleared = createHubAbortTimeout(250); + expect(cleared.signal.aborted).toBe(false); + cleared.clear(); + vi.advanceTimersByTime(10_000); + expect(cleared.signal.aborted).toBe(false); + + const fired = createHubAbortTimeout(250); + expect(fired.signal.aborted).toBe(false); + vi.advanceTimersByTime(250); + expect(fired.signal.aborted).toBe(true); + }); + + it('runs withHubAbortTimeout and returns the run result on success', async () => { + vi.useFakeTimers(); + + let seenSignal: AbortSignal | undefined; + await expect( + withHubAbortTimeout(100, async (signal) => { + seenSignal = signal; + return 'done'; + }), + ).resolves.toBe('done'); + expect(seenSignal?.aborted).toBe(false); + // The abort timer is cleared after a successful settle. + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears the abort timer and rethrows when the run rejects', async () => { + vi.useFakeTimers(); + + const boom = new Error('boom'); + await expect( + withHubAbortTimeout(100, async () => { + throw boom; + }), + ).rejects.toBe(boom); + expect(vi.getTimerCount()).toBe(0); + }); + + it('aborts the run through the timeout signal when it elapses', async () => { + vi.useFakeTimers(); + + const abortError = new DOMException('Aborted', 'AbortError'); + let seenSignal: AbortSignal | undefined; + const pending = withHubAbortTimeout(150, (signal) => { + seenSignal = signal; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(abortError)); + }); + }); + const rejection = expect(pending).rejects.toBe(abortError); + await vi.advanceTimersByTimeAsync(150); + await rejection; + expect(seenSignal?.aborted).toBe(true); + }); + + it('guards retryable refreshed tokens by truthiness', () => { + const maybeToken: string | null | undefined = 'fresh-tok'; + if (shouldRetryWithRefreshedToken(maybeToken)) { + expect(maybeToken.toUpperCase()).toBe('FRESH-TOK'); + } + expect(shouldRetryWithRefreshedToken('fresh-tok')).toBe(true); + expect(shouldRetryWithRefreshedToken('')).toBe(false); + expect(shouldRetryWithRefreshedToken(null)).toBe(false); + expect(shouldRetryWithRefreshedToken(undefined)).toBe(false); + }); + + it('builds token-refresh report context with a fixed context tag', () => { + expect(buildTokenRefreshReportContext('/web/projects')).toEqual({ + path: '/web/projects', + context: 'token_refresh', + }); + expect(buildTokenRefreshFailedLogPrefix()).toBe('[HubClient] Token refresh failed'); + }); + + it('resolves timeout catches into TIMEOUT AppError plus log and report context', () => { + const resolution = resolveHubRequestCatch( + new DOMException('Aborted', 'AbortError'), + CATCH_CONTEXT, + ); + expect(resolution.kind).toBe('timeout'); + if (resolution.kind === 'timeout') { + expect(resolution.error).toBeInstanceOf(AppError); + expect(resolution.error.code).toBe('TIMEOUT'); + expect(resolution.error.status).toBe(0); + expect(resolution.error.message).toBe( + 'Request timed out after 12000ms: POST /web/projects', + ); + expect(resolution.logMessage).toBe( + '[HubClient] Request timed out after 12000ms: POST /web/projects', + ); + expect(resolution.reportContext).toEqual({ + path: '/web/projects', + method: 'POST', + timeoutMs: 12_000, + }); + } + }); + + it('resolves app catches into the original AppError plus report context', () => { + const appError = new AppError({ error: { code: 'UNAUTHORIZED', message: 'nope' } }, 401); + const resolution = resolveHubRequestCatch(appError, CATCH_CONTEXT); + expect(resolution.kind).toBe('app'); + if (resolution.kind === 'app') { + expect(resolution.error).toBe(appError); + // No timeoutMs in the app report context. + expect(resolution.reportContext).toEqual({ path: '/web/projects', method: 'POST' }); + } + }); + + it('resolves network catches into NETWORK_ERROR AppError plus log and report context', () => { + const resolution = resolveHubRequestCatch(new TypeError('Failed to fetch'), CATCH_CONTEXT); + expect(resolution.kind).toBe('network'); + if (resolution.kind === 'network') { + expect(resolution.error).toBeInstanceOf(AppError); + expect(resolution.error.code).toBe('NETWORK_ERROR'); + expect(resolution.error.status).toBe(0); + expect(resolution.error.message).toBe('Network request failed: Failed to fetch'); + expect(resolution.logMessage).toBe( + '[HubClient] Network request failed: Failed to fetch', + ); + expect(resolution.reportContext).toEqual({ path: '/web/projects', method: 'POST' }); + } + }); + + it('resolves other catches into a bare other payload', () => { + const boom = new Error('boom'); + expect(resolveHubRequestCatch(boom, CATCH_CONTEXT)).toEqual({ kind: 'other', error: boom }); + }); + + it('assembles JSON request context with headers, auth, timeout, method, and url', () => { + const context = prepareHubRequestContext({ + baseUrl: 'https://hub.example.com', + path: '/web/projects', + options: { method: 'POST', headers: { 'X-Test': '1' } }, + token: 'tok-1', + timeoutMs: 5_000, + }); + expect(context.url).toBe('https://hub.example.com/web/projects'); + expect(context.method).toBe('POST'); + expect(context.timeoutMs).toBe(5_000); + expect(context.headers.get('Content-Type')).toBe('application/json'); + expect(context.headers.get('Authorization')).toBe('Bearer tok-1'); + expect(context.headers.get('X-Test')).toBe('1'); + }); + + it('defaults JSON request method and timeout and skips auth without a token', () => { + const context = prepareHubRequestContext({ + baseUrl: '', + path: '/client/auth/me', + options: {}, + }); + expect(context.method).toBe('GET'); + expect(context.timeoutMs).toBe(30_000); + expect(context.url).toBe('/client/auth/me'); + expect(context.headers.get('Content-Type')).toBe('application/json'); + expect(context.headers.has('Authorization')).toBe(false); + + const nullTokenContext = prepareHubRequestContext({ + baseUrl: '', + path: '/x', + options: {}, + token: null, + }); + expect(nullTokenContext.headers.has('Authorization')).toBe(false); + }); + + it('assembles multipart upload context with auth-only headers', () => { + const context = prepareMultipartUploadContext({ + baseUrl: 'https://hub.example.com', + path: '/web/uploads', + token: 'tok-up', + timeoutMs: 9_000, + }); + expect(context.url).toBe('https://hub.example.com/web/uploads'); + expect(context.timeoutMs).toBe(9_000); + expect(context.headers.get('Authorization')).toBe('Bearer tok-up'); + expect(context.headers.has('Content-Type')).toBe(false); + + const defaults = prepareMultipartUploadContext({ baseUrl: '', path: '/u' }); + expect(defaults.timeoutMs).toBe(30_000); + expect(defaults.headers.has('Authorization')).toBe(false); + }); + + it('omits explicit-undefined token and timeout in the fromClient wrappers', () => { + const json = prepareHubRequestContextFromClient({ + baseUrl: 'https://hub.example.com', + path: '/web/projects', + options: {}, + token: undefined, + timeoutMs: undefined, + }); + expect(json.headers.has('Authorization')).toBe(false); + expect(json.timeoutMs).toBe(30_000); + + const multipart = prepareMultipartUploadContextFromClient({ + baseUrl: 'https://hub.example.com', + path: '/web/uploads', + token: undefined, + timeoutMs: undefined, + }); + expect(multipart.headers.has('Authorization')).toBe(false); + expect(multipart.timeoutMs).toBe(30_000); + + const jsonTokenContext = prepareHubRequestContextFromClient({ + baseUrl: '', + path: '/x', + options: {}, + token: 'tok-2', + timeoutMs: 1_000, + }); + expect(jsonTokenContext.headers.get('Authorization')).toBe('Bearer tok-2'); + expect(jsonTokenContext.timeoutMs).toBe(1_000); + + const multipartTokenContext = prepareMultipartUploadContextFromClient({ + baseUrl: '', + path: '/x', + token: 'tok-3', + timeoutMs: 2_000, + }); + expect(multipartTokenContext.headers.get('Authorization')).toBe('Bearer tok-3'); + expect(multipartTokenContext.timeoutMs).toBe(2_000); + }); + + it('detects onRefreshToken handler presence', () => { + const handler = (): Promise => Promise.resolve(null); + expect(hasTokenRefreshHandler(handler)).toBe(true); + expect(hasTokenRefreshHandler(undefined)).toBe(false); + expect(hasTokenRefreshHandler(null)).toBe(false); + expect(hasTokenRefreshHandler()).toBe(false); + }); + + it('enters token-refresh recovery only on 401 with a handler', () => { + const handler = (): Promise => Promise.resolve('tok'); + expect(shouldEnterTokenRefreshRecovery(401, handler)).toBe(true); + expect(shouldEnterTokenRefreshRecovery(401, undefined)).toBe(false); + expect(shouldEnterTokenRefreshRecovery(401, null)).toBe(false); + expect(shouldEnterTokenRefreshRecovery(403, handler)).toBe(false); + expect(shouldEnterTokenRefreshRecovery(200, handler)).toBe(false); + expect(shouldEnterTokenRefreshRecovery(0, handler)).toBe(false); + }); + + it('plans a single retry with the refreshed token or aborts', () => { + expect(planRefreshedTokenRetry('new-tok')).toEqual({ action: 'retry', token: 'new-tok' }); + expect(planRefreshedTokenRetry(null)).toEqual({ action: 'abort' }); + expect(planRefreshedTokenRetry(undefined)).toEqual({ action: 'abort' }); + expect(planRefreshedTokenRetry('')).toEqual({ action: 'abort' }); + + const plan = planRefreshedTokenRetry('narrow-me'); + if (plan.action === 'retry') { + expect(plan.token.toUpperCase()).toBe('NARROW-ME'); + } + }); + + it('plans token-refresh failure report with a normalized error', () => { + const plan = planTokenRefreshFailureReport('/web/projects', 'raw-failure'); + expect(plan.logPrefix).toBe('[HubClient] Token refresh failed'); + expect(plan.context).toEqual({ path: '/web/projects', context: 'token_refresh' }); + expect(plan.error).toBeInstanceOf(Error); + expect(plan.error.message).toBe('raw-failure'); + + const original = new Error('already-error'); + const identityPlan = planTokenRefreshFailureReport('/x', original); + expect(identityPlan.error).toBe(original); + }); + + it('plans timeout catch effects with log message and report', () => { + const effects = planHubRequestCatchEffects( + new DOMException('Aborted', 'AbortError'), + CATCH_CONTEXT, + ); + expect(effects.error).toBeInstanceOf(AppError); + if ('logMessage' in effects) { + expect(effects.logMessage).toBe( + '[HubClient] Request timed out after 12000ms: POST /web/projects', + ); + expect(effects.report.error).toBe(effects.error); + expect(effects.report.context).toEqual({ + path: '/web/projects', + method: 'POST', + timeoutMs: 12_000, + }); + } + }); + + it('plans network catch effects with log message and report', () => { + const effects = planHubRequestCatchEffects(new TypeError('Failed to fetch'), CATCH_CONTEXT); + expect(effects.error).toBeInstanceOf(AppError); + if ('logMessage' in effects) { + expect(effects.logMessage).toBe('[HubClient] Network request failed: Failed to fetch'); + expect(effects.report.error).toBe(effects.error); + expect(effects.report.context).toEqual({ path: '/web/projects', method: 'POST' }); + } + }); + + it('plans app catch effects with report but no log message', () => { + const appError = new AppError({ error: { code: 'FORBIDDEN', message: 'no' } }, 403); + const effects = planHubRequestCatchEffects(appError, CATCH_CONTEXT); + expect('logMessage' in effects).toBe(false); + expect(effects.error).toBe(appError); + if ('report' in effects) { + expect(effects.report.error).toBe(appError); + expect(effects.report.context).toEqual({ path: '/web/projects', method: 'POST' }); + } + }); + + it('plans other catch effects as a bare error', () => { + expect(planHubRequestCatchEffects('plain-string', CATCH_CONTEXT)).toEqual({ + error: 'plain-string', + }); + }); + + it('logs and reports timeout effects, then rethrows the AppError', () => { + const logError = vi.fn(); + const report = vi.fn(); + const effects = planHubRequestCatchEffects( + new DOMException('Aborted', 'AbortError'), + CATCH_CONTEXT, + ); + + let thrown: unknown; + try { + applyHubRequestCatchEffects(effects, { logError, report }); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(effects.error); + expect(logError).toHaveBeenCalledTimes(1); + if ('logMessage' in effects) { + expect(logError).toHaveBeenCalledWith(effects.logMessage); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith(effects.error, effects.report.context); + } + }); + + it('reports app effects without logging, then rethrows', () => { + const logError = vi.fn(); + const report = vi.fn(); + const appError = new AppError({ error: { code: 'FORBIDDEN', message: 'no' } }, 403); + const effects = planHubRequestCatchEffects(appError, CATCH_CONTEXT); + + let thrown: unknown; + try { + applyHubRequestCatchEffects(effects, { logError, report }); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(appError); + expect(logError).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith(appError, { path: '/web/projects', method: 'POST' }); + }); + + it('rethrows other effects raw without side effects', () => { + const logError = vi.fn(); + const report = vi.fn(); + + let thrown: unknown; + try { + applyHubRequestCatchEffects({ error: 'boom' }, { logError, report }); + } catch (error) { + thrown = error; + } + expect(thrown).toBe('boom'); + expect(logError).not.toHaveBeenCalled(); + expect(report).not.toHaveBeenCalled(); + }); + + it('applies token-refresh failure report through injected sinks', () => { + const logError = vi.fn(); + const report = vi.fn(); + const plan = planTokenRefreshFailureReport('/web/projects', 'raw-failure'); + + applyTokenRefreshFailureReport(plan, 'raw-failure', { logError, report }); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith('[HubClient] Token refresh failed', 'raw-failure'); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith(plan.error, { + path: '/web/projects', + context: 'token_refresh', + }); + }); + + it('plans timeout effects through reportApiError and rethrows the AppError', () => { + const reportSpy = vi.spyOn(globalErrorReporter, 'report'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let thrown: unknown; + try { + applyDefaultHubRequestCatchEffects( + new DOMException('Aborted', 'AbortError'), + CATCH_CONTEXT, + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).code).toBe('TIMEOUT'); + expect(reportSpy).toHaveBeenCalledTimes(1); + expect(reportSpy.mock.calls[0]?.[0]).toBeInstanceOf(AppError); + expect(reportSpy.mock.calls[0]?.[1]).toEqual({ + path: '/web/projects', + method: 'POST', + timeoutMs: 12_000, + }); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('plans app effects through reportApiError and rethrows the original AppError', () => { + const reportSpy = vi.spyOn(globalErrorReporter, 'report'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const appError = new AppError({ error: { code: 'FORBIDDEN', message: 'no' } }, 403); + + let thrown: unknown; + try { + applyDefaultHubRequestCatchEffects(appError, CATCH_CONTEXT); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(appError); + expect(reportSpy).toHaveBeenCalledTimes(1); + expect(reportSpy.mock.calls[0]?.[0]).toBe(appError); + expect(reportSpy.mock.calls[0]?.[1]).toEqual({ path: '/web/projects', method: 'POST' }); + expect(consoleSpy.mock.calls[0]?.[0]).toContain('[API] FORBIDDEN'); + }); + + it('plans network effects through reportApiError and rethrows a NETWORK_ERROR AppError', () => { + const reportSpy = vi.spyOn(globalErrorReporter, 'report'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let thrown: unknown; + try { + applyDefaultHubRequestCatchEffects(new TypeError('Failed to fetch'), CATCH_CONTEXT); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).code).toBe('NETWORK_ERROR'); + expect(reportSpy).toHaveBeenCalledTimes(1); + expect(reportSpy.mock.calls[0]?.[0]).toBeInstanceOf(AppError); + expect(reportSpy.mock.calls[0]?.[1]).toEqual({ path: '/web/projects', method: 'POST' }); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('rethrows non-AppError catches without logging or reporting', () => { + const reportSpy = vi.spyOn(globalErrorReporter, 'report'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let thrown: unknown; + try { + applyDefaultHubRequestCatchEffects('plain-string', CATCH_CONTEXT); + } catch (error) { + thrown = error; + } + expect(thrown).toBe('plain-string'); + expect(reportSpy).not.toHaveBeenCalled(); + expect(consoleSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/app/shared/src/hub/hubClientTransportRun.test.ts b/app/shared/src/hub/hubClientTransportRun.test.ts new file mode 100644 index 000000000..63794fc99 --- /dev/null +++ b/app/shared/src/hub/hubClientTransportRun.test.ts @@ -0,0 +1,1060 @@ +// real_tested=true — every export of hubClientTransportRun is exercised with +// injected fake fetch implementations (no live network). The only globals +// touched are `fetch` (vi.stubGlobal for resolveHubClientRuntime's global +// fallback lookup, unstubbed in afterEach) and `console.error` (spied silent +// where the real catch/report residuals fire); the shared globalErrorReporter +// is cleared after each test. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AppError, globalErrorReporter } from '../errors'; +import { + createHubClientTransport, + fetchHubJsonWithTimeout, + fetchHubMultipartWithTimeout, + resolveHubClientRuntime, + resolveHubClientTransportOptions, + runHubClientJsonRequest, + runHubClientMultipartUploadRequest, + runHubJsonRequest, + runHubMultipartUploadRequest, + runUnauthorizedTokenRefreshRecovery, +} from './hubClientTransportRun'; + +const BASE_URL = 'https://hub.example.test'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function errorResponse(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { status }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + globalErrorReporter.clear(); +}); + +describe('hubClientTransportRun (#1023 / #1044 / #1055)', () => { + describe('fetchHubJsonWithTimeout', () => { + it('composes options + headers + signal into one fetch init and returns the response', async () => { + const headers = new Headers({ Authorization: 'Bearer tok' }); + const options: RequestInit = { + method: 'PUT', + body: JSON.stringify({ a: 1 }), + credentials: 'include', + }; + const fetchImpl = vi.fn(async () => jsonResponse({ ok: true })); + + const response = await fetchHubJsonWithTimeout( + fetchImpl, + `${BASE_URL}/x`, + 5_000, + options, + headers, + ); + + expect(response.status).toBe(200); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const url = fetchImpl.mock.calls[0]?.[0]; + const init = fetchImpl.mock.calls[0]?.[1]; + expect(String(url)).toBe(`${BASE_URL}/x`); + expect(init).toMatchObject({ + method: 'PUT', + body: JSON.stringify({ a: 1 }), + credentials: 'include', + }); + expect(init?.headers).toBe(headers); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.signal?.aborted).toBe(false); + }); + + it('rethrows fetchImpl rejections after clearing the abort timer', async () => { + const fetchError = new TypeError('Failed to fetch'); + const fetchImpl: typeof fetch = async () => { + throw fetchError; + }; + + await expect( + fetchHubJsonWithTimeout(fetchImpl, `${BASE_URL}/x`, 5_000, {}, new Headers()), + ).rejects.toBe(fetchError); + }); + + it('aborts the passed signal after timeoutMs', async () => { + vi.useFakeTimers(); + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + + const pending = fetchHubJsonWithTimeout( + fetchImpl, + `${BASE_URL}/x`, + 5_000, + {}, + new Headers(), + ); + const expectation = expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + await vi.advanceTimersByTimeAsync(5_000); + await expectation; + }); + }); + + describe('fetchHubMultipartWithTimeout', () => { + it('POSTs formData under auth headers and returns the response', async () => { + const headers = new Headers({ Authorization: 'Bearer tok-up' }); + const form = new FormData(); + form.set('hash', 'h1'); + const fetchImpl = vi.fn(async () => jsonResponse({ code: 'OK' })); + + const response = await fetchHubMultipartWithTimeout( + fetchImpl, + `${BASE_URL}/up`, + 5_000, + headers, + form, + ); + + expect(response.status).toBe(200); + const url = fetchImpl.mock.calls[0]?.[0]; + const init = fetchImpl.mock.calls[0]?.[1]; + expect(String(url)).toBe(`${BASE_URL}/up`); + expect(init).toMatchObject({ method: 'POST', body: form }); + expect(init?.headers).toBe(headers); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + }); + + describe('runUnauthorizedTokenRefreshRecovery', () => { + it('continues without invoking refresh or retry for non-401 statuses', async () => { + const onRefreshToken = vi.fn(async () => 'tok'); + const retry = vi.fn(async () => 'never'); + + const forbidden = await runUnauthorizedTokenRefreshRecovery({ + status: 403, + onRefreshToken, + headers: new Headers(), + path: '/client/auth/me', + retry, + logError: () => undefined, + report: () => undefined, + }); + expect(forbidden).toEqual({ action: 'continue' }); + expect(onRefreshToken).not.toHaveBeenCalled(); + expect(retry).not.toHaveBeenCalled(); + + const zeroStatus = await runUnauthorizedTokenRefreshRecovery({ + status: 0, + onRefreshToken, + headers: new Headers(), + path: '/client/auth/me', + retry, + logError: () => undefined, + report: () => undefined, + }); + expect(zeroStatus).toEqual({ action: 'continue' }); + expect(onRefreshToken).not.toHaveBeenCalled(); + }); + + it('continues when the refresh handler is missing or null even on 401', async () => { + const retry = vi.fn(async () => 'never'); + + const noHandler = await runUnauthorizedTokenRefreshRecovery({ + status: 401, + onRefreshToken: undefined, + headers: new Headers(), + path: '/client/auth/me', + retry, + logError: () => undefined, + report: () => undefined, + }); + expect(noHandler).toEqual({ action: 'continue' }); + + const nullHandler = await runUnauthorizedTokenRefreshRecovery({ + status: 401, + onRefreshToken: null, + headers: new Headers(), + path: '/client/auth/me', + retry, + logError: () => undefined, + report: () => undefined, + }); + expect(nullHandler).toEqual({ action: 'continue' }); + expect(retry).not.toHaveBeenCalled(); + }); + + it('refreshes once and returns retry_result with refreshed auth applied', async () => { + const headers = new Headers({ Authorization: 'Bearer stale' }); + const onRefreshToken = vi.fn(async () => 'fresh'); + const retry = vi.fn(async () => 'retry-value'); + + const result = await runUnauthorizedTokenRefreshRecovery({ + status: 401, + onRefreshToken, + headers, + path: '/client/auth/me', + retry, + logError: () => undefined, + report: () => undefined, + }); + + expect(result).toEqual({ action: 'retry_result', value: 'retry-value' }); + expect(headers.get('Authorization')).toBe('Bearer fresh'); + expect(onRefreshToken).toHaveBeenCalledTimes(1); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('continues without retry when the refreshed token is null or empty', async () => { + const retry = vi.fn(async () => 'never'); + const headers = new Headers(); + + const nullResult = await runUnauthorizedTokenRefreshRecovery({ + status: 401, + onRefreshToken: async () => null, + headers, + path: '/client/auth/me', + retry, + logError: () => undefined, + report: () => undefined, + }); + expect(nullResult).toEqual({ action: 'continue' }); + expect(retry).not.toHaveBeenCalled(); + expect(headers.has('Authorization')).toBe(false); + + const emptyResult = await runUnauthorizedTokenRefreshRecovery({ + status: 401, + onRefreshToken: async () => '', + headers, + path: '/client/auth/me', + retry, + logError: () => undefined, + report: () => undefined, + }); + expect(emptyResult).toEqual({ action: 'continue' }); + expect(retry).not.toHaveBeenCalled(); + }); + + it('logs and reports the refresh failure, then continues', async () => { + const refreshError = new Error('refresh-fail'); + const logs: Array<{ prefix: string; err: unknown }> = []; + const reports: Array<{ + error: Error; + context: { path: string; context: 'token_refresh' }; + }> = []; + + const result = await runUnauthorizedTokenRefreshRecovery({ + status: 401, + onRefreshToken: async () => { + throw refreshError; + }, + headers: new Headers(), + path: '/client/auth/me', + retry: async () => 'never', + logError: (prefix, err) => logs.push({ prefix, err }), + report: (error, context) => reports.push({ error, context }), + }); + + expect(result).toEqual({ action: 'continue' }); + expect(logs).toEqual([{ prefix: '[HubClient] Token refresh failed', err: refreshError }]); + expect(reports).toEqual([ + { error: refreshError, context: { path: '/client/auth/me', context: 'token_refresh' } }, + ]); + }); + + it('logs and reports when the retry attempt itself throws, then continues', async () => { + const retryError = new Error('retry-boom'); + const logs: Array<{ prefix: string; err: unknown }> = []; + const reports: Array<{ + error: Error; + context: { path: string; context: 'token_refresh' }; + }> = []; + const headers = new Headers(); + + const result = await runUnauthorizedTokenRefreshRecovery({ + status: 401, + onRefreshToken: async () => 'fresh', + headers, + path: '/client/auth/me', + retry: async () => { + throw retryError; + }, + logError: (prefix, err) => logs.push({ prefix, err }), + report: (error, context) => reports.push({ error, context }), + }); + + expect(result).toEqual({ action: 'continue' }); + expect(headers.get('Authorization')).toBe('Bearer fresh'); + expect(logs[0]?.prefix).toBe('[HubClient] Token refresh failed'); + expect(logs[0]?.err).toBe(retryError); + expect(reports[0]?.context).toEqual({ path: '/client/auth/me', context: 'token_refresh' }); + }); + }); + + describe('runHubJsonRequest', () => { + it('prepares URL/headers from context and parses the primary response', async () => { + const calls: Array<{ + url: string; + method: string | undefined; + auth: string | null; + contentType: string | null; + }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + method: init?.method, + auth: headers.get('Authorization'), + contentType: headers.get('Content-Type'), + }); + return jsonResponse({ ok: true }); + }; + + const parsed = await runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/client/auth/me', + token: 'tok-1', + fetchImpl, + parseSuccess: async (response) => { + expect(response.status).toBe(200); + return 'parsed-ok' as const; + }, + }); + + expect(parsed).toBe('parsed-ok'); + expect(calls).toEqual([ + { + url: `${BASE_URL}/client/auth/me`, + method: undefined, + auth: 'Bearer tok-1', + contentType: 'application/json', + }, + ]); + }); + + it('refreshes the token and retries once on a 401 primary response', async () => { + const seenAuth: string[] = []; + const fetchImpl: typeof fetch = async (_input, init) => { + const headers = new Headers(init?.headers); + const auth = headers.get('Authorization'); + seenAuth.push(auth ?? 'none'); + if (auth === 'Bearer fresh') { + return jsonResponse({ code: 'OK', data: { id: 'u1' } }); + } + return errorResponse(401, 'unauthorized', 'expired'); + }; + const parseSuccess = vi.fn(async (response: Response) => { + expect(response.status).toBe(200); + return (await response.json()) as { code: string; data: unknown }; + }); + + const result = await runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/client/auth/me', + token: 'stale', + timeoutMs: 5_000, + fetchImpl, + onRefreshToken: async () => 'fresh', + parseSuccess, + }); + + expect(result).toEqual({ code: 'OK', data: { id: 'u1' } }); + expect(parseSuccess).toHaveBeenCalledTimes(1); + expect(seenAuth).toEqual(['Bearer stale', 'Bearer fresh']); + }); + + it('parses the original 401 response when no refresh handler is configured', async () => { + const parseSuccess = vi.fn(async (response: Response) => `status-${response.status}` as const); + const fetchImpl: typeof fetch = async () => errorResponse(401, 'unauthorized', 'm'); + + const result = await runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/x', + fetchImpl, + parseSuccess, + }); + + expect(result).toBe('status-401'); + expect(parseSuccess).toHaveBeenCalledTimes(1); + }); + + it('reports refresh failure through the default sinks and parses the original response', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const parseSuccess = vi.fn(async (response: Response) => `status-${response.status}` as const); + const fetchImpl: typeof fetch = async () => errorResponse(401, 'unauthorized', 'expired'); + + const result = await runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/x', + fetchImpl, + onRefreshToken: async () => { + throw new Error('refresh-fail'); + }, + parseSuccess, + }); + + expect(result).toBe('status-401'); + expect(parseSuccess).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith('[HubClient] Token refresh failed', expect.any(Error)); + }); + + it('remaps abort into a TIMEOUT AppError with log+report', async () => { + vi.useFakeTimers(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + + const request = runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/web/projects', + timeoutMs: 5_000, + fetchImpl, + parseSuccess: async () => 'never', + }); + const expectation = expect(request).rejects.toMatchObject({ + name: 'AppError', + code: 'TIMEOUT', + status: 0, + message: 'Request timed out after 5000ms: GET /web/projects', + }); + await vi.advanceTimersByTimeAsync(5_000); + await expectation; + expect(consoleError).toHaveBeenCalled(); + }); + + it('remaps fetch TypeErrors into a NETWORK_ERROR AppError', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchImpl: typeof fetch = async () => { + throw new TypeError('Failed to fetch'); + }; + + await expect( + runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/x', + fetchImpl, + parseSuccess: async () => 'never', + }), + ).rejects.toMatchObject({ + name: 'AppError', + code: 'NETWORK_ERROR', + status: 0, + message: 'Network request failed: Failed to fetch', + }); + expect(consoleError).toHaveBeenCalled(); + }); + + it('rethrows AppError instances untouched (report, no [HubClient] log)', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const appError = new AppError({ error: { code: 'X', message: 'm' } }, 500); + const fetchImpl: typeof fetch = async () => { + throw appError; + }; + + await expect( + runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/x', + fetchImpl, + parseSuccess: async () => 'never', + }), + ).rejects.toBe(appError); + expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('[HubClient]')); + }); + + it('rethrows unknown values untouched without logging or reporting', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchImpl: typeof fetch = async () => { + throw 'plain-string'; + }; + + await expect( + runHubJsonRequest({ + baseUrl: BASE_URL, + path: '/x', + fetchImpl, + parseSuccess: async () => 'never', + }), + ).rejects.toBe('plain-string'); + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe('runHubMultipartUploadRequest', () => { + it('builds auth-only headers + POST formData and parses the response', async () => { + const calls: Array<{ + url: string; + method: string | undefined; + body: FormData | null; + auth: string | null; + contentType: string | null; + }> = []; + const form = new FormData(); + form.set('hash', 'h1'); + const fetchImpl: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + method: init?.method, + body: (init?.body as FormData | null) ?? null, + auth: headers.get('Authorization'), + contentType: headers.get('Content-Type'), + }); + return jsonResponse({ code: 'OK' }); + }; + + const parsed = await runHubMultipartUploadRequest({ + baseUrl: BASE_URL, + path: '/client/attachments', + formData: form, + token: 'tok-up', + timeoutMs: 5_000, + fetchImpl, + parseSuccess: async (response) => { + expect(response.status).toBe(200); + return 'parsed-up' as const; + }, + }); + + expect(parsed).toBe('parsed-up'); + expect(calls).toEqual([ + { + url: `${BASE_URL}/client/attachments`, + method: 'POST', + body: form, + auth: 'Bearer tok-up', + contentType: null, + }, + ]); + }); + + it('propagates fetch and parse failures without catch remapping', async () => { + const fetchError = new TypeError('Failed to fetch'); + const throwingFetch: typeof fetch = async () => { + throw fetchError; + }; + await expect( + runHubMultipartUploadRequest({ + baseUrl: BASE_URL, + path: '/up', + formData: new FormData(), + fetchImpl: throwingFetch, + parseSuccess: async () => 'never', + }), + ).rejects.toBe(fetchError); + + const parseError = new AppError({ error: { code: 'X', message: 'm' } }, 400); + const okFetch: typeof fetch = async () => jsonResponse({ code: 'OK' }); + await expect( + runHubMultipartUploadRequest({ + baseUrl: BASE_URL, + path: '/up', + formData: new FormData(), + fetchImpl: okFetch, + parseSuccess: async () => { + throw parseError; + }, + }), + ).rejects.toBe(parseError); + }); + + it('omits Authorization when no token is provided', async () => { + const seenHeaders: Headers[] = []; + const fetchImpl: typeof fetch = async (_input, init) => { + seenHeaders.push(new Headers(init?.headers)); + return jsonResponse({ code: 'OK' }); + }; + + await runHubMultipartUploadRequest({ + baseUrl: BASE_URL, + path: '/up', + formData: new FormData(), + fetchImpl, + parseSuccess: async () => undefined, + }); + + expect(seenHeaders[0]?.has('Authorization')).toBe(false); + }); + }); + + describe('resolveHubClientRuntime', () => { + it('normalizes the base URL and prefers the injected fetch', () => { + const injected = (async () => jsonResponse({ code: 'OK' })) as typeof globalThis.fetch; + + const trailing = resolveHubClientRuntime({ + baseUrl: 'https://hub.example.test///', + fetch: injected, + }); + expect(trailing.baseUrl).toBe('https://hub.example.test'); + expect(trailing.fetchImpl).toBe(injected); + + const clean = resolveHubClientRuntime({ baseUrl: BASE_URL, fetch: injected }); + expect(clean).toEqual({ baseUrl: BASE_URL, fetchImpl: injected }); + }); + + it('falls back to the global fetch binding when none is injected', () => { + const stubbed = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal('fetch', stubbed); + + const runtime = resolveHubClientRuntime({ baseUrl: `${BASE_URL}/` }); + expect(runtime.fetchImpl).toBe(stubbed); + expect(runtime.baseUrl).toBe(BASE_URL); + + const noBase = resolveHubClientRuntime({}); + expect(noBase.baseUrl).toBe(''); + expect(noBase.fetchImpl).toBe(stubbed); + }); + }); + + describe('runHubClientJsonRequest', () => { + it('unwraps OK envelope data into the generic result', async () => { + const fetchImpl: typeof fetch = async () => jsonResponse({ code: 'OK', data: { id: 'u1' } }); + + const result = await runHubClientJsonRequest<{ id: string }>({ + baseUrl: BASE_URL, + path: '/client/auth/me', + fetchImpl, + }); + + expect(result).toEqual({ id: 'u1' }); + }); + + it('maps 204 no-content responses to undefined', async () => { + const fetchImpl: typeof fetch = async () => new Response(null, { status: 204 }); + + const result = await runHubClientJsonRequest({ + baseUrl: BASE_URL, + path: '/x', + fetchImpl, + }); + + expect(result).toBeUndefined(); + }); + + it('throws the parsed AppError for non-OK error bodies', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchImpl: typeof fetch = async () => errorResponse(404, 'NOT_FOUND', 'missing'); + + await expect( + runHubClientJsonRequest({ baseUrl: BASE_URL, path: '/x', fetchImpl }), + ).rejects.toMatchObject({ + name: 'AppError', + code: 'NOT_FOUND', + status: 404, + message: 'missing', + }); + expect(consoleError).toHaveBeenCalled(); + }); + + it('throws for non-OK envelope codes even with HTTP 200', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchImpl: typeof fetch = async () => + jsonResponse({ code: 'DENIED', message: 'boom' }, 200); + + await expect( + runHubClientJsonRequest({ baseUrl: BASE_URL, path: '/x', fetchImpl }), + ).rejects.toMatchObject({ + name: 'AppError', + code: 'DENIED', + status: 200, + message: 'boom', + }); + expect(consoleError).toHaveBeenCalled(); + }); + + it('passes through non-envelope JSON bodies', async () => { + const fetchImpl: typeof fetch = async () => jsonResponse({ plain: 'body' }); + + const result = await runHubClientJsonRequest<{ plain: string }>({ + baseUrl: BASE_URL, + path: '/x', + fetchImpl, + }); + + expect(result).toEqual({ plain: 'body' }); + }); + + it('treats explicit undefined optional args as omitted', async () => { + const calls: Array<{ url: string; auth: string | null }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ url: String(input), auth: headers.get('Authorization') }); + return jsonResponse({ code: 'OK', data: 42 }); + }; + + const result = await runHubClientJsonRequest({ + baseUrl: BASE_URL, + path: '/x', + options: undefined, + token: undefined, + timeoutMs: undefined, + onRefreshToken: undefined, + fetchImpl, + }); + + expect(result).toBe(42); + expect(calls).toEqual([{ url: `${BASE_URL}/x`, auth: null }]); + }); + }); + + describe('runHubClientMultipartUploadRequest', () => { + it('unwraps OK envelope data from a multipart upload', async () => { + const fetchImpl: typeof fetch = async () => jsonResponse({ code: 'OK', data: { id: 'a1' } }); + + const result = await runHubClientMultipartUploadRequest<{ id: string }>({ + baseUrl: BASE_URL, + path: '/client/attachments', + formData: new FormData(), + fetchImpl, + }); + + expect(result).toEqual({ id: 'a1' }); + }); + + it('maps 204 upload responses to undefined', async () => { + const fetchImpl: typeof fetch = async () => new Response(null, { status: 204 }); + + const result = await runHubClientMultipartUploadRequest({ + baseUrl: BASE_URL, + path: '/up', + formData: new FormData(), + fetchImpl, + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('resolveHubClientTransportOptions', () => { + it('omits optional keys when undefined (minimal transport)', () => { + const injected = (async () => jsonResponse({ code: 'OK' })) as typeof globalThis.fetch; + const runtime = resolveHubClientRuntime({ baseUrl: BASE_URL, fetch: injected }); + + const minimal = resolveHubClientTransportOptions(runtime, {}); + expect(minimal).toEqual({ baseUrl: BASE_URL, fetchImpl: injected }); + expect(Object.prototype.hasOwnProperty.call(minimal, 'getToken')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(minimal, 'timeoutMs')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(minimal, 'onRefreshToken')).toBe(false); + }); + + it('preserves provided getter/timeout/refresh references and omits explicit undefined', () => { + const injected = (async () => jsonResponse({ code: 'OK' })) as typeof globalThis.fetch; + const runtime = resolveHubClientRuntime({ baseUrl: BASE_URL, fetch: injected }); + const getToken = () => 'tok'; + const onRefreshToken = async () => 'fresh'; + + const resolved = resolveHubClientTransportOptions(runtime, { + getToken, + timeoutMs: 7_000, + onRefreshToken, + }); + expect(resolved.getToken).toBe(getToken); + expect(resolved.timeoutMs).toBe(7_000); + expect(resolved.onRefreshToken).toBe(onRefreshToken); + + const withExplicitUndefined = resolveHubClientTransportOptions(runtime, { + getToken: undefined, + timeoutMs: undefined, + onRefreshToken: undefined, + }); + expect(Object.prototype.hasOwnProperty.call(withExplicitUndefined, 'getToken')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(withExplicitUndefined, 'timeoutMs')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(withExplicitUndefined, 'onRefreshToken')).toBe( + false, + ); + }); + }); + + describe('createHubClientTransport', () => { + it('request: composes URL, options, token, and envelope parsing', async () => { + const calls: Array<{ url: string; method?: string; body: unknown; auth: string | null }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + method: init?.method, + body: init?.body ?? null, + auth: headers.get('Authorization'), + }); + return jsonResponse({ code: 'OK', data: { ok: true } }); + }; + const transport = createHubClientTransport({ + baseUrl: BASE_URL, + fetchImpl, + getToken: () => 'tok', + timeoutMs: 5_000, + }); + + const body = JSON.stringify({ a: 1 }); + const result = await transport.request<{ ok: boolean }>('/client/auth/me', { + method: 'POST', + body, + }); + + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([ + { url: `${BASE_URL}/client/auth/me`, method: 'POST', body, auth: 'Bearer tok' }, + ]); + }); + + it('request: omits auth when getToken is not configured', async () => { + const calls: Array<{ auth: string | null }> = []; + const fetchImpl: typeof fetch = async (_input, init) => { + const headers = new Headers(init?.headers); + calls.push({ auth: headers.get('Authorization') }); + return jsonResponse({ code: 'OK', data: { ok: true } }); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + const result = await transport.request<{ ok: boolean }>('/client/auth/me'); + + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([{ auth: null }]); + }); + + it('request: getToken returning undefined behaves like no auth', async () => { + const calls: Array<{ auth: string | null }> = []; + const fetchImpl: typeof fetch = async (_input, init) => { + const headers = new Headers(init?.headers); + calls.push({ auth: headers.get('Authorization') }); + return jsonResponse({ code: 'OK', data: null }); + }; + const transport = createHubClientTransport({ + baseUrl: BASE_URL, + fetchImpl, + getToken: () => undefined, + }); + + await transport.request('/x'); + await transport.request('/x'); + + expect(calls).toEqual([{ auth: null }, { auth: null }]); + }); + + it('request: applies the shared 30s default timeout when timeoutMs is omitted', async () => { + vi.useFakeTimers(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + const request = transport.request('/client/auth/me'); + const expectation = expect(request).rejects.toMatchObject({ + code: 'TIMEOUT', + message: 'Request timed out after 30000ms: GET /client/auth/me', + }); + await vi.advanceTimersByTimeAsync(30_000); + await expectation; + expect(consoleError).toHaveBeenCalled(); + }); + + it('request: refreshes the token and retries once on a 401 response', async () => { + const seenAuth: string[] = []; + const getToken = vi.fn(() => 'stale'); + const onRefreshToken = vi.fn(async () => 'fresh'); + const fetchImpl: typeof fetch = async (_input, init) => { + const headers = new Headers(init?.headers); + const auth = headers.get('Authorization'); + seenAuth.push(auth ?? 'none'); + if (auth === 'Bearer fresh') { + return jsonResponse({ code: 'OK', data: { id: 'u1' } }); + } + return errorResponse(401, 'unauthorized', 'expired'); + }; + const transport = createHubClientTransport({ + baseUrl: BASE_URL, + fetchImpl, + getToken, + onRefreshToken, + }); + + const result = await transport.request<{ id: string }>('/client/auth/me'); + + expect(result).toEqual({ id: 'u1' }); + expect(seenAuth).toEqual(['Bearer stale', 'Bearer fresh']); + expect(getToken).toHaveBeenCalledTimes(1); + expect(onRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('requestWithFallback: returns the first successful path', async () => { + const seen: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + seen.push(String(input)); + return jsonResponse({ code: 'OK', data: { id: 'ok' } }); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + const result = await transport.requestWithFallback<{ id: string }>(['/a', '/b']); + + expect(result).toEqual({ id: 'ok' }); + expect(seen).toEqual([`${BASE_URL}/a`]); + }); + + it('requestWithFallback: retries the next path after a 404', async () => { + const seen: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + seen.push(String(input)); + if (String(input).endsWith('/first')) { + return jsonResponse({ code: 'NOT_FOUND', message: 'missing' }, 404); + } + return jsonResponse({ code: 'OK', data: { id: 'x' } }); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + const result = await transport.requestWithFallback<{ id: string }>(['/first', '/second']); + + expect(result).toEqual({ id: 'x' }); + expect(seen).toEqual([`${BASE_URL}/first`, `${BASE_URL}/second`]); + }); + + it('requestWithFallback: retries the next path after a 405', async () => { + const seen: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + seen.push(String(input)); + if (String(input).endsWith('/first')) { + return errorResponse(405, 'METHOD_NOT_ALLOWED', 'nope'); + } + return jsonResponse({ code: 'OK', data: { id: 'x' } }); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + const result = await transport.requestWithFallback<{ id: string }>(['/first', '/second']); + + expect(result).toEqual({ id: 'x' }); + expect(seen).toEqual([`${BASE_URL}/first`, `${BASE_URL}/second`]); + }); + + it('requestWithFallback: rethrows non-fallback AppErrors (500) without retrying', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const seen: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + seen.push(String(input)); + return errorResponse(500, 'INTERNAL_ERROR', 'boom'); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + await expect(transport.requestWithFallback(['/first', '/second'])).rejects.toMatchObject({ + code: 'INTERNAL_ERROR', + status: 500, + }); + expect(seen).toEqual([`${BASE_URL}/first`]); + }); + + it('requestWithFallback: rethrows the last fallback error after exhausting paths', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const seen: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + seen.push(String(input)); + return jsonResponse({ code: 'NOT_FOUND', message: String(input) }, 404); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + await expect(transport.requestWithFallback(['/a', '/b'])).rejects.toMatchObject({ + status: 404, + message: `${BASE_URL}/b`, + }); + expect(seen).toEqual([`${BASE_URL}/a`, `${BASE_URL}/b`]); + }); + + it('requestWithFallback: rethrows network errors immediately', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const seen: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + seen.push(String(input)); + throw new TypeError('Failed to fetch'); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + await expect(transport.requestWithFallback(['/a', '/b'])).rejects.toMatchObject({ + code: 'NETWORK_ERROR', + }); + expect(seen).toEqual([`${BASE_URL}/a`]); + }); + + it('requestWithFallback: forwards options to every attempt', async () => { + const seen: Array<{ method?: string; body?: unknown }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + seen.push({ method: init?.method, body: init?.body }); + if (String(input).endsWith('/first')) { + return jsonResponse({ code: 'NOT_FOUND', message: 'm' }, 404); + } + return jsonResponse({ code: 'OK', data: 'done' }); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + const body = JSON.stringify({ a: 1 }); + const result = await transport.requestWithFallback(['/first', '/second'], { + method: 'POST', + body, + }); + + expect(result).toBe('done'); + expect(seen).toEqual([ + { method: 'POST', body }, + { method: 'POST', body }, + ]); + }); + + it('uploadMultipart: POSTs formData with token auth', async () => { + const calls: Array<{ + url: string; + method?: string; + body: FormData | null; + auth: string | null; + }> = []; + const form = new FormData(); + form.set('hash', 'h1'); + const fetchImpl: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + method: init?.method, + body: (init?.body as FormData | null) ?? null, + auth: headers.get('Authorization'), + }); + return jsonResponse({ code: 'OK', data: { id: 'a1' } }); + }; + const transport = createHubClientTransport({ + baseUrl: BASE_URL, + fetchImpl, + getToken: () => 'tok-up', + timeoutMs: 5_000, + }); + + const result = await transport.uploadMultipart<{ id: string }>('/client/attachments', form); + + expect(result).toEqual({ id: 'a1' }); + expect(calls).toEqual([ + { url: `${BASE_URL}/client/attachments`, method: 'POST', body: form, auth: 'Bearer tok-up' }, + ]); + }); + + it('uploadMultipart: omits auth when no getToken is configured', async () => { + const calls: Array<{ auth: string | null }> = []; + const fetchImpl: typeof fetch = async (_input, init) => { + const headers = new Headers(init?.headers); + calls.push({ auth: headers.get('Authorization') }); + return jsonResponse({ code: 'OK', data: null }); + }; + const transport = createHubClientTransport({ baseUrl: BASE_URL, fetchImpl }); + + const result = await transport.uploadMultipart('/client/attachments', new FormData()); + + expect(result).toBeNull(); + expect(calls).toEqual([{ auth: null }]); + }); + }); +}); diff --git a/app/shared/src/testing/i18n.test.ts b/app/shared/src/testing/i18n.test.ts new file mode 100644 index 000000000..8da619c65 --- /dev/null +++ b/app/shared/src/testing/i18n.test.ts @@ -0,0 +1,319 @@ +// real_tested=true +import i18next from 'i18next'; +import { getI18n, setI18n } from 'react-i18next'; +import { describe, expect, it } from 'vitest'; + +import { CHATVIEW_I18N_NAMESPACE, chatviewResources } from '../chatview/i18n/resources'; +import { + SHARED_WORKBENCH_I18N_NAMESPACE, + flattenSharedWorkbenchResource, + sharedWorkbenchResources, +} from '../i18n'; +import { + TEST_I18N_DEFAULT_LNG, + TEST_I18N_FALLBACK_LNG, + createTestI18n, + installTestI18n, + useTestI18nLanguage, + type TestNamespaceResources, +} from './i18n'; + +const extraBundle: TestNamespaceResources = { + zh: { greeting: '你好', zhOnly: '仅中文' }, + en: { greeting: 'Hello', enOnly: 'English only' }, +}; + +const extraNamespaces = { extra: extraBundle }; + +describe('TEST_I18N constants', () => { + it('uses a pseudo-language that matches no registered bundle', () => { + expect(TEST_I18N_DEFAULT_LNG).toBe('test'); + }); + + it('disables fallback languages by default', () => { + expect(TEST_I18N_FALLBACK_LNG).toBe(false); + }); +}); + +describe('createTestI18n defaults', () => { + it('initializes synchronously with key-echo language and chatview as default ns', () => { + const instance = createTestI18n(); + + expect(instance.isInitialized).toBe(true); + expect(instance.language).toBe('test'); + expect(instance.options.defaultNS).toBe('chatview'); + expect(instance.options.ns).toEqual(['chatview', 'sharedWorkbench']); + }); + + it('disables interpolation escaping', () => { + const instance = createTestI18n(); + + expect(instance.options.interpolation?.escapeValue).toBe(false); + }); + + it('echoes keys verbatim when the language has no bundle', () => { + const instance = createTestI18n(); + + expect(instance.t('typing.dm')).toBe('typing.dm'); + expect(instance.t('deeply.nested.missing.key')).toBe('deeply.nested.missing.key'); + }); + + it('still honors t(key, defaultValue) in key-echo mode', () => { + const instance = createTestI18n(); + + expect(instance.t('typing.dm', { defaultValue: 'fallback copy' })).toBe('fallback copy'); + }); +}); + +describe('createTestI18n language selection', () => { + it('resolves real zh copy when lng is zh', () => { + const instance = createTestI18n({ lng: 'zh' }); + + expect(instance.language).toBe('zh'); + expect(instance.t('typing.dm')).toBe('正在输入...'); + expect(instance.t('chat.you')).toBe('你'); + }); + + it('resolves real en copy when lng is en', () => { + const instance = createTestI18n({ lng: 'en' }); + + expect(instance.language).toBe('en'); + expect(instance.t('typing.dm')).toBe('Typing...'); + expect(instance.t('chat.you')).toBe('You'); + }); + + it('interpolates data into zh templates', () => { + const instance = createTestI18n({ lng: 'zh' }); + + expect(instance.t('typing.single', { name: 'Bob' })).toBe('Bob 正在输入...'); + }); + + it('does not escape interpolated values (escapeValue false)', () => { + const instance = createTestI18n({ lng: 'zh' }); + + expect(instance.t('typing.single', { name: 'Alice' })).toBe('Alice 正在输入...'); + }); + + it('accepts an unknown lng without throwing and echoes keys', () => { + const instance = createTestI18n({ lng: 'not-a-real-language' }); + + expect(instance.language).toBe('not-a-real-language'); + expect(instance.t('typing.dm')).toBe('typing.dm'); + }); +}); + +describe('createTestI18n resources', () => { + it('re-uses the production chatview bundles by reference', () => { + const instance = createTestI18n(); + + expect(instance.getResourceBundle('zh', CHATVIEW_I18N_NAMESPACE)).toBe(chatviewResources.zh); + expect(instance.getResourceBundle('en', CHATVIEW_I18N_NAMESPACE)).toBe(chatviewResources.en); + }); + + it('flattens the zh sharedWorkbench tree like the production helper', () => { + const instance = createTestI18n(); + const bundle = (instance.getResourceBundle('zh', SHARED_WORKBENCH_I18N_NAMESPACE) ?? {}) as Record< + string, + string + >; + + expect(Object.keys(bundle).sort()).toEqual( + flattenSharedWorkbenchResource(sharedWorkbenchResources.zh).sort(), + ); + }); + + it('flattens the en sharedWorkbench tree like the production helper', () => { + const instance = createTestI18n(); + const bundle = (instance.getResourceBundle('en', SHARED_WORKBENCH_I18N_NAMESPACE) ?? {}) as Record< + string, + string + >; + + expect(Object.keys(bundle).sort()).toEqual( + flattenSharedWorkbenchResource(sharedWorkbenchResources.en).sort(), + ); + }); + + it('keeps leaf keys after flattening', () => { + const instance = createTestI18n({ lng: 'zh' }); + const bundle = (instance.getResourceBundle('zh', SHARED_WORKBENCH_I18N_NAMESPACE) ?? {}) as Record< + string, + string + >; + + expect(bundle['nav.chat']).toBe('对话'); + expect(bundle['contacts.empty.title']).toBe('暂无联系人'); + }); + + it('removes nested section keys so lookups resolve the defaultValue fallback', () => { + const instance = createTestI18n({ lng: 'zh' }); + const bundle = (instance.getResourceBundle('zh', SHARED_WORKBENCH_I18N_NAMESPACE) ?? {}) as Record< + string, + string + >; + + expect(bundle).not.toHaveProperty('contacts'); + expect(bundle).not.toHaveProperty('contacts.empty'); + expect(instance.t('contacts.empty', { ns: 'sharedWorkbench', defaultValue: 'FALLBACK' })).toBe( + 'FALLBACK', + ); + }); + + it('resolves flattened sharedWorkbench values per language', () => { + const zh = createTestI18n({ lng: 'zh' }); + const en = createTestI18n({ lng: 'en' }); + + expect(zh.t('nav.chat', { ns: 'sharedWorkbench' })).toBe('对话'); + expect(en.t('nav.chat', { ns: 'sharedWorkbench' })).toBe('Chats'); + expect(zh.t('contacts.empty.title', { ns: 'sharedWorkbench' })).toBe('暂无联系人'); + expect(en.t('contacts.empty.title', { ns: 'sharedWorkbench' })).toBe('No contacts yet'); + }); + + it('merges extra namespaces alongside the built-ins', () => { + const instance = createTestI18n({ extraNamespaces }); + + expect(instance.getResourceBundle('zh', 'extra')).toBe(extraBundle.zh); + expect(instance.getResourceBundle('en', 'extra')).toBe(extraBundle.en); + expect(instance.options.ns).toEqual(['chatview', 'sharedWorkbench', 'extra']); + expect(instance.getResourceBundle('zh', CHATVIEW_I18N_NAMESPACE)).toBe(chatviewResources.zh); + expect(instance.getResourceBundle('en', SHARED_WORKBENCH_I18N_NAMESPACE)).toBeDefined(); + }); + + it('lets project setups override the default namespace', () => { + const instance = createTestI18n({ lng: 'en', defaultNS: 'extra', extraNamespaces }); + + expect(instance.options.defaultNS).toBe('extra'); + expect(instance.t('greeting')).toBe('Hello'); + }); + + it('echoes extra-namespace keys under the default test language', () => { + const instance = createTestI18n({ extraNamespaces }); + + expect(instance.t('greeting', { ns: 'extra' })).toBe('greeting'); + }); +}); + +describe('createTestI18n fallback behavior', () => { + it('does not fall through to en when fallbackLng is disabled', () => { + const instance = createTestI18n({ lng: 'zh', extraNamespaces }); + + expect(instance.t('enOnly', { ns: 'extra' })).toBe('enOnly'); + }); + + it('falls through to en for keys missing from zh when fallbackLng is en', () => { + const instance = createTestI18n({ lng: 'zh', fallbackLng: 'en', extraNamespaces }); + + expect(instance.t('enOnly', { ns: 'extra' })).toBe('English only'); + }); + + it('falls back across the whole language when lng has no bundle at all', () => { + const instance = createTestI18n({ lng: 'fr', fallbackLng: 'en' }); + + expect(instance.t('typing.dm')).toBe('Typing...'); + }); + + it('falls back in the reverse direction from en to zh', () => { + const instance = createTestI18n({ lng: 'en', fallbackLng: 'zh', extraNamespaces }); + + expect(instance.t('zhOnly', { ns: 'extra' })).toBe('仅中文'); + }); + + it('prefers an explicit defaultValue when the key is missing everywhere', () => { + const instance = createTestI18n({ lng: 'zh', fallbackLng: 'en', extraNamespaces }); + + expect(instance.t('completely.missing.key', { defaultValue: 'DEFAULT' })).toBe('DEFAULT'); + }); +}); + +describe('createTestI18n isolation and registration', () => { + it('returns a distinct instance on every call', () => { + const first = createTestI18n(); + const second = createTestI18n(); + + expect(first).not.toBe(second); + expect(first).not.toBe(i18next); + }); + + it('registers each created instance as the react-i18next default', () => { + const instance = createTestI18n({ lng: 'en' }); + + expect(getI18n()).toBe(instance); + expect(getI18n().language).toBe('en'); + }); + + it('leaves the i18next module default singleton untouched', () => { + createTestI18n({ lng: 'zh' }); + + expect(i18next.isInitialized).toBeFalsy(); + }); +}); + +describe('useTestI18nLanguage', () => { + it('switches the registered instance to zh', async () => { + const instance = createTestI18n(); + + const result = useTestI18nLanguage('zh'); + expect(result).toBeDefined(); + await result; + + expect(getI18n()).toBe(instance); + expect(getI18n().language).toBe('zh'); + expect(getI18n().t('typing.dm')).toBe('正在输入...'); + }); + + it('switches back to the key-echo pseudo-language', async () => { + createTestI18n(); + await useTestI18nLanguage('zh'); + + await useTestI18nLanguage('test'); + + expect(getI18n().language).toBe('test'); + expect(getI18n().t('typing.dm')).toBe('typing.dm'); + }); + + it('returns undefined when no react-i18next instance is registered', () => { + // Clear the react-i18next module default so the optional chain short-circuits. + setI18n(undefined as never); + try { + expect(useTestI18nLanguage('zh')).toBeUndefined(); + } finally { + installTestI18n(); // restore a registered instance for later tests + } + }); + + it('accepts an unknown language without throwing and keeps echoing keys', async () => { + createTestI18n(); + + await useTestI18nLanguage('no-such-language'); + + expect(getI18n().language).toBe('no-Such-language'); + expect(getI18n().t('typing.dm')).toBe('typing.dm'); + }); +}); + +describe('installTestI18n', () => { + it('returns a registered instance honoring the given options', () => { + const instance = installTestI18n({ lng: 'en' }); + + expect(instance.language).toBe('en'); + expect(instance.t('typing.dm')).toBe('Typing...'); + expect(getI18n()).toBe(instance); + }); + + it('produces a distinct instance per install and re-registers the default', () => { + const first = installTestI18n({ lng: 'zh' }); + const second = installTestI18n({ lng: 'en' }); + + expect(first).not.toBe(second); + expect(getI18n()).toBe(second); + }); + + it('installs the key-echo defaults when called without options', () => { + const instance = installTestI18n(); + + expect(instance.language).toBe(TEST_I18N_DEFAULT_LNG); + expect(instance.options.defaultNS).toBe('chatview'); + expect(instance.options.ns).toEqual(['chatview', 'sharedWorkbench']); + expect(instance.t('typing.dm')).toBe('typing.dm'); + }); +});