mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
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>
532 lines
16 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|