7 Commits

Author SHA1 Message Date
4ce73343ac Update dependency eslint to v10.0.2
All checks were successful
continuous-integration/drone/pr Build is passing
2026-02-24 22:08:36 +00:00
2ec7705b4b update thresholds
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:10:45 +01:00
5877d90f07 fix eslint ver
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:23:55 +01:00
2b96277bec fix yml
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:21:37 +01:00
063d1073de add code analysis step
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:19:44 +01:00
d7e144620e add unit tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:14:24 +01:00
7e938138ff fix sidebar width, v0.1.5
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-24 11:02:17 +01:00
24 changed files with 2798 additions and 26 deletions

View File

@@ -27,6 +27,33 @@ steps:
commands:
- yarn build
- name: unit-tests
image: node:25
environment:
NODE_OPTIONS: --no-webstorage
commands:
- yarn test:coverage
- test -f coverage/lcov.info
- name: code-analysis
when:
event:
- push
image: sonarsource/sonar-scanner-cli:latest
commands:
- |
test -f coverage/lcov.info
SONAR_ARGS="-Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.host.url=$SONAR_INSTANCE_URL -Dsonar.token=$SONAR_LOGIN_KEY -Dsonar.sources=src -Dsonar.tests=tests -Dsonar.test.inclusions=tests/**/*.{test,spec}.{ts,tsx,js,jsx} -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info -Dsonar.working.directory=/tmp/.scannerwork"
sonar-scanner $SONAR_ARGS
environment:
SONAR_USER_HOME: /tmp/.sonar
SONAR_PROJECT_KEY:
from_secret: sonar_project_key
SONAR_INSTANCE_URL:
from_secret: sonar_instance_url
SONAR_LOGIN_KEY:
from_secret: sonar_login_key
---
kind: pipeline
type: docker

View File

@@ -48,4 +48,14 @@ export default tseslint.config(
'react-refresh/only-export-components': 'off',
},
},
{
files: ['tests/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.vitest,
},
},
},
);

View File

@@ -1,6 +1,6 @@
{
"name": "@panic/web-core",
"version": "0.1.4",
"version": "0.1.5",
"license": "AGPL-3.0-only",
"description": "Core auth and utilities for panic.haus web applications",
"type": "module",
@@ -19,6 +19,9 @@
"scripts": {
"clean": "rm -rf dist",
"build": "yarn clean && vite build && tsc -p tsconfig.build.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
@@ -35,17 +38,24 @@
},
"devDependencies": {
"@eslint/js": "^10",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.1.0-canary-ab18f33d-20260220",
"eslint-plugin-react-refresh": "^0.5.1",
"globals": "^17.3.0",
"jsdom": "^28.1.0",
"prettier": "^3.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"typescript-eslint": "^8.56.0",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vitest": "^4.0.18"
}
}

View File

@@ -76,6 +76,10 @@ export function useSidePanelMachine({
}
const storedValue = localStorage.getItem(storageKey);
if (storedValue == null || storedValue.trim() === '') {
return defaultWidth;
}
const parsed = Number(storedValue);
if (!Number.isFinite(parsed)) {
return defaultWidth;

View File

@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest';
import { ApiError, createApiClient } from '../../src/api/createApiClient';
describe('createApiClient', () => {
it('sends json requests and returns parsed payload', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ ok: true }),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
const result = await client.request<{ ok: boolean }>('/users', {
method: 'POST',
token: 'token-123',
body: { hello: 'world' },
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token-123',
},
body: JSON.stringify({ hello: 'world' }),
});
});
it('maps api error payload through custom resolver', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: vi.fn().mockResolvedValue({
code: 'AUTH_UNAUTHORIZED',
error: 'unauthorized',
requestId: 'req-1',
details: { reason: 'expired' },
}),
} as unknown as Response);
const resolveError = vi.fn(() => 'Unauthorized access. Please sign in again.');
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
resolveError,
});
await expect(client.request('/users')).rejects.toMatchObject({
name: 'ApiError',
status: 401,
code: 'AUTH_UNAUTHORIZED',
requestId: 'req-1',
details: { reason: 'expired' },
rawMessage: 'unauthorized',
message: 'Unauthorized access. Please sign in again.',
});
expect(resolveError).toHaveBeenCalledWith({
code: 'AUTH_UNAUTHORIZED',
status: 401,
fallbackMessage: 'unauthorized',
});
});
it('infers an error code from status when payload has no code', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 404,
json: vi.fn().mockResolvedValue({ error: 'missing resource' }),
} as unknown as Response);
const inferErrorCodeFromStatus = vi.fn((status?: number | null) =>
status === 404 ? 'USER_NOT_FOUND' : undefined,
);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
inferErrorCodeFromStatus,
resolveError: ({ code }) => (code === 'USER_NOT_FOUND' ? 'User not found.' : 'Unknown'),
});
await expect(client.request('/users/missing')).rejects.toMatchObject({
code: 'USER_NOT_FOUND',
message: 'User not found.',
});
});
it('falls back to default messages when response is not valid json', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockRejectedValue(new Error('invalid json')),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
await expect(client.request('/users')).rejects.toMatchObject({
code: undefined,
rawMessage: undefined,
message: 'Request failed (500).',
});
});
it('uses generic default message when status is unavailable', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: undefined,
json: vi.fn().mockResolvedValue(null),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
let thrown: unknown;
try {
await client.request('/users');
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(ApiError);
expect((thrown as ApiError).message).toBe('Request failed. Please try again.');
});
it('uses raw fallback error text with the default resolver when present', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: vi.fn().mockResolvedValue({
error: 'Validation failed in backend.',
}),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
await expect(client.request('/users')).rejects.toMatchObject({
message: 'Validation failed in backend.',
rawMessage: 'Validation failed in backend.',
});
});
});

25
tests/api/query.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { buildListQuery } from '../../src/api/query';
describe('buildListQuery', () => {
it('builds query with trimmed search and explicit sort', () => {
const result = buildListQuery({
q: ' jane ',
page: 3,
pageSize: 20,
sort: ' createdAt ',
defaultSort: '-createdAt',
});
expect(result).toBe('q=jane&page=3&pageSize=20&sort=createdAt');
});
it('falls back to defaults when query is blank and sort missing', () => {
const result = buildListQuery({
q: ' ',
defaultSort: '-createdAt',
});
expect(result).toBe('page=1&pageSize=10&sort=-createdAt');
});
});

