mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
651 lines
21 KiB
TypeScript
651 lines
21 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
|
import { useDownloadActions } from '@/components/video-page/hooks/use-download-actions';
|
|
import type { Comment, Version, VideoData } from '@/components/video-page/types';
|
|
|
|
const toastError = vi.fn();
|
|
const toastCustom = vi.fn();
|
|
const toastDismiss = vi.fn();
|
|
|
|
vi.mock('sonner', () => ({
|
|
toast: {
|
|
error: (...args: unknown[]) => toastError(...args),
|
|
custom: (...args: unknown[]) => toastCustom(...args),
|
|
dismiss: (...args: unknown[]) => toastDismiss(...args),
|
|
},
|
|
}));
|
|
|
|
type Params = Parameters<typeof useDownloadActions>[0];
|
|
|
|
const VERSION_ID = 'ver1';
|
|
const BUNNY_HOST = 'cdn.example.test';
|
|
const ALLOWED_DIRECT_HOST = 'files.example.test';
|
|
/** Just over the 10 GiB ceiling in lib/client/download-file.ts. */
|
|
const OVERSIZED_BYTES = String(11 * 1024 * 1024 * 1024);
|
|
|
|
function makeVersion(overrides: Partial<Version> = {}): Version & { comments: Comment[] } {
|
|
return {
|
|
id: VERSION_ID,
|
|
versionNumber: 1,
|
|
versionLabel: null,
|
|
providerId: 'bunny',
|
|
videoId: 'vid1',
|
|
originalUrl: `https://${BUNNY_HOST}/abc/play.mp4`,
|
|
title: null,
|
|
thumbnailUrl: null,
|
|
duration: 600,
|
|
isActive: true,
|
|
_count: { comments: 0 },
|
|
comments: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeVideo(overrides: Partial<VideoData> = {}): VideoData {
|
|
return {
|
|
id: 'vid1',
|
|
title: 'Cut 3',
|
|
description: null,
|
|
projectId: 'proj1',
|
|
project: { name: 'Ad campaign', ownerId: 'user1' },
|
|
versions: [],
|
|
isAuthenticated: true,
|
|
currentUserId: 'user1',
|
|
currentUserName: 'Ada',
|
|
canDownload: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
interface FileResponseInit {
|
|
ok?: boolean;
|
|
contentLength?: string | null;
|
|
contentType?: string | null;
|
|
}
|
|
|
|
/** What the CDN answers when the bytes are pulled for renaming. */
|
|
function fileResponse({
|
|
ok = true,
|
|
contentLength = '2048',
|
|
contentType = 'video/mp4',
|
|
}: FileResponseInit = {}) {
|
|
return {
|
|
ok,
|
|
status: ok ? 200 : 502,
|
|
headers: {
|
|
get: (name: string) => {
|
|
if (name === 'content-length') return contentLength;
|
|
if (name === 'content-type') return contentType;
|
|
return null;
|
|
},
|
|
},
|
|
// Null body sends downloadNamedFile down its res.blob() path, which is what
|
|
// a jsdom fetch mock can honestly represent.
|
|
body: null,
|
|
blob: () => Promise.resolve(new Blob(['bytes'])),
|
|
json: () => Promise.reject(new SyntaxError('not json')),
|
|
};
|
|
}
|
|
|
|
function prepareResponse(ok: boolean, payload: unknown = {}) {
|
|
return {
|
|
ok,
|
|
status: ok ? 200 : 404,
|
|
headers: { get: () => null },
|
|
json: () => Promise.resolve(payload),
|
|
};
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
let clicked: { href: string; download: string }[];
|
|
/** The response the byte-pulling fetch answers with; reassign per test. */
|
|
let downloadResponse: ReturnType<typeof fileResponse>;
|
|
|
|
type Harness = RenderHookResult<ReturnType<typeof useDownloadActions>, Params>;
|
|
|
|
function renderDownload(overrides: Partial<Params> = {}): Harness {
|
|
const initialProps: Params = {
|
|
activeVersion: makeVersion(),
|
|
video: makeVideo(),
|
|
...overrides,
|
|
};
|
|
return renderHook((props: Params) => useDownloadActions(props), { initialProps });
|
|
}
|
|
|
|
function urlsFetched(): string[] {
|
|
return fetchMock.mock.calls.map((call) => call[0] as string);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', `https://${BUNNY_HOST}`);
|
|
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', ALLOWED_DIRECT_HOST);
|
|
clicked = [];
|
|
downloadResponse = fileResponse();
|
|
fetchMock = vi.fn((url: string) => {
|
|
if (typeof url === 'string' && url.includes('prepare=1')) {
|
|
return Promise.resolve(prepareResponse(true, { data: {} }));
|
|
}
|
|
return Promise.resolve(downloadResponse);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
// jsdom would try to navigate on a real anchor click. Record the anchor
|
|
// instead: its href and download attribute are the whole observable result.
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
this: HTMLAnchorElement
|
|
) {
|
|
clicked.push({ href: this.getAttribute('href') ?? '', download: this.download });
|
|
});
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
toastError.mockReset();
|
|
toastCustom.mockReset();
|
|
toastDismiss.mockReset();
|
|
});
|
|
|
|
describe('useDownloadActions refusing to start', () => {
|
|
it('does nothing before a version is loaded', async () => {
|
|
const harness = renderDownload({ activeVersion: undefined });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(toastError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing before the video is loaded', async () => {
|
|
const harness = renderDownload({ video: null });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses when the share link has downloads switched off', async () => {
|
|
const harness = renderDownload({ video: makeVideo({ canDownload: false }) });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('Download is disabled for this shared link');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// canDownload is optional on VideoData, and the guard is a plain falsy check,
|
|
// so a payload that never mentions the flag is treated as "no downloads".
|
|
it('refuses when the payload never mentioned canDownload', async () => {
|
|
const video = makeVideo();
|
|
delete video.canDownload;
|
|
const harness = renderDownload({ video });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('Download is disabled for this shared link');
|
|
});
|
|
|
|
it('refuses a provider with no direct file behind it', async () => {
|
|
const harness = renderDownload({
|
|
activeVersion: makeVersion({ providerId: 'youtube', originalUrl: 'https://youtu.be/abc' }),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('This video source does not support direct download');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('useDownloadActions from Bunny', () => {
|
|
it('asks the route to prepare the file before pulling it', async () => {
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload('compressed');
|
|
});
|
|
|
|
expect(urlsFetched()).toEqual([
|
|
`/api/versions/${VERSION_ID}/download?source=compressed&prepare=1`,
|
|
`/api/versions/${VERSION_ID}/download?source=compressed`,
|
|
]);
|
|
expect(fetchMock.mock.calls[0][1]).toEqual({ cache: 'no-store' });
|
|
expect(clicked).toEqual([{ href: 'blob:openframe-test', download: 'Cut 3 v1.mp4' }]);
|
|
});
|
|
|
|
it('carries the original preference through both requests', async () => {
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload('original');
|
|
});
|
|
|
|
expect(urlsFetched()).toEqual([
|
|
`/api/versions/${VERSION_ID}/download?source=original&prepare=1`,
|
|
`/api/versions/${VERSION_ID}/download?source=original`,
|
|
]);
|
|
});
|
|
|
|
it('defaults to the compressed file when no preference is given', async () => {
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(urlsFetched()[0]).toContain('source=compressed');
|
|
});
|
|
|
|
it('reports which file it is fetching while the download runs', async () => {
|
|
const pending = deferred<unknown>();
|
|
fetchMock.mockReturnValueOnce(pending.promise);
|
|
const harness = renderDownload();
|
|
|
|
let started: Promise<void> | undefined;
|
|
act(() => {
|
|
started = harness.result.current.startDownload('original');
|
|
});
|
|
|
|
expect(harness.result.current.activeDownloadTarget).toBe('original');
|
|
expect(harness.result.current.isDownloadingVideo).toBe(true);
|
|
|
|
await act(async () => {
|
|
pending.resolve(prepareResponse(true, { data: {} }));
|
|
await started;
|
|
});
|
|
|
|
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
|
expect(harness.result.current.isDownloadingVideo).toBe(false);
|
|
});
|
|
|
|
it('shows the message the route sent when the file is not ready', async () => {
|
|
fetchMock.mockResolvedValueOnce(
|
|
prepareResponse(false, { error: 'Original file is still processing' })
|
|
);
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload('original');
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('Original file is still processing');
|
|
expect(clicked).toEqual([]);
|
|
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
|
});
|
|
|
|
it('names the missing original when the failure body says nothing', async () => {
|
|
fetchMock.mockResolvedValueOnce(prepareResponse(false, {}));
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload('original');
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('Original file is not available for this video');
|
|
});
|
|
|
|
it('names the missing compressed file when the failure body says nothing', async () => {
|
|
fetchMock.mockResolvedValueOnce(prepareResponse(false, {}));
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload('compressed');
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('Compressed file is not available for this video');
|
|
});
|
|
|
|
it('clears the progress panel before showing the error', async () => {
|
|
fetchMock.mockResolvedValueOnce(prepareResponse(false, { error: 'Nope' }));
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
// The prepare step fails before the panel is opened, so nothing to dismiss.
|
|
expect(toastCustom).not.toHaveBeenCalled();
|
|
expect(toastError).toHaveBeenCalledWith('Nope');
|
|
});
|
|
|
|
it('opens a progress panel and leaves a success message behind', async () => {
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
// One render for the initial panel, more as progress and success arrive.
|
|
expect(toastCustom).toHaveBeenCalled();
|
|
expect(toastCustom.mock.calls[0][1]).toMatchObject({ id: `download-${VERSION_ID}` });
|
|
expect(toastDismiss).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to a plain navigation for a file too large to rename', async () => {
|
|
downloadResponse = fileResponse({ contentLength: OVERSIZED_BYTES });
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastDismiss).toHaveBeenCalledWith(`download-${VERSION_ID}`);
|
|
// Cross-origin, so no download attribute: the CDN picks the filename.
|
|
expect(clicked).toEqual([
|
|
{ href: `/api/versions/${VERSION_ID}/download?source=compressed`, download: '' },
|
|
]);
|
|
expect(toastError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to a plain navigation when the CDN refuses the byte request', async () => {
|
|
downloadResponse = fileResponse({ ok: false });
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(clicked).toHaveLength(1);
|
|
expect(clicked[0].download).toBe('');
|
|
expect(toastDismiss).toHaveBeenCalledWith(`download-${VERSION_ID}`);
|
|
});
|
|
});
|
|
|
|
describe('useDownloadActions naming the file', () => {
|
|
it('uses the version label when the editor set one', async () => {
|
|
const harness = renderDownload({
|
|
activeVersion: makeVersion({ versionLabel: ' Client cut ', versionNumber: 4 }),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(clicked[0].download).toBe('Cut 3 Client cut.mp4');
|
|
});
|
|
|
|
it('falls back to the version number when there is no label', async () => {
|
|
const harness = renderDownload({ activeVersion: makeVersion({ versionNumber: 7 }) });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(clicked[0].download).toBe('Cut 3 v7.mp4');
|
|
});
|
|
|
|
it('strips path separators and other characters a filesystem rejects', async () => {
|
|
const harness = renderDownload({ video: makeVideo({ title: 'Q3/Q4: "final" cut' }) });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(clicked[0].download).toBe('Q3-Q4- -final- cut v1.mp4');
|
|
});
|
|
|
|
// The `|| 'video'` fallback in the hook is unreachable in practice:
|
|
// sanitising replaces forbidden characters with '-' instead of dropping them,
|
|
// and the "v<number>" suffix survives any title. Pinned so that a rewrite of
|
|
// sanitizeDownloadFileName has to decide about it deliberately.
|
|
it('still produces a name when the title is nothing but separators', async () => {
|
|
const harness = renderDownload({ video: makeVideo({ title: '///' }) });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(clicked[0].download).toBe('--- v1.mp4');
|
|
});
|
|
|
|
it('takes the extension from the content type the CDN reported', async () => {
|
|
downloadResponse = fileResponse({ contentType: 'video/quicktime' });
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(clicked[0].download).toBe('Cut 3 v1.mov');
|
|
});
|
|
});
|
|
|
|
describe('useDownloadActions from R2', () => {
|
|
const r2Version = makeVersion({
|
|
providerId: 'r2',
|
|
originalUrl: '/api/upload/video/proj1/clip.webm',
|
|
});
|
|
|
|
it('navigates to the same-origin proxy with the download attribute set', async () => {
|
|
const harness = renderDownload({ activeVersion: r2Version });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
// No prepare step and no byte pulling: the proxy streams it.
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(clicked).toEqual([
|
|
{ href: '/api/upload/video/proj1/clip.webm', download: 'Cut 3 v1.webm' },
|
|
]);
|
|
expect(toastCustom).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// The R2 branch never awaits, so the busy flag is set and cleared inside one
|
|
// batch: the button never renders as downloading. That is correct here (the
|
|
// browser takes over immediately) but it means the target is unobservable.
|
|
it('never renders as busy because the R2 branch never awaits', async () => {
|
|
const harness = renderDownload({ activeVersion: r2Version });
|
|
|
|
let started: Promise<void> | undefined;
|
|
act(() => {
|
|
started = harness.result.current.startDownload('original');
|
|
});
|
|
|
|
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
|
expect(clicked).toHaveLength(1);
|
|
|
|
await act(async () => {
|
|
await started;
|
|
});
|
|
});
|
|
|
|
it('defaults the extension to mp4 when the proxy path has none', async () => {
|
|
const harness = renderDownload({
|
|
activeVersion: makeVersion({ providerId: 'r2', originalUrl: '/api/upload/video/proj1/clip' }),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(clicked[0].download).toBe('Cut 3 v1.mp4');
|
|
});
|
|
|
|
it('refuses an R2 version whose URL is not the media proxy', async () => {
|
|
const harness = renderDownload({
|
|
activeVersion: makeVersion({
|
|
providerId: 'r2',
|
|
originalUrl: 'https://evil.example.test/clip.mp4',
|
|
}),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
|
expect(clicked).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('useDownloadActions from a direct host', () => {
|
|
function directVersion(url: string) {
|
|
return makeVersion({ providerId: 'direct', originalUrl: url });
|
|
}
|
|
|
|
it('pulls the bytes from a host on the allow list', async () => {
|
|
downloadResponse = fileResponse({ contentType: null });
|
|
const harness = renderDownload({
|
|
activeVersion: directVersion(`https://${ALLOWED_DIRECT_HOST}/clip.mov`),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(urlsFetched()).toEqual([`https://${ALLOWED_DIRECT_HOST}/clip.mov`]);
|
|
expect(clicked).toEqual([{ href: 'blob:openframe-test', download: 'Cut 3 v1.mov' }]);
|
|
});
|
|
|
|
it('accepts the Bunny CDN hostname without it being listed explicitly', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', '');
|
|
const harness = renderDownload({
|
|
activeVersion: directVersion(`https://${BUNNY_HOST}/clip.mp4`),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).not.toHaveBeenCalled();
|
|
expect(clicked).toHaveLength(1);
|
|
});
|
|
|
|
it('refuses a host that is not on the allow list', async () => {
|
|
const harness = renderDownload({
|
|
activeVersion: directVersion('https://evil.example.test/clip.mp4'),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses every host when neither allow list is configured', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', '');
|
|
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', '');
|
|
const harness = renderDownload({
|
|
activeVersion: directVersion(`https://${ALLOWED_DIRECT_HOST}/clip.mp4`),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
|
});
|
|
|
|
it('refuses a non-http scheme even on an allowed host', async () => {
|
|
const harness = renderDownload({
|
|
activeVersion: directVersion(`javascript:alert(1)//${ALLOWED_DIRECT_HOST}`),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
|
expect(clicked).toEqual([]);
|
|
});
|
|
|
|
it('refuses a URL that does not parse at all', async () => {
|
|
const harness = renderDownload({ activeVersion: directVersion('not a url') });
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
|
});
|
|
|
|
it('matches the host case-insensitively', async () => {
|
|
const harness = renderDownload({
|
|
activeVersion: directVersion(`https://FILES.EXAMPLE.TEST/clip.mp4`),
|
|
});
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
|
|
expect(toastError).not.toHaveBeenCalled();
|
|
expect(clicked).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('useDownloadActions repeated clicks', () => {
|
|
it('ignores a second click once the button has re-rendered as busy', async () => {
|
|
const pending = deferred<unknown>();
|
|
fetchMock.mockReturnValueOnce(pending.promise);
|
|
const harness = renderDownload();
|
|
|
|
let first: Promise<void> | undefined;
|
|
act(() => {
|
|
first = harness.result.current.startDownload();
|
|
});
|
|
expect(harness.result.current.isDownloadingVideo).toBe(true);
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
pending.resolve(prepareResponse(true, { data: {} }));
|
|
await first;
|
|
});
|
|
});
|
|
|
|
// KNOWN FRAGILITY, pinned rather than fixed. The in-flight guard reads
|
|
// `isDownloadingVideo` out of the closure the callback was created in, so two
|
|
// calls made from the SAME render (a double click landing before React
|
|
// commits the state update) both get through and the file is fetched twice.
|
|
it('lets two calls from the same render both through', async () => {
|
|
const startDownload = renderDownload().result.current.startDownload;
|
|
|
|
await act(async () => {
|
|
await Promise.all([startDownload(), startDownload()]);
|
|
});
|
|
|
|
expect(urlsFetched().filter((url) => url.includes('prepare=1'))).toHaveLength(2);
|
|
});
|
|
|
|
it('is ready to download again after a failure', async () => {
|
|
fetchMock.mockResolvedValueOnce(prepareResponse(false, { error: 'Nope' }));
|
|
const harness = renderDownload();
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
expect(harness.result.current.isDownloadingVideo).toBe(false);
|
|
|
|
await act(async () => {
|
|
await harness.result.current.startDownload();
|
|
});
|
|
expect(clicked).toHaveLength(1);
|
|
});
|
|
});
|