Add Vitest + React Testing Library setup for viewer-frontend
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user