View File

@@ -0,0 +1,143 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { createAuthContext } from '../../src/auth/createAuthContext';
type User = {
id: string;
username: string;
};
const defaultAuth = createAuthContext<User>();
function createUser(username: string): User {
return {
id: `id-${username}`,
username,
};
}
function AuthHarness() {
const { authToken, refreshToken, currentUser, setSession, setCurrentUser, clearSession } =
defaultAuth.useAuth();
return (
<div>
<span data-testid="auth-token">{authToken ?? 'none'}</span>
<span data-testid="refresh-token">{refreshToken ?? 'none'}</span>
<span data-testid="username">{currentUser?.username ?? 'none'}</span>
<button
type="button"
onClick={() => setSession('auth-next', 'refresh-next', createUser('after-set'))}
>
set-session
</button>
<button type="button" onClick={() => setCurrentUser(createUser('patched'))}>
set-user
</button>
<button type="button" onClick={clearSession}>
clear
</button>
</div>
);
}
describe('createAuthContext', () => {
it('throws when hook is used outside the provider', () => {
function Invalid() {
defaultAuth.useAuth();
return null;
}
expect(() => render(<Invalid />)).toThrow('useAuth must be used within AuthProvider');
});
it('reads persisted tokens and cleans default legacy keys', () => {
localStorage.setItem('authToken', 'auth-1');
localStorage.setItem('refreshToken', 'refresh-1');
localStorage.setItem('auth_token', 'legacy');
localStorage.setItem('auth_user', 'legacy');
localStorage.setItem('token', 'legacy');
render(
<defaultAuth.AuthProvider>
<AuthHarness />
</defaultAuth.AuthProvider>,
);
expect(screen.getByTestId('auth-token')).toHaveTextContent('auth-1');
expect(screen.getByTestId('refresh-token')).toHaveTextContent('refresh-1');
expect(localStorage.getItem('auth_token')).toBeNull();
expect(localStorage.getItem('auth_user')).toBeNull();
expect(localStorage.getItem('token')).toBeNull();
});
it('supports session lifecycle updates', () => {
render(
<defaultAuth.AuthProvider>
<AuthHarness />
</defaultAuth.AuthProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'set-session' }));
expect(screen.getByTestId('auth-token')).toHaveTextContent('auth-next');
expect(screen.getByTestId('refresh-token')).toHaveTextContent('refresh-next');
expect(screen.getByTestId('username')).toHaveTextContent('after-set');
expect(localStorage.getItem('authToken')).toBe('auth-next');
expect(localStorage.getItem('refreshToken')).toBe('refresh-next');
fireEvent.click(screen.getByRole('button', { name: 'set-user' }));
expect(screen.getByTestId('username')).toHaveTextContent('patched');
fireEvent.click(screen.getByRole('button', { name: 'clear' }));
expect(screen.getByTestId('auth-token')).toHaveTextContent('none');
expect(screen.getByTestId('refresh-token')).toHaveTextContent('none');
expect(screen.getByTestId('username')).toHaveTextContent('none');
expect(localStorage.getItem('authToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
it('supports custom token keys and legacy cleanup keys', () => {
const customAuth = createAuthContext<User>({
authTokenKey: 'custom-auth',
refreshTokenKey: 'custom-refresh',
legacyKeys: ['legacy-a', 'legacy-b'],
});
function CustomHarness() {
const { authToken, refreshToken, setSession } = customAuth.useAuth();
return (
<div>
<span data-testid="custom-auth-token">{authToken ?? 'none'}</span>
<span data-testid="custom-refresh-token">{refreshToken ?? 'none'}</span>
<button
type="button"
onClick={() => setSession('next-auth', 'next-refresh', createUser('custom'))}
>
set-custom-session
</button>
</div>
);
}
localStorage.setItem('custom-auth', 'auth-1');
localStorage.setItem('custom-refresh', 'refresh-1');
localStorage.setItem('legacy-a', 'legacy');
localStorage.setItem('legacy-b', 'legacy');
render(
<customAuth.AuthProvider>
<CustomHarness />
</customAuth.AuthProvider>,
);
expect(screen.getByTestId('custom-auth-token')).toHaveTextContent('auth-1');
expect(screen.getByTestId('custom-refresh-token')).toHaveTextContent('refresh-1');
expect(localStorage.getItem('legacy-a')).toBeNull();
expect(localStorage.getItem('legacy-b')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'set-custom-session' }));
expect(localStorage.getItem('custom-auth')).toBe('next-auth');
expect(localStorage.getItem('custom-refresh')).toBe('next-refresh');
});
});

60
tests/auth/jwt.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { decodeJwtPayload, isJwtExpired } from '../../src/auth/jwt';
function createJwt(payload: Record<string, unknown>) {
const header = { alg: 'HS256', typ: 'JWT' };
const encode = (value: object) =>
btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
return `${encode(header)}.${encode(payload)}.signature`;
}
describe('decodeJwtPayload', () => {
it('returns null for malformed tokens or invalid payloads', () => {
expect(decodeJwtPayload('not-a-jwt')).toBeNull();
expect(decodeJwtPayload('a.b')).toBeNull();
expect(decodeJwtPayload('a.b.c.d')).toBeNull();
const nonJson = 'header.' + btoa('nope') + '.sig';
expect(decodeJwtPayload(nonJson)).toBeNull();
const nonObjectPayload = createJwt({ value: 1 }).replace(
/\.[^.]+\./,
`.${btoa('1').replace(/=+$/g, '')}.`,
);
expect(decodeJwtPayload(nonObjectPayload)).toBeNull();
});
it('decodes valid base64url payloads', () => {
const payload = { sub: 'user-1', role: 'ADMIN', exp: 1735689600 };
expect(decodeJwtPayload(createJwt(payload))).toEqual(payload);
});
});
describe('isJwtExpired', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('returns false for malformed tokens and invalid exp claim', () => {
expect(isJwtExpired('not-a-jwt')).toBe(false);
expect(isJwtExpired(createJwt({}))).toBe(false);
expect(isJwtExpired(createJwt({ exp: 'nope' }))).toBe(false);
expect(isJwtExpired(createJwt({ exp: Number.POSITIVE_INFINITY }))).toBe(false);
});
it('returns true when token is expired', () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const exp = Math.floor(new Date('2025-12-31T23:59:00Z').getTime() / 1000);
expect(isJwtExpired(createJwt({ exp }))).toBe(true);
});
it('applies skew seconds when evaluating expiration', () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const exp = Math.floor(new Date('2026-01-01T00:00:10Z').getTime() / 1000);
expect(isJwtExpired(createJwt({ exp }), 15)).toBe(true);
expect(isJwtExpired(createJwt({ exp }), 5)).toBe(false);
});
});

