#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:
Uwe Schuster 2026-04-25 21:45:31 +02:00
parent f4f9289bdc
commit 90c5ca2248
6 changed files with 2841 additions and 165 deletions

2673
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "@uschuster/webapp-scaffold", "name": "@uschuster/webapp-scaffold",
"version": "0.3.6", "version": "0.3.7",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.", "description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module", "type": "module",
"bin": { "bin": {
@ -32,12 +32,23 @@
}, },
"files": ["bin/", "dist/", "src/", "templates/", "README.md", "LICENSE"], "files": ["bin/", "dist/", "src/", "templates/", "README.md", "LICENSE"],
"scripts": { "scripts": {
"prepare": "tsc || true" "prepare": "tsc && vitest run",
"test": "vitest run",
"test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.0.0", "typescript": "^5.0.0",
"@types/node": "^22.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": { "peerDependencies": {
"vite": "^6.0.0", "vite": "^6.0.0",

176
src/core-fetch.test.ts Normal file
View 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
View 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
View 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
View 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'],
},
});