Files
2026-07-13 12:30:28 +08:00

234 lines
7.1 KiB
TypeScript

import { beforeEach, describe, expect, test, vi } from 'vitest';
import nock from 'nock';
async function loadApiClient(env: Record<string, string> = {}) {
vi.resetModules();
vi.doUnmock('../../bin/utils/webLogin');
process.env.PINME_API_BASE = env.PINME_API_BASE || 'https://api.pinme.test';
return import('../../bin/utils/apiClient');
}
async function loadApiClientWithAuthMock() {
vi.resetModules();
process.env.PINME_API_BASE = 'https://api.pinme.test';
vi.doMock('../../bin/utils/webLogin', () => ({
getAuthHeaders: () => ({
'token-address': '0xabc',
'authentication-tokens': 'secret-token',
}),
}));
return import('../../bin/utils/apiClient');
}
async function loadApiClientWithThrowingAuthMock() {
vi.resetModules();
process.env.PINME_API_BASE = 'https://api.pinme.test';
vi.doMock('../../bin/utils/webLogin', () => ({
getAuthHeaders: () => {
throw new Error('auth config unreadable');
},
}));
return import('../../bin/utils/apiClient');
}
describe('apiClient', () => {
beforeEach(() => {
nock.cleanAll();
});
test('returns successful business responses', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test')
.get('/root_domain')
.reply(200, { code: 200, data: { domain: 'pinme.test' } });
const response = await createPinmeApiClient().get('/root_domain');
expect(response.data.data.domain).toBe('pinme.test');
expect(nock.isDone()).toBe(true);
});
test('turns non-200 business codes into CliError', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test')
.post('/bind_pinme_domain')
.reply(200, { code: 500, msg: 'Domain is taken' });
await expect(
createPinmeApiClient().post('/bind_pinme_domain', {
domain_name: 'demo',
hash: 'bafy',
}),
).rejects.toMatchObject({
name: 'CliError',
stage: 'API request',
message: 'Domain is taken',
details: ['Request: POST /bind_pinme_domain', 'Business code: 500'],
});
});
test('wraps HTTP failures with request context', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test')
.get('/my_domains')
.reply(503, { message: 'Temporarily unavailable' });
await expect(createPinmeApiClient().get('/my_domains')).rejects.toMatchObject(
{
name: 'CliError',
stage: 'API request',
message: 'Temporarily unavailable',
details: ['Request: GET /my_domains', 'HTTP status: 503'],
},
);
});
test('injects auth headers from local auth config by default', async () => {
const { createPinmeApiClient } = await loadApiClientWithAuthMock();
let capturedHeaders: Record<string, string | string[] | undefined> = {};
nock('https://api.pinme.test')
.get('/my_domains')
.reply(function () {
capturedHeaders = this.req.headers;
return [200, { code: 200, data: [] }];
});
await expect(createPinmeApiClient().get('/my_domains')).resolves.toMatchObject(
{
data: { code: 200, data: [] },
},
);
expect(capturedHeaders['token-address']).toBe('0xabc');
expect(capturedHeaders['authentication-tokens']).toBe('secret-token');
});
test('omits auth headers when includeAuth is false', async () => {
const { createPinmeApiClient } = await loadApiClientWithAuthMock();
nock('https://api.pinme.test', {
badheaders: ['token-address', 'authentication-tokens'],
})
.get('/public')
.reply(200, { ok: true });
const response = await createPinmeApiClient({ includeAuth: false }).get(
'/public',
);
expect(response.data).toEqual({ ok: true });
});
test('merges custom headers over defaults', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test', {
reqheaders: {
'user-agent': 'Custom-Agent',
'x-test-suite': 'api-client',
},
})
.get('/headers')
.reply(200, { ok: true });
await createPinmeApiClient({
includeAuth: false,
headers: {
'User-Agent': 'Custom-Agent',
'X-Test-Suite': 'api-client',
},
}).get('/headers');
expect(nock.isDone()).toBe(true);
});
test('sends default JSON CLI headers', async () => {
const { createPinmeApiClient } = await loadApiClient();
let capturedHeaders: Record<string, string | string[] | undefined> = {};
nock('https://api.pinme.test')
.get('/headers')
.reply(function () {
capturedHeaders = this.req.headers;
return [200, { ok: true }];
});
await createPinmeApiClient({ includeAuth: false }).get('/headers');
expect(capturedHeaders.accept).toBe('*/*');
expect(capturedHeaders['content-type']).toBe('application/json');
expect(capturedHeaders['user-agent']).toBe('Pinme-CLI');
expect(capturedHeaders.connection).toBe('keep-alive');
});
test('continues without auth headers when local auth config is unreadable', async () => {
const { createPinmeApiClient } = await loadApiClientWithThrowingAuthMock();
nock('https://api.pinme.test', {
badheaders: ['token-address', 'authentication-tokens'],
})
.get('/public')
.reply(200, { ok: true });
await expect(createPinmeApiClient().get('/public')).resolves.toMatchObject({
data: { ok: true },
});
});
test('does not treat non-object response bodies as business errors', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test').get('/plain').reply(200, 'code 500');
const response = await createPinmeApiClient({ includeAuth: false }).get(
'/plain',
);
expect(response.data).toBe('code 500');
});
test('omits request context when axios config has no URL', async () => {
const { createApiClient } = await loadApiClient();
const client = createApiClient({
includeAuth: false,
baseURL: 'https://api.pinme.test',
});
await expect(
client.request({
adapter: async (config) => ({
data: { code: 500, msg: 'Adapter business failure' },
status: 200,
statusText: 'OK',
headers: {},
config,
}),
}),
).rejects.toMatchObject({
message: 'Adapter business failure',
details: ['Business code: 500'],
});
});
test('uses GET as the default request descriptor method', async () => {
const { createApiClient } = await loadApiClient();
const client = createApiClient({
includeAuth: false,
baseURL: 'https://api.pinme.test',
});
await expect(
client.request({
url: '/adapter-default-method',
adapter: async (config) => ({
data: { code: 500, msg: 'Adapter business failure' },
status: 200,
statusText: 'OK',
headers: {},
config,
}),
}),
).rejects.toMatchObject({
message: 'Adapter business failure',
details: [
'Request: GET /adapter-default-method',
'Business code: 500',
],
});
});
});