- listAllInvoices() concatenates pages in order, size=200 on each request
- listAllInvoiceItems(invoiceId) requests the nested /invoices/{id}/items
path and concatenates its pages
- Extends the existing GET-only / Authorization-header assertion to both
new methods (PAX8-08)
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { Pax8Client } from './pax8-client';
|
|
|
|
const SECRET = 'super-secret-value-should-never-leak';
|
|
|
|
function makeFetchMock(opts: {
|
|
tokenOk?: boolean;
|
|
tokenStatus?: number;
|
|
tokenBody?: unknown;
|
|
companiesBody?: unknown;
|
|
}) {
|
|
const {
|
|
tokenOk = true,
|
|
tokenStatus = 200,
|
|
tokenBody = { access_token: 'tok', token_type: 'Bearer', expires_in: 86400 },
|
|
companiesBody = { content: [{ id: 'c1', name: 'Acme' }], page: { size: 10, totalElements: 1, totalPages: 1, number: 0 } },
|
|
} = opts;
|
|
|
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
|
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
calls.push({ url, init });
|
|
|
|
if (url.includes('/token')) {
|
|
return {
|
|
ok: tokenOk,
|
|
status: tokenStatus,
|
|
json: async () => tokenBody,
|
|
text: async () => `token error status ${tokenStatus}`,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => companiesBody,
|
|
text: async () => '',
|
|
} as unknown as Response;
|
|
});
|
|
|
|
return { fetchMock, calls };
|
|
}
|
|
|
|
/**
|
|
* Multi-page fetch mock for the listAll* pagination helpers. Keys page
|
|
* bodies by URL substring (e.g. '/subscriptions') and by the `page=N` query
|
|
* param, returning one page envelope per (resource, page) pair.
|
|
*/
|
|
function makeMultiPageFetchMock(opts: {
|
|
resource: string; // e.g. 'subscriptions', 'products', 'companies'
|
|
pages: Array<{ content: unknown[]; totalPages: number }>;
|
|
tokenBody?: unknown;
|
|
}) {
|
|
const {
|
|
resource,
|
|
pages,
|
|
tokenBody = { access_token: 'tok', token_type: 'Bearer', expires_in: 86400 },
|
|
} = opts;
|
|
|
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
|
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
calls.push({ url, init });
|
|
|
|
if (url.includes('/token')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => tokenBody,
|
|
text: async () => '',
|
|
} as unknown as Response;
|
|
}
|
|
|
|
if (url.includes(`/${resource}`)) {
|
|
const match = url.match(/page=(\d+)/);
|
|
const pageNum = match ? parseInt(match[1], 10) : 0;
|
|
const p = pages[pageNum];
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
content: p.content,
|
|
page: { size: 200, totalElements: pages.reduce((n, pg) => n + pg.content.length, 0), totalPages: p.totalPages, number: pageNum },
|
|
}),
|
|
text: async () => '',
|
|
} as unknown as Response;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ content: [], page: { size: 200, totalElements: 0, totalPages: 1, number: 0 } }),
|
|
text: async () => '',
|
|
} as unknown as Response;
|
|
});
|
|
|
|
return { fetchMock, calls };
|
|
}
|
|
|
|
describe('Pax8Client', () => {
|
|
beforeEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('getToken() POSTs a JSON body with grant_type, client_id, client_secret, audience', async () => {
|
|
const { fetchMock, calls } = makeFetchMock({});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
await client.listCompanies();
|
|
|
|
const tokenCall = calls.find(c => c.url.includes('/token'));
|
|
expect(tokenCall).toBeDefined();
|
|
expect((tokenCall!.init!.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
|
|
|
const parsedBody = JSON.parse(tokenCall!.init!.body as string);
|
|
expect(parsedBody.grant_type).toBe('client_credentials');
|
|
expect(parsedBody.client_id).toBe('id1');
|
|
expect(parsedBody.client_secret).toBe(SECRET);
|
|
expect(parsedBody.audience).toBe('https://api.pax8.com');
|
|
});
|
|
|
|
it('reuses the cached token without a second fetch within the cache window', async () => {
|
|
const { fetchMock, calls } = makeFetchMock({});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
await client.listCompanies();
|
|
await client.listCompanies();
|
|
|
|
const tokenCalls = calls.filter(c => c.url.includes('/token'));
|
|
expect(tokenCalls).toHaveLength(1);
|
|
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 token + 2 companies
|
|
});
|
|
|
|
it('throws on a not-ok token response, including the status but never the secret', async () => {
|
|
const { fetchMock } = makeFetchMock({ tokenOk: false, tokenStatus: 401 });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
|
|
await expect(client.listCompanies()).rejects.toThrow(/401/);
|
|
await expect(client.listCompanies()).rejects.not.toThrow(new RegExp(SECRET));
|
|
});
|
|
|
|
it('listCompanies() sends Authorization: Bearer <token> and returns the parsed envelope', async () => {
|
|
const companiesBody = {
|
|
content: [{ id: 'c1', name: 'Acme Co' }],
|
|
page: { size: 10, totalElements: 1, totalPages: 1, number: 0 },
|
|
};
|
|
const { fetchMock, calls } = makeFetchMock({ companiesBody });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
const result = await client.listCompanies();
|
|
|
|
const companiesCall = calls.find(c => c.url.includes('/companies'));
|
|
expect(companiesCall).toBeDefined();
|
|
const authHeader = (companiesCall!.init!.headers as Record<string, string>)['Authorization'];
|
|
expect(authHeader).toMatch(/^Bearer /);
|
|
|
|
expect(result).toEqual(companiesBody);
|
|
});
|
|
|
|
it('listAllSubscriptions() concatenates content across all pages in order', async () => {
|
|
const pages = [
|
|
{ content: [{ id: 's1' }, { id: 's2' }], totalPages: 2 },
|
|
{ content: [{ id: 's3' }], totalPages: 2 },
|
|
];
|
|
const { fetchMock, calls } = makeMultiPageFetchMock({ resource: 'subscriptions', pages });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
const result = await client.listAllSubscriptions();
|
|
|
|
expect(result).toEqual([{ id: 's1' }, { id: 's2' }, { id: 's3' }]);
|
|
|
|
const subCalls = calls.filter(c => c.url.includes('/subscriptions'));
|
|
expect(subCalls).toHaveLength(2);
|
|
for (const c of subCalls) {
|
|
expect(c.url).toContain('size=200');
|
|
}
|
|
});
|
|
|
|
it('listAllSubscriptions() stops after a single page when totalPages is 1 (no infinite loop)', async () => {
|
|
const pages = [{ content: [{ id: 's1' }], totalPages: 1 }];
|
|
const { fetchMock, calls } = makeMultiPageFetchMock({ resource: 'subscriptions', pages });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
const result = await client.listAllSubscriptions();
|
|
|
|
expect(result).toEqual([{ id: 's1' }]);
|
|
expect(calls.filter(c => c.url.includes('/subscriptions'))).toHaveLength(1);
|
|
});
|
|
|
|
it('listAllProducts() concatenates content across all pages in order', async () => {
|
|
const pages = [
|
|
{ content: [{ id: 'p1' }], totalPages: 2 },
|
|
{ content: [{ id: 'p2' }], totalPages: 2 },
|
|
];
|
|
const { fetchMock } = makeMultiPageFetchMock({ resource: 'products', pages });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
const result = await client.listAllProducts();
|
|
|
|
expect(result).toEqual([{ id: 'p1' }, { id: 'p2' }]);
|
|
});
|
|
|
|
it('listAllCompanies() concatenates content across all pages in order', async () => {
|
|
const pages = [
|
|
{ content: [{ id: 'c1' }], totalPages: 2 },
|
|
{ content: [{ id: 'c2' }], totalPages: 2 },
|
|
];
|
|
const { fetchMock } = makeMultiPageFetchMock({ resource: 'companies', pages });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
const result = await client.listAllCompanies();
|
|
|
|
expect(result).toEqual([{ id: 'c1' }, { id: 'c2' }]);
|
|
});
|
|
|
|
it('every request the listAll* helpers issue is a GET with an Authorization: Bearer header; none use a mutating method', async () => {
|
|
const pages = [{ content: [{ id: 's1' }], totalPages: 1 }];
|
|
const { fetchMock, calls } = makeMultiPageFetchMock({ resource: 'subscriptions', pages });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
await client.listAllSubscriptions();
|
|
|
|
const dataCalls = calls.filter(c => !c.url.includes('/token'));
|
|
expect(dataCalls.length).toBeGreaterThan(0);
|
|
for (const c of dataCalls) {
|
|
expect(c.init?.method === undefined || c.init?.method === 'GET').toBe(true);
|
|
const authHeader = (c.init!.headers as Record<string, string>)['Authorization'];
|
|
expect(authHeader).toMatch(/^Bearer /);
|
|
}
|
|
});
|
|
|
|
it('listAllInvoices() concatenates content across all pages in order', async () => {
|
|
const pages = [
|
|
{ content: [{ id: 'inv-1' }, { id: 'inv-2' }], totalPages: 2 },
|
|
{ content: [{ id: 'inv-3' }], totalPages: 2 },
|
|
];
|
|
const { fetchMock, calls } = makeMultiPageFetchMock({ resource: 'invoices', pages });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
const result = await client.listAllInvoices();
|
|
|
|
expect(result).toEqual([{ id: 'inv-1' }, { id: 'inv-2' }, { id: 'inv-3' }]);
|
|
|
|
const invoiceCalls = calls.filter(c => c.url.includes('/invoices') && !c.url.includes('/items'));
|
|
expect(invoiceCalls).toHaveLength(2);
|
|
for (const c of invoiceCalls) {
|
|
expect(c.url).toContain('size=200');
|
|
}
|
|
});
|
|
|
|
it('listAllInvoiceItems(invoiceId) requests the nested per-invoice path and concatenates its pages', async () => {
|
|
const invoiceId = 'inv-123';
|
|
const pages = [
|
|
{ content: [{ id: 'item-1' }, { id: 'item-2' }], totalPages: 2 },
|
|
{ content: [{ id: 'item-3' }], totalPages: 2 },
|
|
];
|
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
calls.push({ url, init });
|
|
|
|
if (url.includes('/token')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ access_token: 'tok', token_type: 'Bearer', expires_in: 86400 }),
|
|
text: async () => '',
|
|
} as unknown as Response;
|
|
}
|
|
|
|
if (url.includes(`/invoices/${invoiceId}/items`)) {
|
|
const match = url.match(/page=(\d+)/);
|
|
const pageNum = match ? parseInt(match[1], 10) : 0;
|
|
const p = pages[pageNum];
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
content: p.content,
|
|
page: { size: 200, totalElements: pages.reduce((n, pg) => n + pg.content.length, 0), totalPages: p.totalPages, number: pageNum },
|
|
}),
|
|
text: async () => '',
|
|
} as unknown as Response;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ content: [], page: { size: 200, totalElements: 0, totalPages: 1, number: 0 } }),
|
|
text: async () => '',
|
|
} as unknown as Response;
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
const result = await client.listAllInvoiceItems(invoiceId);
|
|
|
|
expect(result).toEqual([{ id: 'item-1' }, { id: 'item-2' }, { id: 'item-3' }]);
|
|
|
|
const itemCalls = calls.filter(c => c.url.includes(`/invoices/${invoiceId}/items`));
|
|
expect(itemCalls).toHaveLength(2);
|
|
expect(itemCalls.some(c => c.url.includes('/invoices/inv-123/items'))).toBe(true);
|
|
for (const c of itemCalls) {
|
|
expect(c.url).toContain('size=200');
|
|
}
|
|
});
|
|
|
|
it('listAllInvoices() and listAllInvoiceItems() only issue GET requests with an Authorization: Bearer header', async () => {
|
|
const invoicePages = [{ content: [{ id: 'inv-1' }], totalPages: 1 }];
|
|
const { fetchMock: invoiceFetchMock, calls: invoiceCalls } = makeMultiPageFetchMock({
|
|
resource: 'invoices',
|
|
pages: invoicePages,
|
|
});
|
|
vi.stubGlobal('fetch', invoiceFetchMock);
|
|
|
|
const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET });
|
|
await client.listAllInvoices();
|
|
await client.listAllInvoiceItems('inv-123');
|
|
|
|
const dataCalls = invoiceCalls.filter(c => !c.url.includes('/token'));
|
|
expect(dataCalls.length).toBeGreaterThan(0);
|
|
for (const c of dataCalls) {
|
|
expect(c.init?.method === undefined || c.init?.method === 'GET').toBe(true);
|
|
const authHeader = (c.init!.headers as Record<string, string>)['Authorization'];
|
|
expect(authHeader).toMatch(/^Bearer /);
|
|
}
|
|
});
|
|
});
|