feat: add error handling and tests for client.ts

This commit is contained in:
Ilia Mashkov
2026-05-21 15:57:43 +03:00
parent caff3fe7e3
commit 0697e9ad72
3 changed files with 67 additions and 19 deletions
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PBHttpError } from '../error';
import { getCollection } from './client';
describe('getCollection', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe('when PocketBase is unreachable', () => {
it('returns an empty list instead of throwing', async () => {
vi.stubEnv('PB_URL', 'http://localhost:8090');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
const result = await getCollection('projects');
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
});
});
describe('when PocketBase returns an HTTP error', () => {
it('rethrows PBHttpError', async () => {
vi.stubEnv('PB_URL', 'http://localhost:8090');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: vi.fn(),
}),
);
await expect(getCollection('projects')).rejects.toBeInstanceOf(PBHttpError);
});
});
});
@@ -1,13 +1,10 @@
import { PBHttpError } from './error'; import { PBHttpError } from '../error';
import type { ListResponse } from './types'; import type { ListResponse } from '../types';
/* /*
* Native fetch wrapper for PocketBase API requests. * Native fetch wrapper for PocketBase API requests.
*/ */
/* Required in production; falls back to localhost in development. */
const PB_URL = process.env.PB_URL ?? (process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8090' : undefined);
/** /**
* Options for PocketBase collection fetching. * Options for PocketBase collection fetching.
*/ */
@@ -40,12 +37,15 @@ export type PBFetchOptions = {
* Fetch a list of records from a PocketBase collection. * Fetch a list of records from a PocketBase collection.
*/ */
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> { export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
const { sort, filter, expand, tags, revalidate } = options; /* Required in production; falls back to localhost in development. */
const pbUrl = process.env.PB_URL ?? (process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8090' : undefined);
if (!PB_URL) { if (!pbUrl) {
throw new Error('PB_URL is required in production'); throw new Error('PB_URL is required in production');
} }
const { sort, filter, expand, tags, revalidate } = options;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (sort) { if (sort) {
params.set('sort', sort); params.set('sort', sort);
@@ -57,20 +57,28 @@ export async function getCollection<T>(collection: string, options: PBFetchOptio
params.set('expand', expand); params.set('expand', expand);
} }
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`; const url = `${pbUrl}/api/collections/${collection}/records?${params.toString()}`;
const res = await fetch(url, { try {
next: { const res = await fetch(url, {
tags: tags ?? [], next: {
revalidate: revalidate ?? 3600, tags: tags ?? [],
}, revalidate: revalidate ?? 3600,
}); },
});
if (!res.ok) { if (!res.ok) {
throw new PBHttpError(res.status, collection, res.statusText); throw new PBHttpError(res.status, collection, res.statusText);
}
return res.json();
} catch (err) {
if (err instanceof PBHttpError) {
throw err;
}
console.warn(`[getCollection] "${collection}" unreachable — returning empty list`, err);
return { items: [], page: 1, perPage: 0, totalItems: 0, totalPages: 0 };
} }
return res.json();
} }
/** /**
+1 -1
View File
@@ -1,2 +1,2 @@
export * from './client'; export * from './client/client';
export * from './types'; export * from './types';