View File

@@ -0,0 +1,246 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
LeftMenuProvider,
type LeftMenuContent,
useLeftMenu,
} from '../../src/contexts/LeftMenuContext';
function buildDefaultContent(label = 'Default menu'): LeftMenuContent {
return {
ariaLabel: label,
render: ({ collapsed }) => <div>{collapsed ? `${label} (collapsed)` : `${label} (expanded)`}</div>,
};
}
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: undefined,
});
}
function renderLeftMenuHarness(initialPathname = '/users') {
let currentPathname = initialPathname;
let currentValue: ReturnType<typeof useLeftMenu> | null = null;
const defaultContent = buildDefaultContent();
function Probe() {
currentValue = useLeftMenu();
const renderState = {
collapsed: currentValue.collapsed,
mobileOpen: currentValue.mobileOpen,
isDesktop: window.innerWidth >= 1024,
closeMenu: currentValue.closeMenu,
};
return <div data-testid="left-menu-content">{currentValue.content.render(renderState)}</div>;
}
function Wrapper({ pathname }: Readonly<{ pathname: string }>) {
return (
<LeftMenuProvider defaultContent={defaultContent} closeOnPathname={pathname}>
<Probe />
</LeftMenuProvider>
);
}
const rendered = render(<Wrapper pathname={currentPathname} />);
return {
getCurrent() {
if (!currentValue) {
throw new Error('Left menu context value not initialized');
}
return currentValue;
},
reroute(nextPathname: string) {
currentPathname = nextPathname;
rendered.rerender(<Wrapper pathname={currentPathname} />);
},
};
}
describe('LeftMenuContext', () => {
beforeEach(() => {
localStorage.removeItem('authSidebarWidth');
localStorage.removeItem('authSidebarCollapsed');
setViewportWidth(1024);
});
it('throws when useLeftMenu is used outside provider', () => {
function Invalid() {
useLeftMenu();
return null;
}
expect(() => render(<Invalid />)).toThrow('useLeftMenu must be used within LeftMenuProvider');
});
it('supports desktop collapse/expand/toggle semantics', () => {
const harness = renderLeftMenuHarness('/users');
expect(harness.getCurrent().collapsed).toBe(false);
act(() => {
harness.getCurrent().closeMenu();
});
expect(harness.getCurrent().collapsed).toBe(true);
act(() => {
harness.getCurrent().openMenu();
});
expect(harness.getCurrent().collapsed).toBe(false);
act(() => {
harness.getCurrent().toggleMenu();
});
expect(harness.getCurrent().collapsed).toBe(true);
});
it('restores collapsed state from storage and clamps persisted width', () => {
localStorage.setItem('authSidebarCollapsed', '1');
localStorage.setItem('authSidebarWidth', '999');
const harness = renderLeftMenuHarness('/users');
expect(harness.getCurrent().collapsed).toBe(true);
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('56px');
});
it('locks body scroll on mobile open and unlocks on close', () => {
setViewportWidth(768);
const harness = renderLeftMenuHarness('/users');
act(() => {
harness.getCurrent().openMenu();
});
expect(harness.getCurrent().mobileOpen).toBe(true);
expect(document.body.style.overflow).toBe('hidden');
act(() => {
harness.getCurrent().closeMenu();
});
expect(harness.getCurrent().mobileOpen).toBe(false);
expect(document.body.style.overflow).toBe('');
});
it('reads, clamps, and persists sidebar width through pointer resize', async () => {
localStorage.setItem('authSidebarWidth', '999');
const harness = renderLeftMenuHarness('/users');
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('420px');
const preventDefault = vi.fn();
act(() => {
harness.getCurrent().startResize({
clientX: 420,
preventDefault,
} as never);
});
act(() => {
fireEvent.pointerMove(window, { clientX: -1000 });
fireEvent.pointerUp(window);
});
await act(async () => {
await Promise.resolve();
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('authSidebarWidth')).toBe('220');
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('220px');
});
it('accepts custom content and resets to default on route changes', () => {
setViewportWidth(768);
const harness = renderLeftMenuHarness('/users');
act(() => {
harness.getCurrent().openMenu();
harness.getCurrent().setMenuContent({
ariaLabel: 'Custom menu',
render: () => <div>Custom menu content</div>,
});
});
expect(harness.getCurrent().mobileOpen).toBe(true);
expect(screen.getByText('Custom menu content')).toBeInTheDocument();
harness.reroute('/posts');
expect(harness.getCurrent().mobileOpen).toBe(false);
expect(screen.queryByText('Custom menu content')).not.toBeInTheDocument();
expect(screen.getByText('Default menu (expanded)')).toBeInTheDocument();
});
it('applies provided content in openMenu/toggleMenu and toggles mobile open state', () => {
setViewportWidth(768);
const harness = renderLeftMenuHarness('/users');
act(() => {
harness.getCurrent().openMenu({
render: () => <div>Open payload</div>,
});
});
expect(screen.getByText('Open payload')).toBeInTheDocument();
expect(harness.getCurrent().mobileOpen).toBe(true);
act(() => {
harness.getCurrent().toggleMenu({
render: () => <div>Toggle payload</div>,
});
});
expect(screen.getByText('Toggle payload')).toBeInTheDocument();
expect(harness.getCurrent().mobileOpen).toBe(false);
});
it('updates default content only when current content is still default', () => {
function Harness() {
const menu = useLeftMenu();
const state = {
collapsed: menu.collapsed,
mobileOpen: menu.mobileOpen,
isDesktop: true,
closeMenu: menu.closeMenu,
};
return (
<div>
<button type="button" onClick={() => menu.setMenuContent({ render: () => <div>Custom</div> })}>
custom
</button>
<div data-testid="content">{menu.content.render(state)}</div>
</div>
);
}
function Wrapper({ label }: Readonly<{ label: string }>) {
return (
<LeftMenuProvider defaultContent={buildDefaultContent(label)}>
<Harness />
</LeftMenuProvider>
);
}
const rendered = render(<Wrapper label="Menu A" />);
expect(screen.getByTestId('content')).toHaveTextContent('Menu A (expanded)');
rendered.rerender(<Wrapper label="Menu B" />);
expect(screen.getByTestId('content')).toHaveTextContent('Menu B (expanded)');
fireEvent.click(screen.getByRole('button', { name: 'custom' }));
expect(screen.getByTestId('content')).toHaveTextContent('Custom');
rendered.rerender(<Wrapper label="Menu C" />);
expect(screen.getByTestId('content')).toHaveTextContent('Custom');
});
});

View File

@@ -0,0 +1,249 @@
import { fireEvent, render } from '@testing-library/react';
import { act } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
RightSidebarProvider,
useRightSidebar,
} from '../../src/contexts/RightSidebarContext';
type RightSidebarHarnessOptions = {
pathname?: string;
onMobileOpenRequest?: () => void;
};
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: undefined,
});
}
function renderRightSidebarHarness(options: RightSidebarHarnessOptions = {}) {
const { pathname = '/users', onMobileOpenRequest } = options;
let currentPathname = pathname;
let currentValue: ReturnType<typeof useRightSidebar> | null = null;
function Probe() {
currentValue = useRightSidebar();
return null;
}
function Wrapper({ pathname }: Readonly<{ pathname: string }>) {
return (
<RightSidebarProvider
closeOnPathname={pathname}
onMobileOpenRequest={onMobileOpenRequest}
>
<Probe />
</RightSidebarProvider>
);
}
const rendered = render(<Wrapper pathname={currentPathname} />);
return {
getCurrent() {
if (!currentValue) {
throw new Error('Right sidebar context value not initialized');
}
return currentValue;
},
reroute(nextPathname: string) {
currentPathname = nextPathname;
rendered.rerender(<Wrapper pathname={currentPathname} />);
},
unmount: rendered.unmount,
};
}
describe('RightSidebarContext', () => {
beforeEach(() => {
localStorage.removeItem('authRightSidebarWidth');
setViewportWidth(1024);
});
it('throws when useRightSidebar is used outside provider', () => {
function Invalid() {
useRightSidebar();
return null;
}
expect(() => render(<Invalid />)).toThrow('useRightSidebar must be used within RightSidebarProvider');
});
it('opens and closes with content', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(harness.getCurrent().content?.title).toBe('Meta');
act(() => {
harness.getCurrent().closeSidebar();
});
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('does not open without current or next content', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar();
});
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('updates content live and supports toggle semantics', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
act(() => {
harness.getCurrent().setSidebarContent({
title: 'Meta Updated',
content: <div>Updated</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(harness.getCurrent().content?.title).toBe('Meta Updated');
act(() => {
harness.getCurrent().toggleSidebar();
});
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('toggleSidebar opens with provided content when closed', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().toggleSidebar({
title: 'Toggle open',
content: <div>Toggle body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(harness.getCurrent().content?.title).toBe('Toggle open');
});
it('closes on pathname changes', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
harness.reroute('/posts');
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('reads/clamps persisted width, persists resized widths, and reacts to escape', async () => {
localStorage.setItem('authRightSidebarWidth', '999');
const harness = renderRightSidebarHarness({ pathname: '/users' });
expect(harness.getCurrent().desktopSidebarStyle['--auth-right-sidebar-width']).toBe(
'480px',
);
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
const preventDefault = vi.fn();
act(() => {
harness.getCurrent().startResize({
clientX: 480,
preventDefault,
} as never);
});
act(() => {
fireEvent.pointerMove(window, { clientX: -400 });
fireEvent.pointerUp(window);
});
await act(async () => {
await Promise.resolve();
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('authRightSidebarWidth')).toBe('480');
act(() => {
fireEvent.keyDown(window, { key: 'Escape' });
});
expect(harness.getCurrent().isOpen).toBe(false);
});
it('notifies mobile open requests when opening on mobile', () => {
setViewportWidth(768);
const onMobileOpenRequest = vi.fn();
const harness = renderRightSidebarHarness({
pathname: '/users',
onMobileOpenRequest,
});
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(onMobileOpenRequest).toHaveBeenCalledTimes(1);
});
it('normalizes custom sizing values', () => {
function Probe() {
const sidebar = useRightSidebar();
return <div data-testid="width">{sidebar.desktopSidebarStyle['--auth-right-sidebar-width']}</div>;
}
const { getByTestId } = render(
<RightSidebarProvider
sizing={{
defaultWidth: 1000,
minWidth: 500,
maxWidth: 300,
}}
>
<Probe />
</RightSidebarProvider>,
);
expect(getByTestId('width').textContent).toBe('500px');
});
});

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { createErrorResolver } from '../../src/errors/createErrorResolver';
const CATALOG = {
AUTH_UNAUTHORIZED: 'Unauthorized access. Please sign in again.',
FORBIDDEN: 'You do not have permission to perform this action.',
USER_NOT_FOUND: 'User not found.',
INTERNAL_ERROR: 'Unexpected request error.',
REQUEST_FAILED: 'Request failed. Please try again.',
};
const resolver = createErrorResolver({
catalog: CATALOG,
fallbackCode: 'REQUEST_FAILED',
defaultContext: 'default',
contextOverrides: {
session: {
AUTH_UNAUTHORIZED: 'Session expired. Please sign in again.',
},
},
inferCodeFromStatus: (status?: number | null) => {
switch (status) {
case 401:
return 'AUTH_UNAUTHORIZED';
case 403:
return 'FORBIDDEN';
case 404:
return 'USER_NOT_FOUND';
default:
if (status != null && status >= 500) {
return 'INTERNAL_ERROR';
}
return undefined;
}
},
inferCodeFromLegacyMessage: (message?: string | null) => {
if (message?.toLowerCase() === 'unauthorized') {
return 'AUTH_UNAUTHORIZED';
}
return undefined;
},
});
describe('createErrorResolver', () => {
it('recognizes known error codes', () => {
expect(resolver.isKnownErrorCode('AUTH_UNAUTHORIZED')).toBe(true);
expect(resolver.isKnownErrorCode('NOPE')).toBe(false);
});
it('returns context override for known code', () => {
expect(
resolver.resolveErrorMessage({
code: 'AUTH_UNAUTHORIZED',
context: 'session',
}),
).toBe('Session expired. Please sign in again.');
});
it('falls back from unknown code to legacy message inference', () => {
expect(
resolver.resolveErrorMessage({
code: 'UNKNOWN_CODE',
fallbackMessage: 'unauthorized',
}),
).toBe('Unauthorized access. Please sign in again.');
});
it('falls back to status mapping when code is missing', () => {
expect(resolver.resolveErrorMessage({ status: 404 })).toBe('User not found.');
});
it('falls back to status mapping when legacy inference returns an unmapped code', () => {
const resolverWithUnmappedLegacyCode = createErrorResolver({
catalog: CATALOG,
inferCodeFromStatus: resolver.inferErrorCodeFromStatus,
inferCodeFromLegacyMessage: () => 'LEGACY_ONLY_CODE',
});
expect(
resolverWithUnmappedLegacyCode.resolveErrorMessage({
status: 403,
fallbackMessage: 'legacy message',
}),
).toBe('You do not have permission to perform this action.');
});
it('uses fallbackCode when no code/status resolve and fallback message is missing', () => {
expect(resolver.resolveErrorMessage({ status: 418 })).toBe('Request failed. Please try again.');
});
it('uses fallback message when no mapping exists and fallback code is unavailable', () => {
const noFallbackCodeResolver = createErrorResolver({
catalog: CATALOG,
inferCodeFromStatus: () => undefined,
});
expect(
noFallbackCodeResolver.resolveErrorMessage({
code: 'UNKNOWN',
fallbackMessage: 'raw backend message',
}),
).toBe('raw backend message');
});
it('returns default request failure message when no signal is available', () => {
const emptyResolver = createErrorResolver({
catalog: {},
});
expect(emptyResolver.resolveErrorMessage({})).toBe('Request failed. Please try again.');
});
it('resolveOptionalErrorMessage returns undefined for empty inputs', () => {
expect(resolver.resolveOptionalErrorMessage(undefined)).toBeUndefined();
expect(resolver.resolveOptionalErrorMessage(null)).toBeUndefined();
});
it('resolveOptionalErrorMessage resolves known codes', () => {
expect(resolver.resolveOptionalErrorMessage('FORBIDDEN')).toBe(
'You do not have permission to perform this action.',
);
});
it('toErrorMessage prefers rawMessage over message and handles unknown values', () => {
expect(
resolver.toErrorMessage({
status: 403,
rawMessage: 'unauthorized',
message: 'ignored message',
}),
).toBe('Unauthorized access. Please sign in again.');
expect(resolver.toErrorMessage('boom')).toBe('Request failed. Please try again.');
expect(resolver.toErrorMessage(null)).toBe('Request failed. Please try again.');
});
it('exposes inferErrorCodeFromStatus', () => {
expect(resolver.inferErrorCodeFromStatus(401)).toBe('AUTH_UNAUTHORIZED');
expect(resolver.inferErrorCodeFromStatus(500)).toBe('INTERNAL_ERROR');
expect(resolver.inferErrorCodeFromStatus(418)).toBeUndefined();
expect(resolver.inferErrorCodeFromStatus(null)).toBeUndefined();
});
});

View File

@@ -0,0 +1,36 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
export function renderHook<T>(useHook: () => T) {
let currentValue: T;
function TestComponent() {
currentValue = useHook();
return null;
}
const container = document.createElement('div');
const root = createRoot(container);
act(() => {
root.render(<TestComponent />);
});
return {
result: {
get current() {
return currentValue;
},
},
rerender() {
act(() => {
root.render(<TestComponent />);
});
},
unmount() {
act(() => {
root.unmount();
});
},
};
}

View File

@@ -0,0 +1,64 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useCooldownTimer } from '../../src/hooks/useCooldownTimer';
import { renderHook } from '../helpers/renderHook';
describe('useCooldownTimer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('decrements cooldown every second while enabled', () => {
const { result } = renderHook(() => useCooldownTimer(2, true));
expect(result.current.cooldown).toBe(2);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.cooldown).toBe(1);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.cooldown).toBe(0);
});
it('does not decrement when disabled', () => {
const { result } = renderHook(() => useCooldownTimer(2, false));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(result.current.cooldown).toBe(2);
});
it('startCooldown floors values and clamps negatives', () => {
const { result } = renderHook(() => useCooldownTimer(0, true));
act(() => {
result.current.startCooldown(2.8);
});
expect(result.current.cooldown).toBe(2);
act(() => {
result.current.startCooldown(-4);
});
expect(result.current.cooldown).toBe(0);
});
it('stays at zero when already expired', () => {
const { result } = renderHook(() => useCooldownTimer(0, true));
act(() => {
vi.advanceTimersByTime(2000);
});
expect(result.current.cooldown).toBe(0);
});
});

View File

@@ -0,0 +1,81 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { useEditableForm } from '../../src/hooks/useEditableForm';
import { renderHook } from '../helpers/renderHook';
type FormValues = {
username: string;
email: string;
};
const INITIAL_VALUES: FormValues = {
username: '',
email: '',
};
function validate(values: FormValues) {
return {
username: values.username.trim().length < 3 ? 'Username too short' : undefined,
email: values.email.includes('@') ? undefined : 'Invalid email',
};
}
describe('useEditableForm', () => {
it('supports load/start/discard/commit edit lifecycle', () => {
const { result } = renderHook(() =>
useEditableForm({
initialValues: INITIAL_VALUES,
validate,
}),
);
expect(result.current.isEditing).toBe(false);
act(() => {
result.current.loadFromSource({ username: 'alice', email: 'alice@example.com' });
});
expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' });
expect(result.current.errors).toEqual({});
act(() => {
result.current.startEditing({ username: 'al', email: 'aliceexample.com' });
});
expect(result.current.isEditing).toBe(true);
act(() => {
result.current.setFieldValue('username', 'a');
});
expect(result.current.errors.username).toBe('Username too short');
act(() => {
result.current.discardChanges({ username: 'alice', email: 'alice@example.com' });
});
expect(result.current.isEditing).toBe(false);
expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' });
act(() => {
result.current.startEditing({ username: 'alice', email: 'alice@example.com' });
result.current.setFieldValue('username', 'alice_2');
result.current.commitSaved({ username: 'alice_2', email: 'alice@example.com' });
});
expect(result.current.isEditing).toBe(false);
expect(result.current.values.username).toBe('alice_2');
});
it('exposes direct editing toggles and field error injection', () => {
const { result } = renderHook(() =>
useEditableForm({
initialValues: INITIAL_VALUES,
validate,
}),
);
act(() => {
result.current.setIsEditing(true);
result.current.setFieldError('username', 'Username already taken');
});
expect(result.current.isEditing).toBe(true);
expect(result.current.errors.username).toBe('Username already taken');
});
});

View File

@@ -0,0 +1,170 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePaginatedResource } from '../../src/hooks/usePaginatedResource';
import { renderHook } from '../helpers/renderHook';
type Item = {
id: string;
};
async function flushDebounce(delay = 250) {
await act(async () => {
vi.advanceTimersByTime(delay);
await Promise.resolve();
});
}
describe('usePaginatedResource', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('loads first page after debounce and stores response fields', async () => {
const loadMock = vi.fn(async () => ({
items: [{ id: '1' }] as Item[],
page: 1,
pageSize: 10,
total: 25,
totalPages: 3,
}));
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
sort: '-createdAt',
}),
);
await flushDebounce();
expect(loadMock).toHaveBeenCalledWith({
q: '',
page: 1,
pageSize: 10,
sort: '-createdAt',
});
expect(result.current.items).toEqual([{ id: '1' }]);
expect(result.current.total).toBe(25);
expect(result.current.totalPages).toBe(3);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
it('resets page to 1 when query or page size changes', async () => {
const loadMock = vi.fn(
async ({ q, page, pageSize }: { q: string; page: number; pageSize: number }) => ({
items: [{ id: `${q}:${page}:${pageSize}` }] as Item[],
page,
pageSize,
total: 1,
totalPages: 1,
}),
);
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
}),
);
await flushDebounce();
act(() => {
result.current.setPage(2);
});
await flushDebounce();
expect(loadMock).toHaveBeenLastCalledWith({
q: '',
page: 2,
pageSize: 10,
sort: undefined,
});
act(() => {
result.current.setQuery('search');
});
await flushDebounce();
expect(loadMock).toHaveBeenLastCalledWith({
q: 'search',
page: 1,
pageSize: 10,
sort: undefined,
});
act(() => {
result.current.setPage(3);
});
await flushDebounce();
act(() => {
result.current.setPageSize(20);
});
await flushDebounce();
expect(loadMock).toHaveBeenLastCalledWith({
q: 'search',
page: 1,
pageSize: 20,
sort: undefined,
});
});
it('cancels stale debounce timers when inputs change quickly', async () => {
const loadMock = vi.fn(async () => ({
items: [] as Item[],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}));
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
debounceMs: 100,
}),
);
act(() => {
result.current.setQuery('latest');
});
await flushDebounce(100);
expect(loadMock).toHaveBeenCalledTimes(1);
expect(loadMock).toHaveBeenCalledWith({
q: 'latest',
page: 1,
pageSize: 10,
sort: undefined,
});
});
it('maps thrown errors into string error state', async () => {
const loadMock = vi
.fn()
.mockRejectedValueOnce(new Error('Load failed'))
.mockRejectedValueOnce('unknown');
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
}),
);
await flushDebounce();
expect(result.current.error).toBe('Load failed');
expect(result.current.isLoading).toBe(false);
act(() => {
result.current.setPage(2);
});
await flushDebounce();
expect(result.current.error).toBe('Request failed. Please try again.');
expect(result.current.isLoading).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { formatSortParam, useSorting } from '../../src/hooks/useSorting';
import { renderHook } from '../helpers/renderHook';
describe('useSorting', () => {
it('starts from default sort and cycles asc/desc/default', () => {
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'asc' }));
expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' });
expect(result.current.sortParam).toBe('createdAt');
act(() => {
result.current.toggleSort('name');
});
expect(result.current.activeSort).toEqual({ field: 'name', direction: 'asc' });
expect(result.current.sortParam).toBe('name');
act(() => {
result.current.toggleSort('name');
});
expect(result.current.activeSort).toEqual({ field: 'name', direction: 'desc' });
expect(result.current.sortParam).toBe('-name');
act(() => {
result.current.toggleSort('name');
});
expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' });
expect(result.current.sortParam).toBe('createdAt');
});
it('cycles sort state without a baseline default', () => {
const { result } = renderHook(() => useSorting(null));
expect(result.current.activeSort).toBeNull();
expect(result.current.sortParam).toBeUndefined();
act(() => {
result.current.toggleSort('updatedAt');
});
expect(result.current.sortParam).toBe('updatedAt');
act(() => {
result.current.toggleSort('updatedAt');
});
expect(result.current.sortParam).toBe('-updatedAt');
act(() => {
result.current.toggleSort('updatedAt');
});
expect(result.current.sortParam).toBeUndefined();
});
it('supports manual setSort and resetSort', () => {
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' }));
expect(result.current.sortParam).toBe('-createdAt');
act(() => {
result.current.setSort({ field: 'title', direction: 'asc' });
});
expect(result.current.sortParam).toBe('title');
act(() => {
result.current.resetSort();
});
expect(result.current.sortParam).toBe('-createdAt');
});
it('toggles baseline field directly between baseline and opposite direction', () => {
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' }));
expect(result.current.sortParam).toBe('-createdAt');
act(() => {
result.current.toggleSort('createdAt');
});
expect(result.current.sortParam).toBe('createdAt');
act(() => {
result.current.toggleSort('createdAt');
});
expect(result.current.sortParam).toBe('-createdAt');
act(() => {
result.current.toggleSort('createdAt');
});
expect(result.current.sortParam).toBe('createdAt');
});
it('formats sort params safely', () => {
expect(formatSortParam(null)).toBeUndefined();
expect(formatSortParam(undefined)).toBeUndefined();
expect(formatSortParam({ field: 'updatedAt', direction: 'desc' })).toBe('-updatedAt');
expect(formatSortParam({ field: 'updatedAt', direction: 'asc' })).toBe('updatedAt');
});
});

