Add Vitest + React Testing Library setup for viewer-frontend
CI / skip-ci-check (pull_request) Successful in 5s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 28s

Establishes frontend unit testing infra (jsdom env, RTL, jest-dom
matchers, common Radix/jsdom polyfills) and seeds it with tests for
lib/utils.ts, lib/photo-utils.ts, hooks/useFocusTrap.ts, and
ThemeToggle.tsx (46 tests total).
This commit is contained in:
2026-07-14 18:35:30 -04:00
parent 05b908241d
commit 975d0c809d
10 changed files with 2303 additions and 30 deletions
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import type { Photo } from '@prisma/client';
import {
getImageSrc,
getVideoSrc,
getWebPlaybackStreamUrl,
isUrl,
isVideo,
} from '@/lib/photo-utils';
function makePhoto(overrides: Partial<Photo> = {}): Photo {
return {
id: 1,
path: '/data/photos/2024/img.jpg',
media_type: 'image',
...overrides,
} as Photo;
}
describe('isUrl', () => {
it('treats http(s) paths as URLs', () => {
expect(isUrl('http://example.com/a.jpg')).toBe(true);
expect(isUrl('https://example.com/a.jpg')).toBe(true);
});
it('treats filesystem paths as non-URLs', () => {
expect(isUrl('/data/photos/img.jpg')).toBe(false);
expect(isUrl('data/photos/img.jpg')).toBe(false);
});
});
describe('isVideo', () => {
it('recognizes the snake_case media_type field (raw DB access / Prisma default)', () => {
expect(isVideo(makePhoto({ media_type: 'video' }))).toBe(true);
});
it('recognizes a camelCase mediaType field, in case a caller normalizes casing', () => {
const photo = { ...makePhoto(), media_type: null, mediaType: 'video' } as unknown as Photo;
expect(isVideo(photo)).toBe(true);
});
it('returns false for images', () => {
expect(isVideo(makePhoto({ media_type: 'image' }))).toBe(false);
});
});
describe('getImageSrc', () => {
it('returns the raw URL directly for URL-backed photos', () => {
const photo = makePhoto({ path: 'https://cdn.example.com/a.jpg' });
expect(getImageSrc(photo)).toBe('https://cdn.example.com/a.jpg');
});
it('routes filesystem-backed photos through the API proxy', () => {
const photo = makePhoto({ id: 42 });
expect(getImageSrc(photo)).toBe('/api/photos/42/image');
});
it('adds a watermark query param when requested', () => {
const photo = makePhoto({ id: 42 });
expect(getImageSrc(photo, { watermark: true })).toBe('/api/photos/42/image?watermark=true');
});
it('prefers the thumbnail endpoint for grid display, ignoring the URL/proxy split', () => {
const urlPhoto = makePhoto({ id: 5, path: 'https://cdn.example.com/a.jpg' });
expect(getImageSrc(urlPhoto, { thumbnail: true })).toBe('/api/photos/5/image?thumbnail=true');
});
it('combines thumbnail and watermark params', () => {
const photo = makePhoto({ id: 7 });
const src = getImageSrc(photo, { thumbnail: true, watermark: true });
expect(src).toContain('thumbnail=true');
expect(src).toContain('watermark=true');
});
});
describe('getVideoSrc', () => {
it('returns the raw URL directly for URL-backed videos', () => {
const photo = makePhoto({ path: 'https://cdn.example.com/clip.mp4' });
expect(getVideoSrc(photo)).toBe('https://cdn.example.com/clip.mp4');
});
it('routes filesystem-backed videos through the API proxy', () => {
const photo = makePhoto({ id: 9 });
expect(getVideoSrc(photo)).toBe('/api/photos/9/image');
});
});
describe('getWebPlaybackStreamUrl', () => {
it('builds the transcoded-playback endpoint for the given photo id', () => {
expect(getWebPlaybackStreamUrl(makePhoto({ id: 13 }))).toBe('/api/photos/13/web-playback');
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { cn, isValidEmail } from '@/lib/utils';
describe('cn', () => {
it('merges class names', () => {
expect(cn('a', 'b')).toBe('a b');
});
it('drops falsy values', () => {
expect(cn('a', false, undefined, null, '', 'b')).toBe('a b');
});
it('resolves conflicting tailwind classes, keeping the last one', () => {
expect(cn('p-2', 'p-4')).toBe('p-4');
});
it('applies conditional classes via object syntax', () => {
expect(cn('base', { active: true, hidden: false })).toBe('base active');
});
});
describe('isValidEmail', () => {
it.each([
'user@example.com',
'first.last@example.co.uk',
'user+tag@example.com',
'user_name@example.io',
])('accepts a valid address: %s', (email) => {
expect(isValidEmail(email)).toBe(true);
});
it('trims surrounding whitespace before validating', () => {
expect(isValidEmail(' user@example.com ')).toBe(true);
});
it.each([
['', 'empty string'],
['not-an-email', 'missing @ and domain'],
['user@', 'missing domain'],
['@example.com', 'missing local part'],
['user@example', 'domain missing TLD'],
['user..name@example.com', 'consecutive dots in local part'],
['.user@example.com', 'local part starts with a dot'],
['user.@example.com', 'local part ends with a dot'],
['user@example..com', 'consecutive dots in domain'],
['user@.example.com', 'domain starts with a dot'],
['user@example.c', 'TLD shorter than 2 chars'],
])('rejects an invalid address: %s (%s)', (email) => {
expect(isValidEmail(email)).toBe(false);
});
it('rejects non-string input without throwing', () => {
expect(isValidEmail(null as unknown as string)).toBe(false);
expect(isValidEmail(undefined as unknown as string)).toBe(false);
});
it('rejects a local part longer than 64 characters', () => {
const longLocal = 'a'.repeat(65);
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
});
it('rejects an address longer than 254 characters overall', () => {
const longDomain = 'a'.repeat(250) + '.com';
expect(isValidEmail(`user@${longDomain}`)).toBe(false);
});
});