cms.c2sgmbh/tests/unit/video/video-utils.unit.spec.ts
Martin Porwoll 913897c87c feat: add comprehensive video feature with collections, hooks, and tests
Video Feature Implementation:
- Add Videos and VideoCategories collections with multi-tenant support
- Extend VideoBlock with library/upload/embed sources and playback options
- Add featuredVideo group to Posts collection with processed embed URLs

Hooks & Validation:
- Add processFeaturedVideo hook for URL parsing and privacy mode embedding
- Add createSlugValidationHook for tenant-scoped slug uniqueness
- Add video-utils library (parseVideoUrl, generateEmbedUrl, formatDuration)

Testing:
- Add 84 unit tests for video-utils (URL parsing, duration, embed generation)
- Add 14 integration tests for Videos collection CRUD and slug validation

Database:
- Migration for videos, video_categories tables with locales
- Migration for Posts featuredVideo processed fields
- Update payload internal tables for new collections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 10:48:33 +00:00

532 lines
16 KiB
TypeScript

/**
* Video Utils Unit Tests
*
* Tests for the video utility module.
* Covers URL parsing, embed URL generation, duration formatting, and validation.
*/
import { describe, it, expect } from 'vitest'
import {
parseVideoUrl,
generateEmbedUrl,
formatDuration,
parseDuration,
getAspectRatioClass,
extractVideoId,
isValidVideoUrl,
getVideoPlatform,
getVideoThumbnail,
validateVideoUrl,
} from '@/lib/video'
describe('Video Utils', () => {
describe('parseVideoUrl', () => {
describe('YouTube URLs', () => {
it('parses standard watch URL', () => {
const result = parseVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
expect(result).not.toBeNull()
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
expect(result?.embedUrl).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ')
expect(result?.thumbnailUrl).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg')
})
it('parses short URL (youtu.be)', () => {
const result = parseVideoUrl('https://youtu.be/dQw4w9WgXcQ')
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
})
it('parses embed URL', () => {
const result = parseVideoUrl('https://www.youtube.com/embed/dQw4w9WgXcQ')
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
})
it('parses youtube-nocookie URL', () => {
const result = parseVideoUrl('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ')
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
})
it('parses shorts URL', () => {
const result = parseVideoUrl('https://www.youtube.com/shorts/dQw4w9WgXcQ')
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
})
it('parses URL with additional parameters', () => {
const result = parseVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120&list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf')
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
})
it('handles URL without https://', () => {
const result = parseVideoUrl('youtube.com/watch?v=dQw4w9WgXcQ')
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
})
})
describe('Vimeo URLs', () => {
it('parses standard Vimeo URL', () => {
const result = parseVideoUrl('https://vimeo.com/123456789')
expect(result?.platform).toBe('vimeo')
expect(result?.videoId).toBe('123456789')
expect(result?.embedUrl).toBe('https://player.vimeo.com/video/123456789')
expect(result?.thumbnailUrl).toBeNull() // Vimeo needs API call
})
it('parses player URL', () => {
const result = parseVideoUrl('https://player.vimeo.com/video/123456789')
expect(result?.platform).toBe('vimeo')
expect(result?.videoId).toBe('123456789')
})
it('parses channel URL', () => {
const result = parseVideoUrl('https://vimeo.com/channels/staffpicks/123456789')
expect(result?.platform).toBe('vimeo')
expect(result?.videoId).toBe('123456789')
})
it('parses groups URL', () => {
const result = parseVideoUrl('https://vimeo.com/groups/shortfilms/videos/123456789')
expect(result?.platform).toBe('vimeo')
expect(result?.videoId).toBe('123456789')
})
})
describe('External Video URLs', () => {
it('recognizes direct MP4 URL', () => {
const result = parseVideoUrl('https://example.com/video.mp4')
expect(result?.platform).toBe('external')
expect(result?.videoId).toBeNull()
expect(result?.embedUrl).toBe('https://example.com/video.mp4')
})
it('recognizes WebM URL', () => {
const result = parseVideoUrl('https://example.com/video.webm')
expect(result?.platform).toBe('external')
})
it('recognizes MOV URL', () => {
const result = parseVideoUrl('https://cdn.example.com/uploads/movie.mov')
expect(result?.platform).toBe('external')
})
})
describe('Edge Cases', () => {
it('returns null for empty string', () => {
expect(parseVideoUrl('')).toBeNull()
})
it('returns null for null input', () => {
expect(parseVideoUrl(null as unknown as string)).toBeNull()
})
it('returns null for undefined input', () => {
expect(parseVideoUrl(undefined as unknown as string)).toBeNull()
})
it('returns unknown for invalid URL', () => {
const result = parseVideoUrl('https://example.com/page')
expect(result?.platform).toBe('unknown')
expect(result?.videoId).toBeNull()
expect(result?.embedUrl).toBeNull()
})
it('handles whitespace', () => {
const result = parseVideoUrl(' https://www.youtube.com/watch?v=dQw4w9WgXcQ ')
expect(result?.platform).toBe('youtube')
expect(result?.videoId).toBe('dQw4w9WgXcQ')
})
})
})
describe('generateEmbedUrl', () => {
const youtubeInfo = {
platform: 'youtube' as const,
videoId: 'dQw4w9WgXcQ',
originalUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
embedUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ',
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
}
const vimeoInfo = {
platform: 'vimeo' as const,
videoId: '123456789',
originalUrl: 'https://vimeo.com/123456789',
embedUrl: 'https://player.vimeo.com/video/123456789',
thumbnailUrl: null,
}
describe('YouTube', () => {
it('generates basic embed URL', () => {
const url = generateEmbedUrl(youtubeInfo)
expect(url).toContain('youtube.com/embed/dQw4w9WgXcQ')
expect(url).toContain('modestbranding=1')
})
it('adds autoplay parameter', () => {
const url = generateEmbedUrl(youtubeInfo, { autoplay: true })
expect(url).toContain('autoplay=1')
})
it('adds mute parameter', () => {
const url = generateEmbedUrl(youtubeInfo, { muted: true })
expect(url).toContain('mute=1')
})
it('adds loop parameter with playlist', () => {
const url = generateEmbedUrl(youtubeInfo, { loop: true })
expect(url).toContain('loop=1')
expect(url).toContain('playlist=dQw4w9WgXcQ')
})
it('hides controls when specified', () => {
const url = generateEmbedUrl(youtubeInfo, { controls: false })
expect(url).toContain('controls=0')
})
it('adds start time', () => {
const url = generateEmbedUrl(youtubeInfo, { startTime: 120 })
expect(url).toContain('start=120')
})
it('uses privacy mode (youtube-nocookie)', () => {
const url = generateEmbedUrl(youtubeInfo, { privacyMode: true })
expect(url).toContain('youtube-nocookie.com')
expect(url).not.toContain('www.youtube.com')
})
it('disables related videos', () => {
const url = generateEmbedUrl(youtubeInfo, { showRelated: false })
expect(url).toContain('rel=0')
})
it('combines multiple options', () => {
const url = generateEmbedUrl(youtubeInfo, {
autoplay: true,
muted: true,
loop: true,
privacyMode: true,
startTime: 30,
})
expect(url).toContain('youtube-nocookie.com')
expect(url).toContain('autoplay=1')
expect(url).toContain('mute=1')
expect(url).toContain('loop=1')
expect(url).toContain('start=30')
})
})
describe('Vimeo', () => {
it('generates basic embed URL', () => {
const url = generateEmbedUrl(vimeoInfo)
expect(url).toBe('https://player.vimeo.com/video/123456789')
})
it('adds autoplay parameter', () => {
const url = generateEmbedUrl(vimeoInfo, { autoplay: true })
expect(url).toContain('autoplay=1')
})
it('adds muted parameter', () => {
const url = generateEmbedUrl(vimeoInfo, { muted: true })
expect(url).toContain('muted=1')
})
it('adds loop parameter', () => {
const url = generateEmbedUrl(vimeoInfo, { loop: true })
expect(url).toContain('loop=1')
})
it('adds start time as hash', () => {
const url = generateEmbedUrl(vimeoInfo, { startTime: 60 })
expect(url).toContain('#t=60s')
})
})
describe('Edge Cases', () => {
it('returns null for null input', () => {
expect(generateEmbedUrl(null as never)).toBeNull()
})
it('returns null for video info without embed URL', () => {
expect(generateEmbedUrl({ ...youtubeInfo, embedUrl: null })).toBeNull()
})
it('floors start time to integer', () => {
const url = generateEmbedUrl(youtubeInfo, { startTime: 30.5 })
expect(url).toContain('start=30')
expect(url).not.toContain('start=30.5')
})
})
})
describe('formatDuration', () => {
it('formats seconds under a minute', () => {
expect(formatDuration(45)).toBe('0:45')
})
it('formats minutes and seconds', () => {
expect(formatDuration(150)).toBe('2:30')
})
it('formats hours, minutes, and seconds', () => {
expect(formatDuration(3723)).toBe('1:02:03')
})
it('pads single digits', () => {
expect(formatDuration(65)).toBe('1:05')
expect(formatDuration(3605)).toBe('1:00:05')
})
it('handles zero', () => {
expect(formatDuration(0)).toBe('0:00')
})
it('handles negative numbers', () => {
expect(formatDuration(-10)).toBe('0:00')
})
it('handles NaN', () => {
expect(formatDuration(NaN)).toBe('0:00')
})
it('handles non-number input', () => {
expect(formatDuration('invalid' as unknown as number)).toBe('0:00')
})
})
describe('parseDuration', () => {
it('parses MM:SS format', () => {
expect(parseDuration('2:30')).toBe(150)
})
it('parses HH:MM:SS format', () => {
expect(parseDuration('1:02:30')).toBe(3750)
})
it('parses seconds only', () => {
expect(parseDuration('90')).toBe(90)
})
it('parses "Xh Ym Zs" format', () => {
expect(parseDuration('1h 30m 45s')).toBe(5445)
})
it('parses partial formats', () => {
expect(parseDuration('2h')).toBe(7200)
expect(parseDuration('30m')).toBe(1800)
expect(parseDuration('45s')).toBe(45)
expect(parseDuration('1h 30m')).toBe(5400)
})
it('handles whitespace', () => {
expect(parseDuration(' 2:30 ')).toBe(150)
})
it('handles empty string', () => {
expect(parseDuration('')).toBe(0)
})
it('handles null/undefined', () => {
expect(parseDuration(null as unknown as string)).toBe(0)
expect(parseDuration(undefined as unknown as string)).toBe(0)
})
it('handles invalid input', () => {
expect(parseDuration('invalid')).toBe(0)
})
})
describe('getAspectRatioClass', () => {
it('returns aspect-video for 16:9', () => {
expect(getAspectRatioClass('16:9')).toBe('aspect-video')
})
it('returns correct class for 4:3', () => {
expect(getAspectRatioClass('4:3')).toBe('aspect-[4/3]')
})
it('returns aspect-square for 1:1', () => {
expect(getAspectRatioClass('1:1')).toBe('aspect-square')
})
it('returns correct class for 9:16', () => {
expect(getAspectRatioClass('9:16')).toBe('aspect-[9/16]')
})
it('returns correct class for 21:9', () => {
expect(getAspectRatioClass('21:9')).toBe('aspect-[21/9]')
})
it('returns default for unknown ratio', () => {
expect(getAspectRatioClass('unknown')).toBe('aspect-video')
})
})
describe('extractVideoId', () => {
it('extracts YouTube video ID', () => {
expect(extractVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ')
})
it('extracts Vimeo video ID', () => {
expect(extractVideoId('https://vimeo.com/123456789')).toBe('123456789')
})
it('returns null for external URLs', () => {
expect(extractVideoId('https://example.com/video.mp4')).toBeNull()
})
it('returns null for invalid URLs', () => {
expect(extractVideoId('not-a-url')).toBeNull()
})
})
describe('isValidVideoUrl', () => {
it('returns true for YouTube URLs', () => {
expect(isValidVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true)
})
it('returns true for Vimeo URLs', () => {
expect(isValidVideoUrl('https://vimeo.com/123456789')).toBe(true)
})
it('returns true for direct video URLs', () => {
expect(isValidVideoUrl('https://example.com/video.mp4')).toBe(true)
})
it('returns false for non-video URLs', () => {
expect(isValidVideoUrl('https://example.com/page')).toBe(false)
})
it('returns false for empty string', () => {
expect(isValidVideoUrl('')).toBe(false)
})
})
describe('getVideoPlatform', () => {
it('returns youtube for YouTube URLs', () => {
expect(getVideoPlatform('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('youtube')
})
it('returns vimeo for Vimeo URLs', () => {
expect(getVideoPlatform('https://vimeo.com/123456789')).toBe('vimeo')
})
it('returns external for direct video URLs', () => {
expect(getVideoPlatform('https://example.com/video.mp4')).toBe('external')
})
it('returns unknown for non-video URLs', () => {
expect(getVideoPlatform('https://example.com/page')).toBe('unknown')
})
})
describe('getVideoThumbnail', () => {
it('returns YouTube thumbnail in default quality', () => {
const url = getVideoThumbnail('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'default')
expect(url).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/default.jpg')
})
it('returns YouTube thumbnail in high quality', () => {
const url = getVideoThumbnail('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'high')
expect(url).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg')
})
it('returns YouTube thumbnail in max quality', () => {
const url = getVideoThumbnail('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'max')
expect(url).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg')
})
it('returns null for Vimeo (requires API)', () => {
expect(getVideoThumbnail('https://vimeo.com/123456789')).toBeNull()
})
it('returns null for external URLs', () => {
expect(getVideoThumbnail('https://example.com/video.mp4')).toBeNull()
})
it('returns null for invalid URLs', () => {
expect(getVideoThumbnail('not-a-url')).toBeNull()
})
})
describe('validateVideoUrl', () => {
it('returns valid for YouTube URL', () => {
const result = validateVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
expect(result.valid).toBe(true)
expect(result.error).toBeUndefined()
})
it('returns valid for Vimeo URL', () => {
const result = validateVideoUrl('https://vimeo.com/123456789')
expect(result.valid).toBe(true)
})
it('returns valid for direct video URL', () => {
const result = validateVideoUrl('https://example.com/video.mp4')
expect(result.valid).toBe(true)
})
it('returns invalid for empty URL', () => {
const result = validateVideoUrl('')
expect(result.valid).toBe(false)
expect(result.error).toBe('URL ist erforderlich')
})
it('returns invalid for URL without protocol', () => {
const result = validateVideoUrl('youtube.com/watch?v=dQw4w9WgXcQ')
expect(result.valid).toBe(false)
expect(result.error).toContain('http')
})
it('returns invalid for unknown URL format', () => {
const result = validateVideoUrl('https://example.com/page')
expect(result.valid).toBe(false)
expect(result.error).toContain('Unbekanntes Video-Format')
})
})
})