View File

@@ -0,0 +1,44 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { useSubmitState } from '../../src/hooks/useSubmitState';
import { renderHook } from '../helpers/renderHook';
describe('useSubmitState', () => {
it('tracks submit lifecycle and feedback state', () => {
const { result } = renderHook(() => useSubmitState<string | null>(null));
expect(result.current.isSubmitting).toBe(false);
expect(result.current.submitError).toBeNull();
expect(result.current.status).toBeNull();
act(() => {
result.current.startSubmitting();
result.current.setSubmitError('Oops');
result.current.setStatus('Done');
});
expect(result.current.isSubmitting).toBe(true);
expect(result.current.submitError).toBe('Oops');
expect(result.current.status).toBe('Done');
act(() => {
result.current.finishSubmitting();
result.current.clearFeedback();
});
expect(result.current.isSubmitting).toBe(false);
expect(result.current.submitError).toBeNull();
expect(result.current.status).toBeNull();
});
it('restores the typed initial status in clearFeedback', () => {
const { result } = renderHook(() => useSubmitState<'idle' | 'done'>('idle'));
act(() => {
result.current.setStatus('done');
result.current.clearFeedback();
});
expect(result.current.status).toBe('idle');
});
});

View File

@@ -0,0 +1,158 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { useValidatedFields } from '../../src/hooks/useValidatedFields';
import { renderHook } from '../helpers/renderHook';
type FormValues = {
password: string;
confirmPassword: string;
};
function validate(values: FormValues) {
return {
password: values.password.length < 3 ? 'Password too short' : undefined,
confirmPassword:
values.confirmPassword !== values.password ? 'Passwords do not match' : undefined,
};
}
describe('useValidatedFields', () => {
it('initializes values and keeps errors hidden until fields are touched', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
expect(result.current.values).toEqual({ password: '', confirmPassword: '' });
expect(result.current.errors).toEqual({});
expect(result.current.isValid).toBe(false);
});
it('setFieldValue touches and validates by default', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.setFieldValue('password', 'ab');
});
expect(result.current.values.password).toBe('ab');
expect(result.current.errors.password).toBe('Password too short');
});
it('setFieldValue can skip touch and validation', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.setFieldValue('password', 'ab', { touch: false, validate: false });
});
expect(result.current.values.password).toBe('ab');
expect(result.current.errors).toEqual({});
});
it('validateAll can avoid touching fields when requested', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: 'abcd', confirmPassword: 'abce' },
validate,
}),
);
let validationErrors: ReturnType<typeof validate> | undefined;
act(() => {
validationErrors = result.current.validateAll({ touchAll: false });
});
expect(validationErrors).toEqual({
password: undefined,
confirmPassword: 'Passwords do not match',
});
expect(result.current.errors).toEqual({});
});
it('validateAll touches all fields by default', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.validateAll();
});
expect(result.current.errors.password).toBe('Password too short');
expect(result.current.errors.confirmPassword).toBeUndefined();
});
it('supports setFieldError, setErrors and clearErrors', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: 'abcd', confirmPassword: 'abcd' },
validate,
}),
);
act(() => {
result.current.setFieldError('password', 'Server-side password issue');
});
expect(result.current.errors.password).toBe('Server-side password issue');
act(() => {
result.current.setErrors({
password: undefined,
confirmPassword: 'Still invalid',
});
});
expect(result.current.errors.confirmPassword).toBe('Still invalid');
act(() => {
result.current.clearErrors();
});
expect(result.current.errors).toEqual({});
});
it('setValues with clearErrors resets touched state and revalidates', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.setFieldValue('password', 'ab');
});
expect(result.current.errors.password).toBe('Password too short');
act(() => {
result.current.setValues(
{
password: 'abcd',
confirmPassword: 'abcd',
},
{ clearErrors: true },
);
});
expect(result.current.values).toEqual({
password: 'abcd',
confirmPassword: 'abcd',
});
expect(result.current.errors).toEqual({});
expect(result.current.isValid).toBe(true);
});
});

