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
Some checks are pending
CI / test (push) Waiting to run
This commit is contained in:
29
.github/workflows/ci.yml
vendored
Normal file
29
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
@@ -1,33 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { downloadVideo } from '@/lib/youtube';
|
import { downloadVideo } from '@/lib/youtube';
|
||||||
import { isValidYouTubeUrl } from '@/lib/utils';
|
import { isValidYouTubeUrl } from '@/lib/utils';
|
||||||
import type { DownloadRequest, AudioFormat, VideoFormat, FormatType } from '@/lib/types';
|
import { getMimeType } from '@/lib/mime';
|
||||||
|
import type { DownloadRequest } from '@/lib/types';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
const VIDEO_MIME: Record<VideoFormat, string> = {
|
|
||||||
mp4: 'video/mp4',
|
|
||||||
webm: 'video/webm',
|
|
||||||
mkv: 'video/x-matroska',
|
|
||||||
avi: 'video/x-msvideo',
|
|
||||||
};
|
|
||||||
|
|
||||||
const AUDIO_MIME: Record<AudioFormat, string> = {
|
|
||||||
mp3: 'audio/mpeg',
|
|
||||||
wav: 'audio/wav',
|
|
||||||
m4a: 'audio/mp4',
|
|
||||||
opus: 'audio/ogg',
|
|
||||||
};
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveDownloadPath(downloadUrl: string) {
|
async function resolveDownloadPath(downloadUrl: string) {
|
||||||
const sanitized = downloadUrl.startsWith('/') ? downloadUrl.slice(1) : downloadUrl;
|
const sanitized = downloadUrl.startsWith('/') ? downloadUrl.slice(1) : downloadUrl;
|
||||||
const filePath = path.join(process.cwd(), 'public', sanitized);
|
const filePath = path.join(process.cwd(), 'public', sanitized);
|
||||||
|
|||||||
149
app/api/download/route.test.ts
Normal file
149
app/api/download/route.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { POST } from './route'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
vi.mock('@/lib/youtube', () => ({
|
||||||
|
downloadVideo: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { downloadVideo } from '@/lib/youtube'
|
||||||
|
|
||||||
|
const mockDownloadVideo = vi.mocked(downloadVideo)
|
||||||
|
|
||||||
|
function createRequest(body: object): NextRequest {
|
||||||
|
return new NextRequest('http://localhost:3000/api/download', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/download', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when url is missing', async () => {
|
||||||
|
const request = createRequest({ format: 'mp4', formatType: 'video' })
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Missing required fields')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when format is missing', async () => {
|
||||||
|
const request = createRequest({ url: 'https://youtube.com/watch?v=abc', formatType: 'video' })
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Missing required fields')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when formatType is missing', async () => {
|
||||||
|
const request = createRequest({ url: 'https://youtube.com/watch?v=abc', format: 'mp4' })
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Missing required fields')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 for invalid YouTube URL', async () => {
|
||||||
|
const request = createRequest({
|
||||||
|
url: 'https://vimeo.com/123',
|
||||||
|
format: 'mp4',
|
||||||
|
formatType: 'video',
|
||||||
|
})
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Invalid YouTube URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 500 when download fails', async () => {
|
||||||
|
mockDownloadVideo.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'Download failed',
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
format: 'mp4',
|
||||||
|
formatType: 'video',
|
||||||
|
})
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(500)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Download failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns download result on success', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
success: true,
|
||||||
|
downloadUrl: '/downloads/123.mp4',
|
||||||
|
filename: 'video.mp4',
|
||||||
|
}
|
||||||
|
mockDownloadVideo.mockResolvedValue(mockResult)
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
format: 'mp4',
|
||||||
|
formatType: 'video',
|
||||||
|
})
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.success).toBe(true)
|
||||||
|
expect(data.downloadUrl).toBe('/downloads/123.mp4')
|
||||||
|
expect(data.filename).toBe('video.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes correct parameters to downloadVideo', async () => {
|
||||||
|
mockDownloadVideo.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
downloadUrl: '/downloads/123.mp3',
|
||||||
|
filename: 'audio.mp3',
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
format: 'mp3',
|
||||||
|
formatType: 'audio',
|
||||||
|
})
|
||||||
|
await POST(request)
|
||||||
|
|
||||||
|
expect(mockDownloadVideo).toHaveBeenCalledWith({
|
||||||
|
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
format: 'mp3',
|
||||||
|
formatType: 'audio',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles exceptions gracefully', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mockDownloadVideo.mockRejectedValue(new Error('Unexpected error'))
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
format: 'mp4',
|
||||||
|
formatType: 'video',
|
||||||
|
})
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(500)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Unexpected error')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
89
app/api/info/route.test.ts
Normal file
89
app/api/info/route.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { POST } from './route'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
vi.mock('@/lib/youtube', () => ({
|
||||||
|
getVideoInfo: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getVideoInfo } from '@/lib/youtube'
|
||||||
|
|
||||||
|
const mockGetVideoInfo = vi.mocked(getVideoInfo)
|
||||||
|
|
||||||
|
function createRequest(body: object): NextRequest {
|
||||||
|
return new NextRequest('http://localhost:3000/api/info', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/info', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when URL is missing', async () => {
|
||||||
|
const request = createRequest({})
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('URL is required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 for invalid YouTube URL', async () => {
|
||||||
|
const request = createRequest({ url: 'https://vimeo.com/123' })
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Invalid YouTube URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 500 when getVideoInfo fails', async () => {
|
||||||
|
mockGetVideoInfo.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const request = createRequest({ url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' })
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(500)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Failed to get video info')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns video info on success', async () => {
|
||||||
|
const mockVideoInfo = {
|
||||||
|
title: 'Test Video',
|
||||||
|
duration: 180,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
|
formats: [],
|
||||||
|
}
|
||||||
|
mockGetVideoInfo.mockResolvedValue(mockVideoInfo)
|
||||||
|
|
||||||
|
const request = createRequest({ url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' })
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.success).toBe(true)
|
||||||
|
expect(data.videoInfo).toEqual(mockVideoInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles exceptions gracefully', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mockGetVideoInfo.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
const request = createRequest({ url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' })
|
||||||
|
const response = await POST(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(500)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('Network error')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
281
app/page.test.tsx
Normal file
281
app/page.test.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
|
||||||
|
import Home from './page'
|
||||||
|
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
|
||||||
|
const mockFetch = vi.mocked(global.fetch)
|
||||||
|
|
||||||
|
describe('Home Page', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the main heading', () => {
|
||||||
|
render(<Home />)
|
||||||
|
expect(screen.getByText('Downlink')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the URL input', () => {
|
||||||
|
render(<Home />)
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
expect(inputs.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the Fetch button', () => {
|
||||||
|
render(<Home />)
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error for empty URL on fetch', async () => {
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please enter a YouTube URL')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error for invalid YouTube URL', async () => {
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://vimeo.com/123' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please enter a valid YouTube URL')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches video info for valid URL', async () => {
|
||||||
|
const mockVideoInfo = {
|
||||||
|
title: 'Test Video',
|
||||||
|
duration: 180,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true, videoInfo: mockVideoInfo }),
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Video')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/info', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays video duration after fetch', async () => {
|
||||||
|
const mockVideoInfo = {
|
||||||
|
title: 'Test Video',
|
||||||
|
duration: 125,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true, videoInfo: mockVideoInfo }),
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('2:05')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows download options after fetching video info', async () => {
|
||||||
|
const mockVideoInfo = {
|
||||||
|
title: 'Test Video',
|
||||||
|
duration: 180,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true, videoInfo: mockVideoInfo }),
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Download Options')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles API error response', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: false, error: 'Video not found' }),
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Video not found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles fetch network error', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows switching between video and audio format types', async () => {
|
||||||
|
const mockVideoInfo = {
|
||||||
|
title: 'Test Video',
|
||||||
|
duration: 180,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true, videoInfo: mockVideoInfo }),
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Download Options')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioButton = screen.getByRole('button', { name: /audio/i })
|
||||||
|
fireEvent.click(audioButton)
|
||||||
|
|
||||||
|
expect(screen.getByText('Audio Format')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers download on button click', async () => {
|
||||||
|
const mockVideoInfo = {
|
||||||
|
title: 'Test Video',
|
||||||
|
duration: 180,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true, videoInfo: mockVideoInfo }),
|
||||||
|
} as Response)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
downloadUrl: '/downloads/test.mp4',
|
||||||
|
filename: 'test.mp4',
|
||||||
|
}),
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Download Options')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadButton = screen.getByRole('button', { name: /download mp4/i })
|
||||||
|
fireEvent.click(downloadButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenLastCalledWith('/api/download', expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success message after download completes', async () => {
|
||||||
|
const mockVideoInfo = {
|
||||||
|
title: 'Test Video',
|
||||||
|
duration: 180,
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true, videoInfo: mockVideoInfo }),
|
||||||
|
} as Response)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
downloadUrl: '/downloads/test.mp4',
|
||||||
|
filename: 'test.mp4',
|
||||||
|
}),
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
render(<Home />)
|
||||||
|
|
||||||
|
const inputs = screen.getAllByPlaceholderText('Paste YouTube URL here...')
|
||||||
|
fireEvent.change(inputs[0], { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } })
|
||||||
|
|
||||||
|
const fetchButtons = screen.getAllByRole('button', { name: /fetch/i })
|
||||||
|
fireEvent.click(fetchButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Download Options')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download mp4/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Ready to Download')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Download, Loader2, Play, Music, Sparkles, Zap, Check } from 'lucide-react';
|
import { Download, Loader2, Play, Music, Sparkles, Zap, Check } from 'lucide-react';
|
||||||
import { isValidYouTubeUrl } from '@/lib/utils';
|
import { isValidYouTubeUrl, formatDuration } from '@/lib/utils';
|
||||||
import type { VideoInfo, DownloadResponse, VideoFormat, AudioFormat } from '@/lib/types';
|
import type { VideoInfo, DownloadResponse, VideoFormat, AudioFormat } from '@/lib/types';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -112,12 +112,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative">
|
<div className="min-h-screen relative">
|
||||||
{/* Animated background */}
|
{/* Animated background */}
|
||||||
|
|||||||
52
lib/mime.test.ts
Normal file
52
lib/mime.test.ts
Normal 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
22
lib/mime.ts
Normal 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
122
lib/utils.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -33,3 +33,9 @@ export function extractYouTubeId(url: string): string | null {
|
|||||||
export function isValidYouTubeId(id: string): boolean {
|
export function isValidYouTubeId(id: string): boolean {
|
||||||
return /^[A-Za-z0-9_-]{11}$/.test(id);
|
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
65
lib/youtube.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { readFile, unlink, mkdir, copyFile } from 'fs/promises';
|
import { unlink, mkdir, copyFile } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { VideoInfo, FormatOption, DownloadRequest, DownloadResponse } from './types';
|
import type { VideoInfo, FormatOption, DownloadRequest, DownloadResponse } from './types';
|
||||||
@@ -22,8 +22,7 @@ function getFfmpegLocationFlag(): string {
|
|||||||
const DOWNLOADS_DIR = path.join(process.cwd(), 'downloads');
|
const DOWNLOADS_DIR = path.join(process.cwd(), 'downloads');
|
||||||
const PUBLIC_DOWNLOADS_DIR = path.join(process.cwd(), 'public', 'downloads');
|
const PUBLIC_DOWNLOADS_DIR = path.join(process.cwd(), 'public', 'downloads');
|
||||||
|
|
||||||
// Sanitize filename for safe storage
|
export function sanitizeFilename(filename: string): string {
|
||||||
function sanitizeFilename(filename: string): string {
|
|
||||||
return filename
|
return filename
|
||||||
.replace(/[^a-z0-9]/gi, '_')
|
.replace(/[^a-z0-9]/gi, '_')
|
||||||
.replace(/_+/g, '_')
|
.replace(/_+/g, '_')
|
||||||
|
|||||||
2263
package-lock.json
generated
2263
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -22,13 +24,19 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.0",
|
"eslint-config-next": "16.1.0",
|
||||||
|
"jsdom": "^27.3.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
vitest.config.ts
Normal file
17
vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: ['**/node_modules/**', '**/.next/**'],
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1
vitest.setup.ts
Normal file
1
vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
Reference in New Issue
Block a user