Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions __tests__/api/youtube-import/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vi.mock('@/lib/youtube-import/preview-media-url', () => ({
}));

import { POST } from '@/app/api/youtube-import/resolve/route';
import { NoBrowserStreamableFormatError } from '@/lib/youtube-import/probe-keyframes';

const USER_ID = 'user-123';
const ACCESS_TOKEN = 'yt-access-token';
Expand Down Expand Up @@ -134,6 +135,7 @@ describe('POST /api/youtube-import/resolve', () => {
const body = await response.json();
expect(body.data).toEqual({
...resolvedMetadata,
previewMode: 'direct',
previewStreamUrl: '/api/youtube-import/preview/stream?youtubeVideoId=dQw4w9WgXcQ',
previewExpiresAt: expect.any(Number),
});
Expand Down Expand Up @@ -200,6 +202,41 @@ describe('POST /api/youtube-import/resolve', () => {
expect(body.message).toBe('quotaExceeded');
});

it('returns embed preview mode when no browser-streamable format exists', async () => {
mockResolvePreviewDirectMediaUrl.mockRejectedValueOnce(
new NoBrowserStreamableFormatError(
'yt-dlp returned formats but none are browser-streamable progressive MP4'
)
);

const response = await POST(
createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' })
);

expect(response.status).toBe(200);
const body = await response.json();
expect(body.data).toEqual({
...resolvedMetadata,
previewMode: 'embed',
});
expect(body.data.previewStreamUrl).toBeUndefined();
expect(body.data.previewExpiresAt).toBeUndefined();
});

it('returns 502 when preview media resolution fails for other reasons', async () => {
mockResolvePreviewDirectMediaUrl.mockRejectedValueOnce(
new Error('yt-dlp metadata lookup failed')
);

const response = await POST(
createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' })
);

expect(response.status).toBe(502);
const body = await response.json();
expect(body.message).toBe('yt-dlp metadata lookup failed');
});

it('returns 400 when mapYouTubeImportResolvedSource rejects the video', async () => {
mockMapYouTubeImportResolvedSource.mockReturnValueOnce({
ok: false,
Expand Down
28 changes: 24 additions & 4 deletions __tests__/components/youtube-import/TrimRangeSlider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ function renderSlider(
) {
const onChange = props.onChange ?? vi.fn();
const initialValue = props.value ?? { startSeconds: 10, endSeconds: 100 };
const { onChange: _onChange, value: _value, ...sliderProps } = props;
const view = render(
<TrimRangeSliderHarness
durationSeconds={props.durationSeconds ?? 300}
youtubeVideoId={props.youtubeVideoId ?? VIDEO_ID}
durationSeconds={300}
youtubeVideoId={VIDEO_ID}
initialValue={initialValue}
onChange={onChange}
playerHandle={props.playerHandle}
enableKeyframeSnap={props.enableKeyframeSnap}
{...sliderProps}
/>
);
return { onChange, ...view };
Expand Down Expand Up @@ -201,6 +201,26 @@ describe('TrimRangeSlider', () => {
expect(onChange).toHaveBeenCalled();
});

it('hides frame nudge buttons but keeps jump-step controls when showFrameNudge is false', () => {
renderSlider({ showFrameNudge: false });

expect(screen.queryByTestId('trim-start-frame-earlier')).not.toBeInTheDocument();
expect(screen.queryByTestId('trim-start-frame-later')).not.toBeInTheDocument();
expect(screen.queryByTestId('trim-end-frame-earlier')).not.toBeInTheDocument();
expect(screen.queryByTestId('trim-end-frame-later')).not.toBeInTheDocument();
expect(screen.getByTestId('trim-start-jump-earlier')).toBeInTheDocument();
expect(screen.getByTestId('trim-start-jump-later')).toBeInTheDocument();
expect(screen.getByTestId('trim-jump-step-5')).toBeInTheDocument();
});

it('does not request keyframes when showFrameNudge is false and snapping is disabled', () => {
renderSlider({ showFrameNudge: false, enableKeyframeSnap: false });

fireEvent.click(screen.getByTestId('trim-start-jump-later'));

expect(global.fetch).not.toHaveBeenCalled();
});

it('snaps the moved handle to the closest returned keyframe after a frame nudge', async () => {
const onChange = vi.fn();
renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { YouTubeEmbedPreviewPlayer } from '@/components/youtube-import/YouTubeEmbedPreviewPlayer';
import type { YouTubePlayerHandle } from '@/components/youtube-import/YouTubePreviewPlayer';

const VIDEO_ID = 'dQw4w9WgXcQ';

describe('YouTubeEmbedPreviewPlayer', () => {
const mockSeekTo = vi.fn();
const mockGetCurrentTime = vi.fn();
const mockDestroy = vi.fn();

class MockYouTubePlayer {
seekTo = mockSeekTo;
getCurrentTime = mockGetCurrentTime;
destroy = mockDestroy;

constructor(
_element: HTMLElement,
options?: {
videoId?: string;
events?: {
onReady?: (event: { target: MockYouTubePlayer }) => void;
};
}
) {
options?.events?.onReady?.({ target: this });
}
}

beforeEach(() => {
vi.restoreAllMocks();
mockSeekTo.mockReset();
mockGetCurrentTime.mockReset();
mockDestroy.mockReset();
mockGetCurrentTime.mockReturnValue(42);

window.YT = {
Player: MockYouTubePlayer as unknown as NonNullable<typeof window.YT>['Player'],
};
});

it('renders an embed preview container and creates a YT.Player for the video id', async () => {
const playerSpy = vi.spyOn(window.YT!, 'Player');
const { container } = render(<YouTubeEmbedPreviewPlayer youtubeVideoId={VIDEO_ID} />);

expect(screen.getByTestId('youtube-embed-preview-player')).toBeInTheDocument();

await waitFor(() => {
expect(playerSpy).toHaveBeenCalledWith(
container.querySelector('.aspect-video div'),
expect.objectContaining({
videoId: VIDEO_ID,
})
);
});
});

it('exposes previewAt and getCurrentTime through playerRef', async () => {
const playerRef = { current: null as YouTubePlayerHandle | null };

render(<YouTubeEmbedPreviewPlayer youtubeVideoId={VIDEO_ID} playerRef={playerRef} />);

await waitFor(() => {
expect(playerRef.current).not.toBeNull();
});

playerRef.current?.previewAt(120);

await waitFor(() => {
expect(mockSeekTo).toHaveBeenCalledWith(120, true);
});

expect(playerRef.current?.getCurrentTime()).toBe(42);
});

it('destroys the player on unmount', async () => {
const playerSpy = vi.spyOn(window.YT!, 'Player');
const { unmount } = render(<YouTubeEmbedPreviewPlayer youtubeVideoId={VIDEO_ID} />);

await waitFor(() => {
expect(playerSpy).toHaveBeenCalled();
});

unmount();

expect(mockDestroy).toHaveBeenCalledTimes(1);
});
});
67 changes: 53 additions & 14 deletions __tests__/components/youtube-import/YouTubeImportModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,16 @@ vi.mock('@/components/youtube-import/YouTubePreviewPlayer', () => ({
YouTubePreviewPlayer: () => <div data-testid="youtube-preview-player" />,
}));

vi.mock('@/components/youtube-import/TrimRangeSlider', () => ({
TrimRangeSlider: ({
onChange,
}: {
onChange: (value: { startSeconds: number; endSeconds: number }) => void;
}) => (
<button
type="button"
onClick={() => onChange({ startSeconds: 5, endSeconds: 95 })}
data-testid="trim-range-slider"
>
Adjust trim
</button>
),
vi.mock('@/components/youtube-import/YouTubeEmbedPreviewPlayer', () => ({
YouTubeEmbedPreviewPlayer: () => <div data-testid="youtube-embed-preview-player" />,
}));

class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}

const DRAFT_ID = 'draft-1';
const VIDEO_ID = 'dQw4w9WgXcQ';

Expand All @@ -39,10 +33,19 @@ const resolvedSource = {
title: 'Sunday Service',
durationSeconds: 3600,
thumbnailUrl: 'https://img.youtube.com/high.jpg',
previewMode: 'direct' as const,
previewStreamUrl: `/api/youtube-import/preview/stream?youtubeVideoId=${VIDEO_ID}`,
previewExpiresAt: Date.now() + 3_600_000,
};

const embedResolvedSource = {
youtubeVideoId: VIDEO_ID,
title: 'Sunday Service',
durationSeconds: 3600,
thumbnailUrl: 'https://img.youtube.com/high.jpg',
previewMode: 'embed' as const,
};

const livestreamRow: Livestream = {
id: 'livestream-1',
userId: 'user-1',
Expand Down Expand Up @@ -204,6 +207,7 @@ function renderModal(props: Partial<ComponentProps<typeof YouTubeImportModal>> =

beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});

afterEach(() => {
Expand Down Expand Up @@ -246,6 +250,41 @@ describe('YouTubeImportModal', () => {
);
});

it('uses embed preview when resolve returns previewMode embed', async () => {
installFetchMock({
resolve: () => new Response(JSON.stringify({ data: embedResolvedSource }), { status: 200 }),
});
const user = userEvent.setup();

renderModal();

await waitFor(() => {
expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument();
});

await user.type(
screen.getByLabelText(/youtube link/i),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
);
await user.click(screen.getByRole('button', { name: /use this link/i }));

await waitFor(() => {
expect(screen.getByTestId('youtube-embed-preview-player')).toBeInTheDocument();
expect(screen.queryByTestId('youtube-preview-player')).not.toBeInTheDocument();
});

const smartCutCheckbox = screen.getByRole('checkbox', { name: /smart cut/i });
expect(smartCutCheckbox).toBeChecked();
expect(smartCutCheckbox).toBeDisabled();

expect(screen.queryByTestId('trim-start-frame-earlier')).not.toBeInTheDocument();
expect(screen.queryByTestId('trim-start-frame-later')).not.toBeInTheDocument();
expect(screen.queryByTestId('trim-end-frame-earlier')).not.toBeInTheDocument();
expect(screen.queryByTestId('trim-end-frame-later')).not.toBeInTheDocument();
expect(screen.getByTestId('trim-start-jump-earlier')).toBeInTheDocument();
expect(screen.getByTestId('trim-jump-step-5')).toBeInTheDocument();
});

it('hides the preview player when Show video preview is unchecked', async () => {
installFetchMock({});
const user = userEvent.setup();
Expand Down
69 changes: 69 additions & 0 deletions __tests__/lib/youtube-import/probe-keyframes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildFfprobeReadInterval,
getDirectMediaUrl,
isBrowserStreamableMp4Format,
NoBrowserStreamableFormatError,
parseFfprobeKeyframeCsv,
parseFfprobeKeyframePacketCsv,
pickYtDlpProbeFormat,
Expand Down Expand Up @@ -493,4 +494,72 @@ describe('getDirectMediaUrl', () => {

await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toThrow(/timed out after 50ms/);
});

it('throws NoBrowserStreamableFormatError when only DASH/HLS formats exist', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

mockSpawnProcess.mockImplementationOnce(() =>
createMockChild({
stdout: JSON.stringify({
duration: 3600,
formats: [
{
url: 'https://example.com/manifest.mpd',
height: 720,
vcodec: 'avc1',
acodec: 'none',
ext: 'mp4',
protocol: 'http_dash_segments',
format_id: '137',
},
{
url: 'https://example.com/playlist.m3u8',
height: 720,
vcodec: 'avc1',
acodec: 'mp4a',
ext: 'mp4',
protocol: 'm3u8_native',
format_id: '96',
},
],
}),
})
);

await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toBeInstanceOf(
NoBrowserStreamableFormatError
);
expect(warnSpy).toHaveBeenCalledWith(
'[getDirectMediaUrl] No browser-streamable MP4 format; yt-dlp format list:',
[
{ protocol: 'http_dash_segments', ext: 'mp4', format_id: '137' },
{ protocol: 'm3u8_native', ext: 'mp4', format_id: '96' },
]
);

warnSpy.mockRestore();
});

it('throws a generic Error when yt-dlp returns no formats', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

mockSpawnProcess.mockImplementationOnce(() =>
createMockChild({
stdout: JSON.stringify({
duration: 3600,
formats: [],
}),
})
);

const error = await getDirectMediaUrl('dQw4w9WgXcQ').catch((err: unknown) => err);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(NoBrowserStreamableFormatError);
expect((error as Error).message).toBe(
'yt-dlp metadata lookup did not return any video formats'
);
expect(warnSpy).not.toHaveBeenCalled();

warnSpy.mockRestore();
});
});
Loading
Loading