View File

@@ -0,0 +1,239 @@
import { fireEvent } from '@testing-library/react';
import { act } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { isDesktopViewport, useSidePanelMachine } from '../../src/panels/useSidePanelMachine';
import { renderHook } from '../helpers/renderHook';
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
});
}
function resetMatchMedia() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: undefined,
});
}
describe('isDesktopViewport', () => {
it('uses matchMedia when available', () => {
const matchMedia = vi.fn(() => ({ matches: false }));
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: matchMedia,
});
expect(isDesktopViewport(1200)).toBe(false);
expect(matchMedia).toHaveBeenCalledWith('(min-width: 1200px)');
});
it('falls back to window.innerWidth when matchMedia is unavailable', () => {
resetMatchMedia();
setViewportWidth(1024);
expect(isDesktopViewport(1024)).toBe(true);
setViewportWidth(800);
expect(isDesktopViewport(1024)).toBe(false);
});
});
describe('useSidePanelMachine', () => {
it('reads stored width, clamps resize values, and persists final width', async () => {
resetMatchMedia();
setViewportWidth(1200);
localStorage.setItem('panel-width', '999');
const preventDefault = vi.fn();
const { result } = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: true,
closeOnPathname: undefined,
}),
);
expect(result.current.width).toBe(400);
expect(result.current.isDesktop).toBe(true);
expect(localStorage.getItem('panel-width')).toBe('400');
act(() => {
result.current.startResize({
clientX: 400,
preventDefault,
} as never);
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(document.body.classList.contains('resizing')).toBe(true);
act(() => {
fireEvent.pointerMove(window, { clientX: 100 });
fireEvent.pointerUp(window);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.width).toBe(200);
expect(localStorage.getItem('panel-width')).toBe('200');
expect(document.body.classList.contains('resizing')).toBe(false);
});
it('falls back to default width for empty or invalid stored values', () => {
resetMatchMedia();
setViewportWidth(1200);
localStorage.setItem('panel-width', '');
const invalid = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 310,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: false,
closeOnPathname: undefined,
}),
);
expect(invalid.result.current.width).toBe(310);
invalid.unmount();
localStorage.setItem('panel-width', 'NaN-value');
const nonFinite = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 315,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: false,
closeOnPathname: undefined,
}),
);
expect(nonFinite.result.current.width).toBe(315);
});
it('does not start resizing when viewport is mobile or resizing is disabled', () => {
resetMatchMedia();
setViewportWidth(700);
const preventDefault = vi.fn();
const { result } = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: false,
shouldPersistWidth: false,
closeOnPathname: undefined,
}),
);
act(() => {
result.current.startResize({
clientX: 300,
preventDefault,
} as never);
});
expect(preventDefault).not.toHaveBeenCalled();
expect(document.body.classList.contains('resizing')).toBe(false);
expect(localStorage.getItem('panel-width')).toBeNull();
});
it('ignores pointer events when not currently resizing', () => {
resetMatchMedia();
setViewportWidth(1200);
renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: true,
closeOnPathname: undefined,
}),
);
act(() => {
fireEvent.pointerMove(window, { clientX: 1000 });
fireEvent.pointerUp(window);
});
expect(document.body.classList.contains('resizing')).toBe(false);
});
it('handles closeOnPathname, escape key callbacks and body overflow lock', () => {
resetMatchMedia();
setViewportWidth(700);
const onCloseOnPathname = vi.fn();
const onEscape = vi.fn();
let options = {
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-right' as const,
resizingBodyClass: 'resizing',
isOpen: true,
canResize: true,
shouldPersistWidth: false,
closeOnPathname: '/users',
onCloseOnPathname,
onEscape,
};
const { rerender } = renderHook(() => useSidePanelMachine(options));
expect(onCloseOnPathname).toHaveBeenCalledTimes(1);
expect(document.body.style.overflow).toBe('hidden');
act(() => {
fireEvent.keyDown(window, { key: 'Escape' });
});
expect(onEscape).toHaveBeenCalledTimes(1);
options = {
...options,
closeOnPathname: '/posts',
};
rerender();
expect(onCloseOnPathname).toHaveBeenCalledTimes(2);
options = {
...options,
isOpen: false,
};
rerender();
expect(document.body.style.overflow).toBe('');
});
});

