Files
tools/core/fetch/src/utils.test.ts
T
robonen a7e668ced8 feat(fetch): plugin system + eslint/tsconfig migration
- Add fetch plugin API (definePlugin, plugins) with type-level option flow.
- Migrate to eslint flat config and composite tsconfig.
2026-06-07 16:29:18 +07:00

270 lines
8.4 KiB
TypeScript

import {
buildURL,
callHooks,
detectResponseType,
isJSONSerializable,
isPayloadMethod,
joinURL,
resolveFetchOptions,
} from './utils';
import { describe, expect, it } from 'vitest';
import type { FetchContext } from './types';
// ---------------------------------------------------------------------------
// isPayloadMethod
// ---------------------------------------------------------------------------
describe('isPayloadMethod', () => {
it('returns true for payload methods', () => {
expect(isPayloadMethod('POST')).toBe(true);
expect(isPayloadMethod('PUT')).toBe(true);
expect(isPayloadMethod('PATCH')).toBe(true);
expect(isPayloadMethod('DELETE')).toBe(true);
});
it('returns false for non-payload methods', () => {
expect(isPayloadMethod('GET')).toBe(false);
expect(isPayloadMethod('HEAD')).toBe(false);
expect(isPayloadMethod('OPTIONS')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isJSONSerializable
// ---------------------------------------------------------------------------
describe('isJSONSerializable', () => {
it('returns false for undefined', () => {
expect(isJSONSerializable(undefined)).toBe(false);
});
it('returns true for primitives', () => {
expect(isJSONSerializable('hello')).toBe(true);
expect(isJSONSerializable(42)).toBe(true);
expect(isJSONSerializable(true)).toBe(true);
expect(isJSONSerializable(null)).toBe(true);
});
it('returns false for functions, symbols, bigints', () => {
expect(isJSONSerializable(() => {})).toBe(false);
expect(isJSONSerializable(Symbol('x'))).toBe(false);
expect(isJSONSerializable(42n)).toBe(false);
});
it('returns true for plain arrays', () => {
expect(isJSONSerializable([1, 2, 3])).toBe(true);
});
it('returns false for ArrayBuffer-like values', () => {
expect(isJSONSerializable(new Uint8Array([1, 2]))).toBe(false);
});
it('returns false for FormData and URLSearchParams', () => {
expect(isJSONSerializable(new FormData())).toBe(false);
expect(isJSONSerializable(new URLSearchParams())).toBe(false);
});
it('returns true for plain objects', () => {
expect(isJSONSerializable({ a: 1 })).toBe(true);
});
it('returns true for objects with toJSON', () => {
expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// detectResponseType
// ---------------------------------------------------------------------------
describe('detectResponseType', () => {
it('defaults to json when content-type is empty', () => {
expect(detectResponseType('')).toBe('json');
expect(detectResponseType()).toBe('json');
});
it('detects json content types', () => {
expect(detectResponseType('application/json')).toBe('json');
expect(detectResponseType('application/json; charset=utf-8')).toBe('json');
expect(detectResponseType('application/vnd.api+json')).toBe('json');
});
it('detects event-stream as stream', () => {
expect(detectResponseType('text/event-stream')).toBe('stream');
});
it('detects text content types', () => {
expect(detectResponseType('text/plain')).toBe('text');
expect(detectResponseType('text/html')).toBe('text');
expect(detectResponseType('application/xml')).toBe('text');
});
it('falls back to blob for binary types', () => {
expect(detectResponseType('image/png')).toBe('blob');
expect(detectResponseType('application/octet-stream')).toBe('blob');
});
});
// ---------------------------------------------------------------------------
// buildURL
// ---------------------------------------------------------------------------
describe('buildURL', () => {
it('appends query params to a clean URL', () => {
expect(buildURL('https://api.example.com', { page: 1, limit: 20 })).toBe(
'https://api.example.com?page=1&limit=20',
);
});
it('appends to an existing query string with &', () => {
expect(buildURL('https://api.example.com?foo=bar', { baz: 'qux' })).toBe(
'https://api.example.com?foo=bar&baz=qux',
);
});
it('omits null and undefined values', () => {
expect(buildURL('https://api.example.com', { a: null, b: undefined, c: 'keep' })).toBe(
'https://api.example.com?c=keep',
);
});
it('returns the URL unchanged when all params are omitted', () => {
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
});
it('inserts the query string before a fragment', () => {
expect(buildURL('https://api.example.com/p#section', { a: 1 })).toBe(
'https://api.example.com/p?a=1#section',
);
});
it('appends to an existing query string before a fragment', () => {
expect(buildURL('https://api.example.com/p?foo=bar#section', { baz: 'qux' })).toBe(
'https://api.example.com/p?foo=bar&baz=qux#section',
);
});
});
// ---------------------------------------------------------------------------
// joinURL
// ---------------------------------------------------------------------------
describe('joinURL', () => {
it('joins base and path correctly', () => {
expect(joinURL('https://api.example.com/v1', '/users')).toBe(
'https://api.example.com/v1/users',
);
});
it('does not double slashes', () => {
expect(joinURL('https://api.example.com/v1/', '/users')).toBe(
'https://api.example.com/v1/users',
);
});
it('adds a slash when neither side has one', () => {
expect(joinURL('https://api.example.com/v1', 'users')).toBe(
'https://api.example.com/v1/users',
);
});
it('returns base when path is empty', () => {
expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com');
});
it('returns base when path is "/"', () => {
expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com');
});
});
// ---------------------------------------------------------------------------
// callHooks
// ---------------------------------------------------------------------------
describe('callHooks', () => {
function makeCtx(): FetchContext {
return {
request: 'https://example.com',
options: { headers: new Headers() },
response: undefined,
error: undefined,
} as FetchContext;
}
it('does nothing when hooks is undefined', async () => {
await expect(callHooks(makeCtx(), undefined)).resolves.toBeUndefined();
});
it('calls a single hook', async () => {
const calls: number[] = [];
await callHooks(makeCtx(), () => {
calls.push(1);
});
expect(calls).toEqual([1]);
});
it('calls an array of hooks in order', async () => {
const calls: number[] = [];
await callHooks(makeCtx(), [
() => { calls.push(1); },
() => { calls.push(2); },
() => { calls.push(3); },
]);
expect(calls).toEqual([1, 2, 3]);
});
it('awaits async hooks', async () => {
const calls: number[] = [];
await callHooks(makeCtx(), [
async () => {
await Promise.resolve();
calls.push(1);
},
() => {
calls.push(2);
},
]);
expect(calls).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// resolveFetchOptions
// ---------------------------------------------------------------------------
describe('resolveFetchOptions', () => {
it('returns an object with a Headers instance', () => {
const resolved = resolveFetchOptions('https://example.com', undefined, undefined);
expect(resolved.headers).toBeInstanceOf(Headers);
});
it('merges input and default headers (input wins)', () => {
const resolved = resolveFetchOptions(
'https://example.com',
{ headers: { 'x-custom': 'input' } },
{ headers: { 'x-custom': 'default', 'x-default-only': 'yes' } },
);
expect(resolved.headers.get('x-custom')).toBe('input');
expect(resolved.headers.get('x-default-only')).toBe('yes');
});
it('merges query params from defaults and input', () => {
const resolved = resolveFetchOptions(
'https://example.com',
{ query: { a: '1' } },
{ query: { b: '2' } },
);
expect(resolved.query).toEqual({ a: '1', b: '2' });
});
it('merges params alias into query', () => {
const resolved = resolveFetchOptions(
'https://example.com',
{ params: { p: '10' } },
undefined,
);
expect(resolved.query).toEqual({ p: '10' });
expect(resolved.params).toEqual({ p: '10' });
});
});