180 lines
5.3 KiB
JavaScript
180 lines
5.3 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|||
|
|
import * as npm from './npm-registry.js';
|
||
|
|
|
||
|
|
const originalFetch = globalThis.fetch;
|
||
|
|
const originalDateNow = Date.now;
|
||
|
|
|
||
|
|
let fetchMock;
|
||
|
|
|
||
|
|
function jsonResponse(body, status = 200) {
|
||
|
|
return Promise.resolve(new Response(JSON.stringify(body), {
|
||
|
|
status,
|
||
|
|
headers: { 'content-type': 'application/json' },
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('npm registry client', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
npm.clearCache();
|
||
|
|
Date.now = originalDateNow;
|
||
|
|
fetchMock = mock(() => jsonResponse({}));
|
||
|
|
globalThis.fetch = fetchMock;
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
npm.clearCache();
|
||
|
|
globalThis.fetch = originalFetch;
|
||
|
|
Date.now = originalDateNow;
|
||
|
|
});
|
||
|
|
|
||
|
|
test('200 success returns latest versions and dist tags', async () => {
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({
|
||
|
|
'dist-tags': { latest: '1.2.0' },
|
||
|
|
versions: { '1.0.0': {}, '1.2.0': {} },
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await npm.lookupNpmPackage('foo');
|
||
|
|
|
||
|
|
expect(result).toEqual({
|
||
|
|
ok: true,
|
||
|
|
latest: '1.2.0',
|
||
|
|
versions: ['1.0.0', '1.2.0'],
|
||
|
|
distTags: { latest: '1.2.0' },
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('200 success handles missing dist-tags and versions', async () => {
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({}));
|
||
|
|
|
||
|
|
const result = await npm.lookupNpmPackage('foo');
|
||
|
|
|
||
|
|
expect(result).toEqual({ ok: true, latest: null, versions: [], distTags: {} });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('404 returns package not found', async () => {
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({}, 404));
|
||
|
|
|
||
|
|
const result = await npm.lookupNpmPackage('missing');
|
||
|
|
|
||
|
|
expect(result).toEqual({ ok: false, status: 404, error: 'Package not found' });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('500 returns registry error', async () => {
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({}, 500));
|
||
|
|
|
||
|
|
const result = await npm.lookupNpmPackage('foo');
|
||
|
|
|
||
|
|
expect(result).toEqual({ ok: false, status: 500, error: 'Registry returned 500' });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('network error returns network status', async () => {
|
||
|
|
fetchMock.mockImplementation(() => Promise.reject(new Error('socket closed')));
|
||
|
|
|
||
|
|
const result = await npm.lookupNpmPackage('foo');
|
||
|
|
|
||
|
|
expect(result.ok).toBe(false);
|
||
|
|
expect(result.status).toBe('network');
|
||
|
|
expect(result.error).toBe('socket closed');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('timeout plumbs AbortSignal to fetch', async () => {
|
||
|
|
fetchMock.mockImplementation((_url, init) => {
|
||
|
|
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||
|
|
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await npm.lookupNpmPackage('foo');
|
||
|
|
|
||
|
|
expect(result.ok).toBe(false);
|
||
|
|
expect(result.status).toBe('network');
|
||
|
|
expect(result.error).toContain('aborted');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('cache hit reuses definitive success', async () => {
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({ 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {} } }));
|
||
|
|
|
||
|
|
const first = await npm.getNpmInfo('foo');
|
||
|
|
const second = await npm.getNpmInfo('foo');
|
||
|
|
|
||
|
|
expect(first).toEqual(second);
|
||
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('cache miss after ttl fetches again', async () => {
|
||
|
|
let now = 1_000;
|
||
|
|
Date.now = mock(() => now);
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
|
||
|
|
|
||
|
|
await npm.getNpmInfo('foo');
|
||
|
|
now += 3_600_001;
|
||
|
|
await npm.getNpmInfo('foo');
|
||
|
|
|
||
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('forceRefresh bypasses cache', async () => {
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
|
||
|
|
|
||
|
|
await npm.getNpmInfo('foo');
|
||
|
|
await npm.getNpmInfo('foo', { forceRefresh: true });
|
||
|
|
|
||
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('in-flight requests dedup by package name', async () => {
|
||
|
|
let release;
|
||
|
|
const wait = new Promise((resolve) => {
|
||
|
|
release = resolve;
|
||
|
|
});
|
||
|
|
fetchMock.mockImplementation(async () => {
|
||
|
|
await wait;
|
||
|
|
return new Response(JSON.stringify({ versions: { '1.0.0': {} } }), { status: 200 });
|
||
|
|
});
|
||
|
|
|
||
|
|
const requests = Promise.all([
|
||
|
|
npm.getNpmInfo('foo'),
|
||
|
|
npm.getNpmInfo('foo'),
|
||
|
|
npm.getNpmInfo('foo'),
|
||
|
|
]);
|
||
|
|
release();
|
||
|
|
const results = await requests;
|
||
|
|
|
||
|
|
expect(results.every((result) => result.ok)).toBe(true);
|
||
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('network failure is not cached', async () => {
|
||
|
|
fetchMock
|
||
|
|
.mockImplementationOnce(() => Promise.reject(new Error('down')))
|
||
|
|
.mockImplementationOnce(() => jsonResponse({ versions: {} }));
|
||
|
|
|
||
|
|
const first = await npm.getNpmInfo('foo');
|
||
|
|
const second = await npm.getNpmInfo('foo');
|
||
|
|
|
||
|
|
expect(first.status).toBe('network');
|
||
|
|
expect(second.ok).toBe(true);
|
||
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('404 is cached', async () => {
|
||
|
|
fetchMock.mockImplementation(() => jsonResponse({}, 404));
|
||
|
|
|
||
|
|
await npm.getNpmInfo('missing');
|
||
|
|
await npm.getNpmInfo('missing');
|
||
|
|
|
||
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('scoped names encode slash in registry url', async () => {
|
||
|
|
await npm.getNpmInfo('@scope/pkg');
|
||
|
|
|
||
|
|
expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@scope%2Fpkg');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('user-agent header is present', async () => {
|
||
|
|
await npm.getNpmInfo('foo');
|
||
|
|
|
||
|
|
expect(fetchMock.mock.calls[0][1].headers['User-Agent']).toMatch(/^openchamber-server\//);
|
||
|
|
});
|
||
|
|
});
|