Implement testing framework with Vitest, add unit tests for API routes and utility functions, and configure CI workflow for automated testing. Update package dependencies for testing libraries and add test scripts to package.json.
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
2025-12-22 12:50:35 -06:00
parent f92b73c7db
commit c7e7d63be5
16 changed files with 3110 additions and 35 deletions

52
lib/mime.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { getMimeType, VIDEO_MIME, AUDIO_MIME } from './mime'
describe('VIDEO_MIME', () => {
it('has correct MIME types for all video formats', () => {
expect(VIDEO_MIME.mp4).toBe('video/mp4')
expect(VIDEO_MIME.webm).toBe('video/webm')
expect(VIDEO_MIME.mkv).toBe('video/x-matroska')
expect(VIDEO_MIME.avi).toBe('video/x-msvideo')
})
})
describe('AUDIO_MIME', () => {
it('has correct MIME types for all audio formats', () => {
expect(AUDIO_MIME.mp3).toBe('audio/mpeg')
expect(AUDIO_MIME.wav).toBe('audio/wav')
expect(AUDIO_MIME.m4a).toBe('audio/mp4')
expect(AUDIO_MIME.opus).toBe('audio/ogg')
})
})
describe('getMimeType', () => {
it('returns correct MIME type for video formats', () => {
expect(getMimeType('video', 'mp4')).toBe('video/mp4')
expect(getMimeType('video', 'webm')).toBe('video/webm')
expect(getMimeType('video', 'mkv')).toBe('video/x-matroska')
expect(getMimeType('video', 'avi')).toBe('video/x-msvideo')
})
it('returns correct MIME type for audio formats', () => {
expect(getMimeType('audio', 'mp3')).toBe('audio/mpeg')
expect(getMimeType('audio', 'wav')).toBe('audio/wav')
expect(getMimeType('audio', 'm4a')).toBe('audio/mp4')
expect(getMimeType('audio', 'opus')).toBe('audio/ogg')
})
it('returns fallback for unknown video format', () => {
expect(getMimeType('video', 'unknown' as any)).toBe('application/octet-stream')
})
it('returns fallback for unknown audio format', () => {
expect(getMimeType('audio', 'unknown' as any)).toBe('application/octet-stream')
})
it('handles audio format passed with video type gracefully', () => {
expect(getMimeType('video', 'mp3')).toBe('application/octet-stream')
})
it('handles video format passed with audio type gracefully', () => {
expect(getMimeType('audio', 'mp4')).toBe('application/octet-stream')
})
})

22
lib/mime.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { AudioFormat, VideoFormat, FormatType } from './types';
export const VIDEO_MIME: Record<VideoFormat, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
};
export const AUDIO_MIME: Record<AudioFormat, string> = {
mp3: 'audio/mpeg',
wav: 'audio/wav',
m4a: 'audio/mp4',
opus: 'audio/ogg',
};
export function getMimeType(formatType: FormatType, format: VideoFormat | AudioFormat): string {
if (formatType === 'video') {
return VIDEO_MIME[format as VideoFormat] || 'application/octet-stream';
}
return AUDIO_MIME[format as AudioFormat] || 'application/octet-stream';
}