15
tests/setup.ts Normal file
View File

@@ -0,0 +1,15 @@
// Required by React to silence act(...) warnings in jsdom tests.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
afterEach(() => {
cleanup();
localStorage.clear();
document.body.className = '';
document.body.style.overflow = '';
vi.restoreAllMocks();
vi.useRealTimers();
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { capitalize, formatDate, splitAndCapitalize } from '../../src/utils/formatting';
describe('formatDate', () => {
it('formats date in it-IT locale and includes seconds when requested', () => {
const withoutSeconds = formatDate('2026-01-01T12:34:56.000Z');
const withSeconds = formatDate('2026-01-01T12:34:56.000Z', true);
expect(withoutSeconds).toContain('2026');
expect(withSeconds).toContain('56');
});
});
describe('capitalize', () => {
it('capitalizes every space-delimited word', () => {
expect(capitalize('hello WORLD')).toBe('Hello World');
expect(capitalize('mUlTi SPACES')).toBe('Multi Spaces');
});
});
describe('splitAndCapitalize', () => {
it('splits underscore and hyphen mode', () => {
expect(splitAndCapitalize('hello_world-test', 'underscore')).toBe('Hello World Test');
});
it('splits camel mode with acronym and number boundaries', () => {
expect(splitAndCapitalize('helloWorldXML2Http', 'camel')).toBe('Hello World Xml 2 Http');
});
it('auto mode prefers underscore splitting when underscore-like chars exist', () => {
expect(splitAndCapitalize('user_name')).toBe('User Name');
expect(splitAndCapitalize('kebab-case')).toBe('Kebab Case');
});
it('auto mode falls back to camel splitting when underscore-like chars are absent', () => {
expect(splitAndCapitalize('helloWorldTest')).toBe('Hello World Test');
});
it('returns empty string for empty or missing input', () => {
expect(splitAndCapitalize('')).toBe('');
expect(splitAndCapitalize(undefined)).toBe('');
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { shouldShowVerifiedEmailBadge } from '../../src/utils/verifiedEmail';
describe('shouldShowVerifiedEmailBadge', () => {
it('returns false when email has not been verified', () => {
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: null,
persistedEmail: 'a@example.com',
currentEmail: 'a@example.com',
isEditing: false,
}),
).toBe(false);
});
it('returns true when verified and not editing', () => {
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: '2025-01-01T10:00:00Z',
persistedEmail: 'a@example.com',
currentEmail: 'other@example.com',
isEditing: false,
}),
).toBe(true);
});
it('while editing, returns true only when trimmed current email matches persisted email', () => {
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: '2025-01-01T10:00:00Z',
persistedEmail: 'a@example.com',
currentEmail: ' a@example.com ',
isEditing: true,
}),
).toBe(true);
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: '2025-01-01T10:00:00Z',
persistedEmail: 'a@example.com',
currentEmail: 'other@example.com',
isEditing: true,
}),
).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
@@ -15,4 +15,18 @@ export default defineConfig({
external: ['react'],
},
},
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/index.ts'],
thresholds: {
lines: 80,
functions: 75,
branches: 70,
},
},
},
});

738
yarn.lock

File diff suppressed because it is too large Load Diff