Compare commits
7 Commits
v0.1.4
...
102a8e29d3
| Author | SHA1 | Date | |
|---|---|---|---|
| 102a8e29d3 | |||
| 2ec7705b4b | |||
| 5877d90f07 | |||
| 2b96277bec | |||
| 063d1073de | |||
| d7e144620e | |||
| 7e938138ff |
27
.drone.yml
27
.drone.yml
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
16
package.json
16
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
154
tests/api/createApiClient.test.ts
Normal file
154
tests/api/createApiClient.test.ts
Normal 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
25
tests/api/query.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
143
tests/auth/createAuthContext.test.tsx
Normal file
143
tests/auth/createAuthContext.test.tsx
Normal 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
60
tests/auth/jwt.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
246
tests/contexts/LeftMenuContext.test.tsx
Normal file
246
tests/contexts/LeftMenuContext.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
249
tests/contexts/RightSidebarContext.test.tsx
Normal file
249
tests/contexts/RightSidebarContext.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
143
tests/errors/createErrorResolver.test.ts
Normal file
143
tests/errors/createErrorResolver.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
36
tests/helpers/renderHook.tsx
Normal file
36
tests/helpers/renderHook.tsx
Normal 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();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
64
tests/hooks/useCooldownTimer.test.tsx
Normal file
64
tests/hooks/useCooldownTimer.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
81
tests/hooks/useEditableForm.test.tsx
Normal file
81
tests/hooks/useEditableForm.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
170
tests/hooks/usePaginatedResource.test.tsx
Normal file
170
tests/hooks/usePaginatedResource.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
97
tests/hooks/useSorting.test.tsx
Normal file
97
tests/hooks/useSorting.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
44
tests/hooks/useSubmitState.test.tsx
Normal file
44
tests/hooks/useSubmitState.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
158
tests/hooks/useValidatedFields.test.tsx
Normal file
158
tests/hooks/useValidatedFields.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
239
tests/panels/useSidePanelMachine.test.tsx
Normal file
239
tests/panels/useSidePanelMachine.test.tsx
Normal 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
15
tests/setup.ts
Normal 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();
|
||||
});
|
||||
43
tests/utils/formatting.test.ts
Normal file
43
tests/utils/formatting.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
46
tests/utils/verifiedEmail.test.ts
Normal file
46
tests/utils/verifiedEmail.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user