Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps, plus the migration they need. The mechanical part is `sv migrate sveltekit-3`, run one task at a time and reviewed: - svelte.config.js is gone. Its options move into sveltekit() in vite.config.ts, exported as kitOptions so vitest.config.ts runs the same kit setup, including the $test-utils alias the tests import. - $lib becomes #lib through package.json "imports". There is no src/lib/index, so only the "#lib/*" entry is kept. - tsconfig extends $app/tsconfig. - Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite ^8.0.12, svelte-check ^4.7.5. By hand, from the codemod's list of non-automated tasks: - goto's replaceState option is now replace; keepFocus becomes reset: false. For the search typeahead, reset: false also stops the scroll-to-top, which is wanted while typing. - The test setup mocks drop pushState/replaceState and $app/paths base/assets, which kit 3 removed, and mock refreshAll in place of invalidateAll. - The other flagged files only read page.url or goto internal routes, so they needed no change. TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to 7 once both accept it. With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0, so the web lane now audits every dependency rather than only what ships to browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { api, apiFetch } from './client';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
|
|
const res = new Response(
|
|
body === null ? null : JSON.stringify(body),
|
|
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
|
|
);
|
|
const spy = vi.fn().mockResolvedValue(res);
|
|
vi.stubGlobal('fetch', spy);
|
|
return spy;
|
|
}
|
|
|
|
describe('apiFetch', () => {
|
|
test('resolves with parsed JSON on 200', async () => {
|
|
stubFetch(200, { hello: 'world' });
|
|
const result = await apiFetch('/api/ping');
|
|
expect(result).toEqual({ hello: 'world' });
|
|
});
|
|
|
|
test('sends Content-Type application/json by default', async () => {
|
|
const spy = stubFetch(200, {});
|
|
await apiFetch('/api/ping');
|
|
const init = spy.mock.calls[0][1] as RequestInit;
|
|
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
|
});
|
|
|
|
test('resolves to null on 204', async () => {
|
|
stubFetch(204, null);
|
|
const result = await apiFetch('/api/ping', { method: 'DELETE' });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('throws ApiError from error envelope on 4xx', async () => {
|
|
stubFetch(404, { error: { code: 'not_found', message: 'track not found' } });
|
|
await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({
|
|
code: 'not_found',
|
|
message: 'track not found',
|
|
status: 404
|
|
});
|
|
});
|
|
|
|
test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
|
new Response('<html>500</html>', { status: 500, statusText: 'Internal Server Error' })
|
|
));
|
|
await expect(apiFetch('/api/ping')).rejects.toMatchObject({
|
|
code: 'unknown',
|
|
message: 'Internal Server Error',
|
|
status: 500
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('api.get/post/del', () => {
|
|
test('api.get returns typed JSON on 200', async () => {
|
|
stubFetch(200, { id: 'abc', name: 'A' });
|
|
type T = { id: string; name: string };
|
|
const result = await api.get<T>('/api/artists/abc');
|
|
expect(result).toEqual({ id: 'abc', name: 'A' });
|
|
});
|
|
|
|
test('api.post serializes body and sets method', async () => {
|
|
const spy = stubFetch(200, { ok: true });
|
|
await api.post('/api/auth/login', { username: 'u', password: 'p' });
|
|
const init = spy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('POST');
|
|
expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' }));
|
|
});
|
|
|
|
test('api.del sends DELETE and resolves to null on 204', async () => {
|
|
const spy = stubFetch(204, null);
|
|
const result = await api.del('/api/sessions/xyz');
|
|
expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' });
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('apiFetch 401 interceptor', () => {
|
|
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
|
|
const logoutSpy = vi.fn();
|
|
vi.doMock('#lib/auth/store.svelte.js', () => ({
|
|
logout: logoutSpy,
|
|
login: vi.fn(),
|
|
bootstrap: vi.fn(),
|
|
user: { value: null }
|
|
}));
|
|
// Re-import to pick up the mock.
|
|
const { apiFetch: apiFetchFresh } = await import('./client');
|
|
stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } });
|
|
await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({
|
|
code: 'unauthorized',
|
|
status: 401
|
|
});
|
|
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
|
|
vi.doUnmock('#lib/auth/store.svelte.js');
|
|
});
|
|
});
|