122
lib/utils.test.ts Normal file
View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import { isValidYouTubeUrl, extractYouTubeId, isValidYouTubeId, cn, formatDuration } from './utils'
describe('isValidYouTubeUrl', () => {
it('accepts standard youtube.com watch URLs', () => {
expect(isValidYouTubeUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true)
expect(isValidYouTubeUrl('http://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true)
expect(isValidYouTubeUrl('https://youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true)
})
it('accepts youtu.be short URLs', () => {
expect(isValidYouTubeUrl('https://youtu.be/dQw4w9WgXcQ')).toBe(true)
expect(isValidYouTubeUrl('http://youtu.be/dQw4w9WgXcQ')).toBe(true)
})
it('accepts URLs without protocol', () => {
expect(isValidYouTubeUrl('www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true)
expect(isValidYouTubeUrl('youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true)
})
it('rejects non-YouTube URLs', () => {
expect(isValidYouTubeUrl('https://vimeo.com/123456')).toBe(false)
expect(isValidYouTubeUrl('https://google.com')).toBe(false)
expect(isValidYouTubeUrl('not a url')).toBe(false)
expect(isValidYouTubeUrl('')).toBe(false)
})
it('rejects YouTube domain without path', () => {
expect(isValidYouTubeUrl('https://youtube.com')).toBe(false)
expect(isValidYouTubeUrl('https://youtube.com/')).toBe(false)
})
})
describe('extractYouTubeId', () => {
it('extracts ID from standard watch URLs', () => {
expect(extractYouTubeId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ')
expect(extractYouTubeId('https://youtube.com/watch?v=abc123XYZ_-')).toBe('abc123XYZ_-')
})
it('extracts ID from youtu.be short URLs', () => {
expect(extractYouTubeId('https://youtu.be/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ')
})
it('extracts ID from embed URLs', () => {
expect(extractYouTubeId('https://www.youtube.com/embed/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ')
})
it('extracts ID from /v/ URLs', () => {
expect(extractYouTubeId('https://www.youtube.com/v/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ')
})
it('handles URLs with extra parameters', () => {
expect(extractYouTubeId('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120')).toBe('dQw4w9WgXcQ')
expect(extractYouTubeId('https://youtu.be/dQw4w9WgXcQ?t=120')).toBe('dQw4w9WgXcQ')
})
it('returns null for invalid URLs', () => {
expect(extractYouTubeId('https://vimeo.com/123456')).toBe(null)
expect(extractYouTubeId('not a url')).toBe(null)
expect(extractYouTubeId('')).toBe(null)
})
})
describe('isValidYouTubeId', () => {
it('accepts valid 11-character IDs', () => {
expect(isValidYouTubeId('dQw4w9WgXcQ')).toBe(true)
expect(isValidYouTubeId('abc123XYZ_-')).toBe(true)
expect(isValidYouTubeId('AAAAAAAAAAA')).toBe(true)
expect(isValidYouTubeId('00000000000')).toBe(true)
})
it('rejects IDs with wrong length', () => {
expect(isValidYouTubeId('dQw4w9WgXc')).toBe(false)
expect(isValidYouTubeId('dQw4w9WgXcQQ')).toBe(false)
expect(isValidYouTubeId('')).toBe(false)
})
it('rejects IDs with invalid characters', () => {
expect(isValidYouTubeId('dQw4w9WgXc!')).toBe(false)
expect(isValidYouTubeId('dQw4w9WgXc@')).toBe(false)
expect(isValidYouTubeId('dQw4w9 gXcQ')).toBe(false)
})
})
describe('cn', () => {
it('merges class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar')
})
it('handles conditional classes', () => {
expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz')
expect(cn('foo', true && 'bar', 'baz')).toBe('foo bar baz')
})
it('deduplicates tailwind classes', () => {
expect(cn('p-4', 'p-2')).toBe('p-2')
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
})
})
describe('formatDuration', () => {
it('formats seconds into mm:ss', () => {
expect(formatDuration(0)).toBe('0:00')
expect(formatDuration(5)).toBe('0:05')
expect(formatDuration(59)).toBe('0:59')
expect(formatDuration(60)).toBe('1:00')
expect(formatDuration(61)).toBe('1:01')
expect(formatDuration(125)).toBe('2:05')
})
it('handles longer durations', () => {
expect(formatDuration(600)).toBe('10:00')
expect(formatDuration(3600)).toBe('60:00')
expect(formatDuration(3661)).toBe('61:01')
})
it('handles decimal seconds by flooring', () => {
expect(formatDuration(5.7)).toBe('0:05')
expect(formatDuration(59.9)).toBe('0:59')
expect(formatDuration(60.1)).toBe('1:00')
})
})

View File

@@ -33,3 +33,9 @@ export function extractYouTubeId(url: string): string | null {
export function isValidYouTubeId(id: string): boolean {
return /^[A-Za-z0-9_-]{11}$/.test(id);
}
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}

65
lib/youtube.test.ts Normal file
View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { sanitizeFilename } from './youtube'
describe('sanitizeFilename', () => {
it('replaces special characters with underscores', () => {
expect(sanitizeFilename('My Video!@#$%')).toBe('My_Video_')
expect(sanitizeFilename('hello world')).toBe('hello_world')
expect(sanitizeFilename('file/with\\slashes')).toBe('file_with_slashes')
})
it('collapses multiple underscores', () => {
expect(sanitizeFilename('a!!!b')).toBe('a_b')
expect(sanitizeFilename('test file')).toBe('test_file')
expect(sanitizeFilename('no___multiple___underscores')).toBe('no_multiple_underscores')
})
it('preserves alphanumeric characters', () => {
expect(sanitizeFilename('Video123')).toBe('Video123')
expect(sanitizeFilename('ABC_xyz_123')).toBe('ABC_xyz_123')
expect(sanitizeFilename('simple')).toBe('simple')
})
it('truncates to 200 characters', () => {
const longName = 'a'.repeat(250)
expect(sanitizeFilename(longName).length).toBe(200)
expect(sanitizeFilename(longName)).toBe('a'.repeat(200))
})
it('handles edge cases', () => {
expect(sanitizeFilename('')).toBe('')
expect(sanitizeFilename(' ')).toBe('_')
expect(sanitizeFilename('___')).toBe('_')
})
it('handles unicode and emoji', () => {
expect(sanitizeFilename('日本語タイトル')).toBe('_')
expect(sanitizeFilename('Café')).toBe('Caf_')
expect(sanitizeFilename('🎵 Music 🎵')).toBe('_Music_')
})
it('handles typical YouTube video titles', () => {
expect(sanitizeFilename('Never Gonna Give You Up (Official Video)')).toBe('Never_Gonna_Give_You_Up_Official_Video_')
expect(sanitizeFilename("Lofi Hip Hop Radio 📚 - Beats to Relax/Study")).toBe('Lofi_Hip_Hop_Radio_Beats_to_Relax_Study')
expect(sanitizeFilename('[4K] Amazing Nature Video')).toBe('_4K_Amazing_Nature_Video')
expect(sanitizeFilename('Song Title | Artist Name (Lyrics)')).toBe('Song_Title_Artist_Name_Lyrics_')
})
})
describe('format selection logic', () => {
it('maps audio formats correctly', () => {
const audioFormats = ['mp3', 'wav', 'm4a', 'opus'] as const
audioFormats.forEach((format) => {
expect(['mp3', 'wav', 'm4a', 'opus']).toContain(format)
})
})
it('maps video formats correctly', () => {
const videoFormats = ['mp4', 'webm', 'mkv', 'avi'] as const
videoFormats.forEach((format) => {
expect(['mp4', 'webm', 'mkv', 'avi']).toContain(format)
})
})
})

View File

@@ -1,6 +1,6 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { readFile, unlink, mkdir, copyFile } from 'fs/promises';
import { unlink, mkdir, copyFile } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import type { VideoInfo, FormatOption, DownloadRequest, DownloadResponse } from './types';
@@ -22,8 +22,7 @@ function getFfmpegLocationFlag(): string {
const DOWNLOADS_DIR = path.join(process.cwd(), 'downloads');
const PUBLIC_DOWNLOADS_DIR = path.join(process.cwd(), 'public', 'downloads');
// Sanitize filename for safe storage
function sanitizeFilename(filename: string): string {
export function sanitizeFilename(filename: string): string {
return filename
.replace(/[^a-z0-9]/gi, '_')
.replace(/_+/g, '_')