#4: vitest harness + suites for createCoreFetch / createI18n / useI18nStore
Bootstrap vitest with jsdom + @testing-library/react. 25 tests covering: - core-fetch.test.ts (14): wrapped vs body shape, 204, non-JSON 2xx text, 401/409 hooks (incl. decorate), formatError override, network failure + onNetworkFailure, sync queue + non-mutating bypass, request shape (X-Requested-With, credentials: include), baseUrl trailing-slash strip. - i18n.test.ts (10): resolver chain (locale-tone → locale → default → raw), once-per-(key,locale,tone) onMiss firing + flip on locale change, subscribe/notify with same-value no-op + unsubscribe, getSnapshot. - i18n-react.test.tsx (1): useI18nStore re-renders on locale change. prepare: drops `|| true` per audit — `tsc && vitest run` so publish is gated on tests passing. Bump to 0.3.7. Closes #4 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4f9289bdc
commit
90c5ca2248
6 changed files with 2841 additions and 165 deletions
2673
package-lock.json
generated
2673
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@uschuster/webapp-scaffold",
|
||||
"version": "0.3.6",
|
||||
"version": "0.3.7",
|
||||
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -32,12 +32,23 @@
|
|||
},
|
||||
"files": ["bin/", "dist/", "src/", "templates/", "README.md", "LICENSE"],
|
||||
"scripts": {
|
||||
"prepare": "tsc || true"
|
||||
"prepare": "tsc && vitest run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0"
|
||||
"@types/react": "^19.0.0",
|
||||
"vitest": "^2.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0",
|
||||
|
|
|
|||
176
src/core-fetch.test.ts
Normal file
176
src/core-fetch.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// Vitest suite for createCoreFetch (#4).
|
||||
//
|
||||
// Covers the user-visible response shapes, hook wiring (onEnqueue, on401,
|
||||
// on409, onNetworkFailure, formatError), and the boundary cases the audit
|
||||
// flagged: 401 hook fires + caller still gets an error, 409 hook can swallow,
|
||||
// network failure hook can synthesise a body, non-JSON 2xx returns text.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createCoreFetch } from './core-fetch.js';
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
describe('createCoreFetch — response shapes', () => {
|
||||
it("'wrapped' default returns { data, status, headers }", async () => {
|
||||
const cf = createCoreFetch({ fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })) });
|
||||
const r = await cf<{ data: { ok: number }; status: number }>('/x');
|
||||
expect(r.data).toEqual({ ok: 1 });
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("'body' returns the parsed body directly", async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })),
|
||||
});
|
||||
const r = await cf<{ ok: number }>('/x');
|
||||
expect(r).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it('non-JSON 2xx returns the body as plain text', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
fetchImpl: () => Promise.resolve(new Response('hello', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})),
|
||||
});
|
||||
const r = await cf<string>('/x');
|
||||
expect(r).toBe('hello');
|
||||
});
|
||||
|
||||
it('204 No Content returns undefined data with no JSON parse', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
fetchImpl: () => Promise.resolve(new Response(null, { status: 204 })),
|
||||
});
|
||||
const r = await cf<undefined>('/x');
|
||||
expect(r).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCoreFetch — error hooks', () => {
|
||||
it('401 fires on401 then throws', async () => {
|
||||
const on401 = vi.fn();
|
||||
const cf = createCoreFetch({
|
||||
on401,
|
||||
fetchImpl: () => Promise.resolve(new Response('', { status: 401 })),
|
||||
});
|
||||
await expect(cf('/x')).rejects.toThrow();
|
||||
expect(on401).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('409 fires on409; resolved value is returned in place of throw', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
on409: () => ({ swallowed: true }),
|
||||
fetchImpl: () => Promise.resolve(jsonResponse({ message: 'conflict' }, { status: 409 })),
|
||||
});
|
||||
const r = await cf<{ swallowed: boolean }>('/x');
|
||||
expect(r).toEqual({ swallowed: true });
|
||||
});
|
||||
|
||||
it("409 without on409 throws an error decorated with status=409 + body", async () => {
|
||||
const cf = createCoreFetch({
|
||||
fetchImpl: () => Promise.resolve(jsonResponse({ message: 'nope' }, { status: 409 })),
|
||||
});
|
||||
try {
|
||||
await cf('/x');
|
||||
expect.unreachable('expected throw');
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number; body?: unknown };
|
||||
expect(err.status).toBe(409);
|
||||
expect(err.body).toEqual({ message: 'nope' });
|
||||
expect(err.message).toBe('nope');
|
||||
}
|
||||
});
|
||||
|
||||
it('formatError overrides the default error message', async () => {
|
||||
const cf = createCoreFetch({
|
||||
formatError: () => 'custom',
|
||||
fetchImpl: () => Promise.resolve(new Response('boom', { status: 500 })),
|
||||
});
|
||||
await expect(cf('/x')).rejects.toThrow('custom');
|
||||
});
|
||||
|
||||
it('network failure rethrows by default', async () => {
|
||||
const cf = createCoreFetch({
|
||||
fetchImpl: () => Promise.reject(new Error('offline')),
|
||||
});
|
||||
await expect(cf('/x')).rejects.toThrow('offline');
|
||||
});
|
||||
|
||||
it('onNetworkFailure can synthesise a replacement body', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
onNetworkFailure: () => ({ offline: true }),
|
||||
fetchImpl: () => Promise.reject(new Error('offline')),
|
||||
});
|
||||
const r = await cf<{ offline: boolean }>('/x');
|
||||
expect(r).toEqual({ offline: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCoreFetch — sync queue', () => {
|
||||
it('onEnqueue can short-circuit a mutating request on a sync table', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const onEnqueue = vi.fn().mockReturnValue(true);
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
syncTables: new Set(['bookings']),
|
||||
onEnqueue,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/api/bookings', { method: 'POST', body: '{}' });
|
||||
expect(onEnqueue).toHaveBeenCalledOnce();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('non-mutating request still hits the network even on sync table', async () => {
|
||||
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse({})));
|
||||
const onEnqueue = vi.fn();
|
||||
const cf = createCoreFetch({
|
||||
syncTables: new Set(['bookings']),
|
||||
onEnqueue,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/api/bookings');
|
||||
expect(onEnqueue).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCoreFetch — request shape', () => {
|
||||
it('always sends X-Requested-With and credentials: include', async () => {
|
||||
let captured: { url: string; init: RequestInit } | null = null;
|
||||
const cf = createCoreFetch({
|
||||
fetchImpl: ((url: string, init: RequestInit) => {
|
||||
captured = { url, init };
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/x');
|
||||
expect(captured!.init.credentials).toBe('include');
|
||||
const h = captured!.init.headers as Headers;
|
||||
expect(h.get('X-Requested-With')).toBe('XMLHttpRequest');
|
||||
});
|
||||
|
||||
it('baseUrl is prepended and trailing slash stripped', async () => {
|
||||
let captured = '';
|
||||
const cf = createCoreFetch({
|
||||
baseUrl: 'https://api.example.com/',
|
||||
fetchImpl: ((url: string) => {
|
||||
captured = url;
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/v1/x');
|
||||
expect(captured).toBe('https://api.example.com/v1/x');
|
||||
});
|
||||
});
|
||||
31
src/i18n-react.test.tsx
Normal file
31
src/i18n-react.test.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Vitest suite for useI18nStore (#4) — confirms the React binding re-renders
|
||||
// on locale change. Kept minimal: the React peer is optional, so this is the
|
||||
// only file that pulls in @testing-library/react.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { createI18n } from './i18n.js';
|
||||
import { useI18nStore } from './i18n-react.js';
|
||||
|
||||
interface B extends Record<string, string> {
|
||||
hello: string;
|
||||
}
|
||||
|
||||
const bundles = {
|
||||
'de': { hello: 'Hallo' } as Partial<B>,
|
||||
'en': { hello: 'Hello' } as Partial<B>,
|
||||
};
|
||||
|
||||
describe('useI18nStore', () => {
|
||||
it('re-renders when locale changes', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const Comp = () => {
|
||||
const store = useI18nStore(i);
|
||||
return <span data-testid="t">{store.t('hello')}</span>;
|
||||
};
|
||||
render(<Comp />);
|
||||
expect(screen.getByTestId('t').textContent).toBe('Hallo');
|
||||
act(() => { i.setLocale('en'); });
|
||||
expect(screen.getByTestId('t').textContent).toBe('Hello');
|
||||
});
|
||||
});
|
||||
96
src/i18n.test.ts
Normal file
96
src/i18n.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Vitest suite for createI18n (#4).
|
||||
//
|
||||
// Pins the resolver chain (`<locale>-<tone>` → `<locale>` → default), the
|
||||
// once-per-key onMiss firing, and the subscribe/notify behaviour.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createI18n } from './i18n.js';
|
||||
|
||||
interface B extends Record<string, string> {
|
||||
hello: string;
|
||||
bye: string;
|
||||
}
|
||||
|
||||
const bundles = {
|
||||
'de': { hello: 'Hallo', bye: 'Tschüss' } as Partial<B>,
|
||||
'de-informal': { hello: 'Hi' } as Partial<B>,
|
||||
'en': { hello: 'Hello' } as Partial<B>,
|
||||
};
|
||||
|
||||
describe('createI18n — resolver chain', () => {
|
||||
it('hits <locale>-<tone> first', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'de', initialTone: 'informal' });
|
||||
expect(i.t('hello')).toBe('Hi');
|
||||
});
|
||||
|
||||
it('falls through tone to <locale> when key missing in tone bundle', () => {
|
||||
// 'bye' is only in 'de'; informal asks for 'de-informal' first, falls back to 'de'.
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'de', initialTone: 'informal' });
|
||||
expect(i.t('bye')).toBe('Tschüss');
|
||||
});
|
||||
|
||||
it('falls through locale to default when current locale has no entry', () => {
|
||||
// 'bye' missing in en → falls back to default 'de'.
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'en' });
|
||||
expect(i.t('bye')).toBe('Tschüss');
|
||||
});
|
||||
|
||||
it('returns the raw key when nothing resolves', () => {
|
||||
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de' });
|
||||
expect(i.t('hello')).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createI18n — onMiss', () => {
|
||||
it('fires once per (key, locale, tone) triple', () => {
|
||||
const onMiss = vi.fn();
|
||||
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de', onMiss });
|
||||
i.t('hello');
|
||||
i.t('hello');
|
||||
i.t('hello');
|
||||
expect(onMiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires again after locale flip', () => {
|
||||
const onMiss = vi.fn();
|
||||
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de', onMiss });
|
||||
i.t('hello');
|
||||
i.setLocale('en');
|
||||
i.t('hello');
|
||||
expect(onMiss).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createI18n — subscribe/notify', () => {
|
||||
it('setLocale notifies subscribers exactly once', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const fn = vi.fn();
|
||||
i.subscribe(fn);
|
||||
i.setLocale('en');
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('setLocale to same value does not notify', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const fn = vi.fn();
|
||||
i.subscribe(fn);
|
||||
i.setLocale('de');
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unsubscribe stops further notifications', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const fn = vi.fn();
|
||||
const unsub = i.subscribe(fn);
|
||||
unsub();
|
||||
i.setLocale('en');
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getSnapshot reflects the current <locale>-<tone>', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
expect(i.getSnapshot()).toBe('de-formal');
|
||||
i.setTone('informal');
|
||||
expect(i.getSnapshot()).toBe('de-informal');
|
||||
});
|
||||
});
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue