test: close the coverage gaps the first round left

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.
This commit is contained in:
yusufipk
2026-07-26 13:25:11 +07:00
parent fe42c0836f
commit 0187db5dc7
55 changed files with 17028 additions and 166 deletions
+554
View File
@@ -0,0 +1,554 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import type { ApprovalDecision, ApprovalRequest } from '@/components/video-page/types';
type Params = Parameters<typeof useApprovals>[0];
const VERSION_ID = 'ver1';
const PROJECT_ID = 'proj1';
function makeDecision(overrides: Partial<ApprovalDecision> = {}): ApprovalDecision {
return {
id: 'dec1',
approverId: 'user2',
status: 'PENDING',
note: null,
respondedAt: null,
createdAt: '2026-01-01T00:00:00.000Z',
approver: { id: 'user2', name: 'Linus', email: '[email protected]', image: null },
...overrides,
};
}
function makeRequest(overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
return {
id: 'req1',
status: 'PENDING',
requestedById: 'user1',
message: null,
resolvedAt: null,
canceledAt: null,
canceledById: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
requestedBy: { id: 'user1', name: 'Ada', email: '[email protected]', image: null },
canceledBy: null,
decisions: [makeDecision()],
...overrides,
};
}
function ok(payload: unknown) {
return { ok: true, status: 200, json: () => Promise.resolve(payload) };
}
function fail(status: number, payload: unknown = {}) {
return { ok: false, status, 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>;
/** What the approvals GET answers with. Reassign to change it mid-test. */
let listedRequests: ApprovalRequest[];
let listedCandidates: unknown[];
function callsTo(url: string, method?: string) {
return fetchMock.mock.calls.filter(
(call) => call[0] === url && (call[1]?.method ?? undefined) === method
);
}
function bodyOf(call: unknown[]): unknown {
const init = call[1] as { body?: string };
return init.body === undefined ? undefined : JSON.parse(init.body);
}
type Harness = RenderHookResult<ReturnType<typeof useApprovals>, Params>;
function renderApprovals(overrides: Partial<Params> = {}): Harness {
const initialProps: Params = {
projectId: PROJECT_ID,
activeVersionId: VERSION_ID,
currentUserId: 'user1',
...overrides,
};
return renderHook((props: Params) => useApprovals(props), { initialProps });
}
beforeEach(() => {
listedRequests = [makeRequest()];
listedCandidates = [{ id: 'user2', name: 'Linus', email: '[email protected]', image: null }];
fetchMock = vi.fn((url: string) => {
if (url === `/api/versions/${VERSION_ID}/approvals`) {
return Promise.resolve(ok({ data: { requests: listedRequests } }));
}
if (url === `/api/projects/${PROJECT_ID}/approval-candidates`) {
return Promise.resolve(ok({ data: { candidates: listedCandidates } }));
}
return Promise.resolve(ok({ data: {} }));
});
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('useApprovals reading the request list', () => {
it('reads the approvals of the active version, bypassing the cache', async () => {
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(fetchMock).toHaveBeenCalledWith(`/api/versions/${VERSION_ID}/approvals`, {
cache: 'no-store',
});
expect(harness.result.current.requests).toEqual(listedRequests);
expect(harness.result.current.error).toBe('');
});
it('does not read anything before a version is selected', async () => {
const harness = renderApprovals({ activeVersionId: null });
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(fetchMock).not.toHaveBeenCalled();
expect(harness.result.current.requests).toEqual([]);
});
it('flags loading while the read is in flight and clears it afterwards', async () => {
const pending = deferred<unknown>();
fetchMock.mockReturnValue(pending.promise);
const harness = renderApprovals();
let read: Promise<void> | undefined;
act(() => {
read = harness.result.current.fetchRequests();
});
expect(harness.result.current.isLoadingRequests).toBe(true);
await act(async () => {
pending.resolve(ok({ data: { requests: [] } }));
await read;
});
expect(harness.result.current.isLoadingRequests).toBe(false);
});
it('shows the message the server sent when the caller is forbidden', async () => {
fetchMock.mockResolvedValue(fail(403, { error: 'Access denied' }));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.error).toBe('Access denied');
expect(harness.result.current.requests).toEqual([]);
expect(harness.result.current.isLoadingRequests).toBe(false);
});
it('falls back to a generic message when a 500 carries no error string', async () => {
fetchMock.mockResolvedValue(fail(500, {}));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.error).toBe('Failed to fetch approval requests');
});
it('reports a network failure instead of leaving the panel spinning', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.error).toBe('Failed to fetch approval requests');
expect(harness.result.current.isLoadingRequests).toBe(false);
});
it('clears a previous error when the next read succeeds', async () => {
fetchMock.mockResolvedValueOnce(fail(500, { error: 'Boom' }));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.error).toBe('Boom');
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.error).toBe('');
});
// A failed read must not silently empty a list the user is looking at.
it('keeps the requests already on screen when a later read fails', async () => {
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.requests).toHaveLength(1);
fetchMock.mockResolvedValue(fail(500, {}));
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.requests).toHaveLength(1);
});
it('treats a body with no requests key as an empty list', async () => {
fetchMock.mockResolvedValue(ok({ data: {} }));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.requests).toEqual([]);
});
});
describe('useApprovals reading the candidate list', () => {
it('reads the approvers of the project, bypassing the cache', async () => {
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchCandidates();
});
expect(fetchMock).toHaveBeenCalledWith(`/api/projects/${PROJECT_ID}/approval-candidates`, {
cache: 'no-store',
});
expect(harness.result.current.candidates).toEqual(listedCandidates);
});
it('does not read approvers without a project', async () => {
const harness = renderApprovals({ projectId: undefined });
await act(async () => {
await harness.result.current.fetchCandidates();
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('shows the message the server sent when the caller cannot manage the project', async () => {
fetchMock.mockResolvedValue(fail(403, { error: 'Access denied' }));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchCandidates();
});
expect(harness.result.current.error).toBe('Access denied');
expect(harness.result.current.isLoadingCandidates).toBe(false);
});
it('reports a network failure while reading approvers', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchCandidates();
});
expect(harness.result.current.error).toBe('Failed to fetch approvers');
});
});
describe('useApprovals creating a request', () => {
it('posts the approvers to the active version and re-reads the list', async () => {
const harness = renderApprovals();
let created: boolean | undefined;
await act(async () => {
created = await harness.result.current.createRequest(['user2', 'user3'], 'Please review');
});
const post = callsTo(`/api/versions/${VERSION_ID}/approvals`, 'POST')[0];
expect(bodyOf(post)).toEqual({ approverIds: ['user2', 'user3'], message: 'Please review' });
expect(created).toBe(true);
// The POST answers with the created row, but the hook trusts only the
// re-read, so the list has to come back from the GET that follows.
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(1);
expect(harness.result.current.requests).toEqual(listedRequests);
});
it('omits the message entirely when none was typed', async () => {
const harness = renderApprovals();
await act(async () => {
await harness.result.current.createRequest(['user2'], '');
});
const post = callsTo(`/api/versions/${VERSION_ID}/approvals`, 'POST')[0];
expect(bodyOf(post)).toEqual({ approverIds: ['user2'] });
});
it('refuses to post before a version is selected', async () => {
const harness = renderApprovals({ activeVersionId: null });
let created: boolean | undefined;
await act(async () => {
created = await harness.result.current.createRequest(['user2']);
});
expect(created).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('surfaces the server error and skips the re-read when the post is rejected', async () => {
fetchMock.mockResolvedValue(fail(403, { error: 'Only editors can request approval' }));
const harness = renderApprovals();
let created: boolean | undefined;
await act(async () => {
created = await harness.result.current.createRequest(['user2']);
});
expect(created).toBe(false);
expect(harness.result.current.error).toBe('Only editors can request approval');
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(0);
expect(harness.result.current.isSubmittingRequest).toBe(false);
});
it('reports a network failure without hanging the submit flag', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderApprovals();
let created: boolean | undefined;
await act(async () => {
created = await harness.result.current.createRequest(['user2']);
});
expect(created).toBe(false);
expect(harness.result.current.error).toBe('Failed to create approval request');
expect(harness.result.current.isSubmittingRequest).toBe(false);
});
// KNOWN FRAGILITY, pinned rather than fixed. `createRequest` has no in-flight
// guard of its own, so a double-clicked "Request approval" button sends two
// POSTs. The route de-duplicates server side, which is why this has not
// surfaced; the hook must at least settle cleanly afterwards.
it('sends one post per click and still settles when clicked twice', async () => {
const harness = renderApprovals();
await act(async () => {
await Promise.all([
harness.result.current.createRequest(['user2']),
harness.result.current.createRequest(['user2']),
]);
});
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, 'POST')).toHaveLength(2);
expect(harness.result.current.isSubmittingRequest).toBe(false);
expect(harness.result.current.requests).toEqual(listedRequests);
});
});
describe('useApprovals deciding', () => {
it('posts the decision to the request and re-reads the list', async () => {
const harness = renderApprovals();
let decided: boolean | undefined;
await act(async () => {
decided = await harness.result.current.submitDecision('req1', 'APPROVED', 'Looks good');
});
const post = callsTo('/api/approvals/req1/decision', 'POST')[0];
expect(bodyOf(post)).toEqual({ decision: 'APPROVED', note: 'Looks good' });
expect((post[1] as { headers: Record<string, string> }).headers).toEqual({
'Content-Type': 'application/json',
});
expect(decided).toBe(true);
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(1);
});
it('rejects without a note when none was written', async () => {
const harness = renderApprovals();
await act(async () => {
await harness.result.current.submitDecision('req1', 'REJECTED');
});
expect(bodyOf(callsTo('/api/approvals/req1/decision', 'POST')[0])).toEqual({
decision: 'REJECTED',
});
});
it('surfaces the server error when the caller is not an approver', async () => {
fetchMock.mockResolvedValue(fail(403, { error: 'You are not an approver on this request' }));
const harness = renderApprovals();
let decided: boolean | undefined;
await act(async () => {
decided = await harness.result.current.submitDecision('req1', 'APPROVED');
});
expect(decided).toBe(false);
expect(harness.result.current.error).toBe('You are not an approver on this request');
expect(harness.result.current.isSubmittingDecision).toBe(false);
});
it('reports a network failure while deciding', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.submitDecision('req1', 'APPROVED');
});
expect(harness.result.current.error).toBe('Failed to submit approval decision');
expect(harness.result.current.isSubmittingDecision).toBe(false);
});
});
describe('useApprovals canceling', () => {
it('posts to the cancel endpoint with no body and re-reads the list', async () => {
const harness = renderApprovals();
let canceled: boolean | undefined;
await act(async () => {
canceled = await harness.result.current.cancelRequest('req1');
});
const post = callsTo('/api/approvals/req1/cancel', 'POST')[0];
expect(post[1]).toEqual({ method: 'POST' });
expect(canceled).toBe(true);
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(1);
});
it('surfaces the server error when the request cannot be canceled', async () => {
fetchMock.mockResolvedValue(fail(409, { error: 'Request is already resolved' }));
const harness = renderApprovals();
let canceled: boolean | undefined;
await act(async () => {
canceled = await harness.result.current.cancelRequest('req1');
});
expect(canceled).toBe(false);
expect(harness.result.current.error).toBe('Request is already resolved');
expect(harness.result.current.isCancelingRequest).toBe(false);
});
it('reports a network failure while canceling', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.cancelRequest('req1');
});
expect(harness.result.current.error).toBe('Failed to cancel approval request');
expect(harness.result.current.isCancelingRequest).toBe(false);
});
});
describe('useApprovals derived state', () => {
it('finds the one pending request among resolved ones', async () => {
listedRequests = [
makeRequest({ id: 'req-new', status: 'PENDING' }),
makeRequest({ id: 'req-old', status: 'APPROVED' }),
makeRequest({ id: 'req-older', status: 'CANCELED' }),
];
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.activePendingRequest?.id).toBe('req-new');
});
it('reports no pending request once everything is resolved', async () => {
listedRequests = [makeRequest({ id: 'req-old', status: 'REJECTED' })];
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.activePendingRequest).toBeNull();
expect(harness.result.current.myPendingDecision).toBeNull();
});
it('surfaces the current user own undecided slot', async () => {
listedRequests = [
makeRequest({
decisions: [
makeDecision({ id: 'dec-other', approverId: 'user2', status: 'PENDING' }),
makeDecision({ id: 'dec-mine', approverId: 'user1', status: 'PENDING' }),
],
}),
];
const harness = renderApprovals({ currentUserId: 'user1' });
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.myPendingDecision?.id).toBe('dec-mine');
});
it('hides the decide prompt once the user has already answered', async () => {
listedRequests = [
makeRequest({
decisions: [
makeDecision({ id: 'dec-mine', approverId: 'user1', status: 'APPROVED' }),
makeDecision({ id: 'dec-other', approverId: 'user2', status: 'PENDING' }),
],
}),
];
const harness = renderApprovals({ currentUserId: 'user1' });
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.myPendingDecision).toBeNull();
});
it('never offers a decision to an anonymous viewer', async () => {
const harness = renderApprovals({ currentUserId: null });
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.activePendingRequest?.id).toBe('req1');
expect(harness.result.current.myPendingDecision).toBeNull();
});
it('lets a caller clear the error banner by hand', async () => {
fetchMock.mockResolvedValue(fail(500, { error: 'Boom' }));
const harness = renderApprovals();
await act(async () => {
await harness.result.current.fetchRequests();
});
expect(harness.result.current.error).toBe('Boom');
act(() => harness.result.current.setError(''));
expect(harness.result.current.error).toBe('');
});
});
@@ -0,0 +1,650 @@
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);
});
});
@@ -0,0 +1,771 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useState } from 'react';
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { useVersionActions } from '@/components/video-page/hooks/use-version-actions';
import type { Comment, Version, VideoData } from '@/components/video-page/types';
const toastError = vi.fn();
const toastSuccess = vi.fn();
vi.mock('sonner', () => ({
toast: {
error: (...args: unknown[]) => toastError(...args),
success: (...args: unknown[]) => toastSuccess(...args),
},
}));
interface FakeTusOptions {
endpoint: string;
headers: Record<string, string>;
metadata: Record<string, string>;
onError: (error: Error) => void;
onProgress: (bytesUploaded: number, bytesTotal: number) => void;
onSuccess: () => void;
}
/** Every tus upload the hook constructed, and how the fake client behaves. */
const tusUploads: { fileName: string; options: FakeTusOptions }[] = [];
let tusFailure: string | null = null;
// tus-js-client talks to Bunny over the network. The fake keeps the callback
// contract (onProgress then onSuccess, or onError) and nothing else.
vi.mock('tus-js-client', () => ({
Upload: class FakeUpload {
private options: FakeTusOptions;
constructor(file: File, options: FakeTusOptions) {
this.options = options;
tusUploads.push({ fileName: file.name, options });
}
start() {
if (tusFailure) {
this.options.onError(new Error(tusFailure));
return;
}
this.options.onProgress(512, 1024);
this.options.onSuccess();
}
},
}));
const uploadVideoToR2 = vi.fn();
const cleanupPendingR2VideoUpload = vi.fn();
// The R2 client does presigning and multipart PUTs over XHR: a boundary, not a
// helper of this hook.
vi.mock('@/lib/client/r2-video-upload', () => ({
uploadVideoToR2: (...args: unknown[]) => uploadVideoToR2(...args),
cleanupPendingR2VideoUpload: (...args: unknown[]) => cleanupPendingR2VideoUpload(...args),
}));
type Params = Parameters<typeof useVersionActions>[0];
type HookParams = Omit<Params, 'setVideo' | 'activeVersionId' | 'setActiveVersionId'>;
const PROJECT_ID = 'proj1';
const VIDEO_ID = 'vid1';
const VERSIONS_URL = `/api/projects/${PROJECT_ID}/videos/${VIDEO_ID}/versions`;
const BUNNY_INIT_URL = `/api/projects/${PROJECT_ID}/videos/bunny-init`;
const DIRECT_URL = 'https://files.example.test/clip.mp4';
function makeVersion(overrides: Partial<Version> = {}): Version & { comments: Comment[] } {
return {
id: 'ver1',
versionNumber: 1,
versionLabel: null,
providerId: 'direct',
videoId: VIDEO_ID,
originalUrl: 'https://files.example.test/v1.mp4',
title: null,
thumbnailUrl: null,
duration: 600,
isActive: true,
_count: { comments: 0 },
comments: [],
...overrides,
};
}
function makeVideo(): VideoData {
return {
id: VIDEO_ID,
title: 'Cut 3',
description: null,
projectId: PROJECT_ID,
project: { name: 'Ad campaign', ownerId: 'user1' },
isAuthenticated: true,
currentUserId: 'user1',
currentUserName: 'Ada',
// ver1 is the one on screen; ver3 is the row the server has flagged active.
versions: [
makeVersion({ isActive: false }),
makeVersion({ id: 'ver2', versionNumber: 2, isActive: false }),
makeVersion({ id: 'ver3', versionNumber: 3, isActive: true }),
],
};
}
/** What the versions POST answers with on success. */
const createdVersion = {
id: 'ver-new',
versionNumber: 4,
versionLabel: null,
providerId: 'direct',
videoId: VIDEO_ID,
originalUrl: DIRECT_URL,
title: null,
thumbnailUrl: '/placeholder-video-thumbnail.png',
duration: null,
isActive: true,
_count: { comments: 0 },
};
function ok(payload: unknown) {
return { ok: true, status: 200, json: () => Promise.resolve(payload) };
}
function fail(status: number, payload: unknown = {}) {
return { ok: false, status, json: () => Promise.resolve(payload) };
}
let fetchMock: ReturnType<typeof vi.fn>;
function callsTo(url: string, method?: string) {
return fetchMock.mock.calls.filter(
(call) => call[0] === url && (call[1]?.method ?? undefined) === method
);
}
function bodyOf(call: unknown[]): unknown {
return JSON.parse((call[1] as { body: string }).body);
}
function useHarness(overrides: Partial<HookParams>) {
const [video, setVideo] = useState<VideoData | null>(makeVideo());
const [activeVersionId, setActiveVersionId] = useState<string | null>('ver1');
const actions = useVersionActions({
projectId: PROJECT_ID,
videoId: VIDEO_ID,
setVideo,
activeVersionId,
setActiveVersionId,
...overrides,
});
return { video, activeVersionId, actions };
}
type Harness = RenderHookResult<ReturnType<typeof useHarness>, Partial<HookParams>>;
function renderVersionActions(overrides: Partial<HookParams> = {}): Harness {
return renderHook((props: Partial<HookParams>) => useHarness(props), {
initialProps: overrides,
});
}
function versionIds(harness: Harness): string[] {
return (harness.result.current.video?.versions ?? []).map((v) => v.id);
}
function makeFile(name = 'my clip.mp4') {
return new File(['0123456789'], name, { type: 'video/mp4' });
}
beforeEach(() => {
tusUploads.length = 0;
tusFailure = null;
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://cdn.example.test');
fetchMock = vi.fn((url: string) => {
if (url === BUNNY_INIT_URL) {
return Promise.resolve(
ok({
data: {
videoId: 'bunny-vid',
libraryId: '1234',
signature: 'sig',
expirationTime: 1800000000,
uploadToken: 'upload-token',
},
})
);
}
if (url === VERSIONS_URL) {
return Promise.resolve(ok({ data: createdVersion }));
}
return Promise.resolve(ok({ data: {} }));
});
vi.stubGlobal('fetch', fetchMock);
uploadVideoToR2.mockResolvedValue({
proxyUrl: '/api/upload/video/proj1/clip.mp4',
objectKey: 'proj1/clip.mp4',
uploadToken: 'r2-upload-token',
reservationId: 'res1',
thumbnailObjectKey: 'proj1/clip.jpg',
thumbnailUrl: '/api/upload/image/proj1/clip.jpg',
duration: 42,
});
cleanupPendingR2VideoUpload.mockResolvedValue(undefined);
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
toastError.mockReset();
toastSuccess.mockReset();
uploadVideoToR2.mockReset();
cleanupPendingR2VideoUpload.mockReset();
});
describe('useVersionActions typing a URL', () => {
it('recognises a supported URL and clears any earlier complaint', () => {
const harness = renderVersionActions();
act(() => harness.result.current.actions.handleNewVersionUrlChange(DIRECT_URL));
expect(harness.result.current.actions.newVersionUrl).toBe(DIRECT_URL);
expect(harness.result.current.actions.newVersionSource).toEqual({
providerId: 'direct',
videoId: DIRECT_URL,
originalUrl: DIRECT_URL,
});
expect(harness.result.current.actions.newVersionUrlError).toBe('');
});
it('complains once the unrecognised URL is long enough to be a real attempt', () => {
const harness = renderVersionActions();
act(() =>
harness.result.current.actions.handleNewVersionUrlChange('https://example.test/not-a-video')
);
expect(harness.result.current.actions.newVersionSource).toBeNull();
expect(harness.result.current.actions.newVersionUrlError).toBe('Unsupported URL');
});
it('stays quiet while the field holds fewer than eleven characters', () => {
const harness = renderVersionActions();
act(() => harness.result.current.actions.handleNewVersionUrlChange('https://ex'));
expect(harness.result.current.actions.newVersionUrlError).toBe('');
expect(harness.result.current.actions.newVersionSource).toBeNull();
});
it('resets the source when the field is emptied again', () => {
const harness = renderVersionActions();
act(() => harness.result.current.actions.handleNewVersionUrlChange(DIRECT_URL));
act(() => harness.result.current.actions.handleNewVersionUrlChange(' '));
expect(harness.result.current.actions.newVersionSource).toBeNull();
expect(harness.result.current.actions.newVersionUrlError).toBe('');
});
});
describe('useVersionActions creating a version from a URL', () => {
async function createFromUrl(harness: Harness, url = DIRECT_URL) {
act(() => harness.result.current.actions.handleNewVersionUrlChange(url));
await act(async () => {
await harness.result.current.actions.handleCreateVersion();
});
}
it('posts the parsed source and the derived thumbnail to the versions route', async () => {
const harness = renderVersionActions();
await createFromUrl(harness);
const post = callsTo(VERSIONS_URL, 'POST')[0];
expect(bodyOf(post)).toEqual({
videoUrl: DIRECT_URL,
providerId: 'direct',
providerVideoId: DIRECT_URL,
uploadToken: null,
objectKey: null,
reservationId: null,
versionLabel: null,
thumbnailUrl: '/placeholder-video-thumbnail.png',
duration: null,
setActive: true,
});
});
it('puts the new version first and demotes every other one', async () => {
const harness = renderVersionActions();
await createFromUrl(harness);
expect(versionIds(harness)).toEqual(['ver-new', 'ver1', 'ver2', 'ver3']);
const versions = harness.result.current.video?.versions ?? [];
expect(versions.map((v) => v.isActive)).toEqual([true, false, false, false]);
expect(versions[0].comments).toEqual([]);
expect(harness.result.current.activeVersionId).toBe('ver-new');
});
it('closes the dialog and empties the form once the version exists', async () => {
const harness = renderVersionActions();
act(() => harness.result.current.actions.setShowVersionDialog(true));
act(() => harness.result.current.actions.setNewVersionLabel('Client cut'));
await createFromUrl(harness);
expect(harness.result.current.actions.showVersionDialog).toBe(false);
expect(harness.result.current.actions.newVersionUrl).toBe('');
expect(harness.result.current.actions.newVersionLabel).toBe('');
expect(harness.result.current.actions.newVersionSource).toBeNull();
expect(harness.result.current.actions.newVersionFile).toBeNull();
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
});
it('sends a trimmed label when the editor typed one', async () => {
const harness = renderVersionActions();
act(() => harness.result.current.actions.setNewVersionLabel(' Client cut '));
await createFromUrl(harness);
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toMatchObject({
versionLabel: 'Client cut',
});
});
it('does nothing at all without a project', async () => {
const harness = renderVersionActions({ projectId: undefined });
await createFromUrl(harness);
expect(fetchMock).not.toHaveBeenCalled();
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
});
it('refuses a URL no provider recognised', async () => {
const harness = renderVersionActions();
await createFromUrl(harness, 'https://example.test/not-a-video');
expect(callsTo(VERSIONS_URL, 'POST')).toHaveLength(0);
expect(toastError).toHaveBeenCalledWith('Invalid URL');
});
it('leaves the version list and the dialog untouched when the server refuses', async () => {
fetchMock.mockResolvedValue(fail(403, { error: 'Only editors can add versions' }));
const harness = renderVersionActions();
act(() => harness.result.current.actions.setShowVersionDialog(true));
await createFromUrl(harness);
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
expect(harness.result.current.activeVersionId).toBe('ver1');
expect(harness.result.current.actions.showVersionDialog).toBe(true);
expect(toastError).toHaveBeenCalledWith('Only editors can add versions');
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
});
it('falls back to a generic message when the failure body says nothing', async () => {
fetchMock.mockResolvedValue(fail(500, {}));
const harness = renderVersionActions();
await createFromUrl(harness);
expect(toastError).toHaveBeenCalledWith('Failed to create version');
});
it('reports a network failure without leaving the dialog spinning', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderVersionActions();
await createFromUrl(harness);
expect(toastError).toHaveBeenCalledWith('offline');
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
});
});
describe('useVersionActions uploading a file to Bunny', () => {
async function createFromFile(harness: Harness, file = makeFile()) {
act(() => {
harness.result.current.actions.setNewVersionMode('file');
harness.result.current.actions.setNewVersionFile(file);
});
await act(async () => {
await harness.result.current.actions.handleCreateVersion();
});
}
it('refuses the file tab when the host has direct uploads switched off', async () => {
const harness = renderVersionActions({ directUploadsEnabled: false });
await createFromFile(harness);
expect(toastError).toHaveBeenCalledWith('Direct uploads are disabled by this host');
expect(fetchMock).not.toHaveBeenCalled();
});
it('refuses to upload nothing', async () => {
const harness = renderVersionActions({ directUploadsEnabled: true });
act(() => harness.result.current.actions.setNewVersionMode('file'));
await act(async () => {
await harness.result.current.actions.handleCreateVersion();
});
expect(toastError).toHaveBeenCalledWith('No file selected');
expect(fetchMock).not.toHaveBeenCalled();
});
it('initialises the Bunny upload with the filename minus its extension', async () => {
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'my clip' });
expect(tusUploads[0].options.endpoint).toBe('https://video.bunnycdn.com/tusupload');
expect(tusUploads[0].options.headers).toEqual({
AuthorizationSignature: 'sig',
AuthorizationExpire: '1800000000',
VideoId: 'bunny-vid',
LibraryId: '1234',
});
expect(tusUploads[0].options.metadata).toEqual({ filetype: 'video/mp4', title: 'my clip' });
});
it('prefers the version label over the filename as the Bunny title', async () => {
const harness = renderVersionActions({ directUploadsEnabled: true });
act(() => harness.result.current.actions.setNewVersionLabel(' Client cut '));
await createFromFile(harness);
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'Client cut' });
});
it('registers the version against the Bunny embed and CDN thumbnail', async () => {
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toEqual({
videoUrl: 'https://iframe.mediadelivery.net/embed/1234/bunny-vid',
providerId: 'bunny',
providerVideoId: 'bunny-vid',
uploadToken: 'upload-token',
objectKey: null,
reservationId: null,
versionLabel: null,
thumbnailUrl: 'https://cdn.example.test/bunny-vid/thumbnail.jpg',
duration: null,
setActive: true,
});
});
it('sends no thumbnail when no CDN hostname is configured', async () => {
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', '');
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toMatchObject({ thumbnailUrl: null });
});
it('reports upload progress and then resets it', async () => {
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(harness.result.current.actions.newVersionUploadProgress).toBe(0);
expect(harness.result.current.actions.newVersionUploadStatus).toBe('');
});
it('surfaces a failed initialisation and never starts a tus upload', async () => {
fetchMock.mockResolvedValueOnce(fail(500, {}));
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(tusUploads).toHaveLength(0);
expect(toastError).toHaveBeenCalledWith('Failed to initialize upload');
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
});
// This is the rollback path: the bytes are already on Bunny when the versions
// route rejects, so the pending video has to be handed back.
it('deletes the pending Bunny video when the version cannot be registered', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === VERSIONS_URL) return Promise.resolve(fail(507, { error: 'Storage full' }));
return Promise.resolve(
ok({
data: {
videoId: 'bunny-vid',
libraryId: '1234',
signature: 'sig',
expirationTime: 1800000000,
uploadToken: 'upload-token',
},
})
);
});
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
const cleanup = callsTo(BUNNY_INIT_URL, 'DELETE')[0];
expect(bodyOf(cleanup)).toEqual({ videoId: 'bunny-vid', uploadToken: 'upload-token' });
expect(toastError).toHaveBeenCalledWith('Storage full');
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
});
it('does not delete anything after a version was created successfully', async () => {
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
});
// BUG, pinned rather than fixed. bunny-init has already created a video on
// Bunny by the time tus runs, but `pendingCleanup` is only assigned after
// uploadNewVersionFile returns. A tus failure therefore leaks that video:
// nothing ever calls the DELETE branch below it in the catch.
it('leaks the Bunny video when the tus upload itself fails', async () => {
tusFailure = 'connection reset';
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(toastError).toHaveBeenCalledWith('Upload failed: connection reset');
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
});
});
describe('useVersionActions uploading a file to R2', () => {
async function createFromFile(harness: Harness) {
act(() => {
harness.result.current.actions.setNewVersionMode('file');
harness.result.current.actions.setNewVersionFile(makeFile());
});
await act(async () => {
await harness.result.current.actions.handleCreateVersion();
});
}
it('registers the version against the proxy URL and object key', async () => {
const harness = renderVersionActions({
directUploadsEnabled: true,
directUploadProvider: 'r2',
});
await createFromFile(harness);
expect(uploadVideoToR2).toHaveBeenCalledWith(PROJECT_ID, expect.any(File), expect.anything());
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toEqual({
videoUrl: '/api/upload/video/proj1/clip.mp4',
providerId: 'r2',
providerVideoId: 'proj1/clip.mp4',
uploadToken: 'r2-upload-token',
objectKey: 'proj1/clip.mp4',
reservationId: 'res1',
versionLabel: null,
thumbnailUrl: '/api/upload/image/proj1/clip.jpg',
duration: 42,
setActive: true,
});
expect(tusUploads).toHaveLength(0);
});
it('falls back to the placeholder thumbnail when none was captured', async () => {
uploadVideoToR2.mockResolvedValue({
proxyUrl: '/api/upload/video/proj1/clip.mp4',
objectKey: 'proj1/clip.mp4',
uploadToken: 'r2-upload-token',
reservationId: null,
thumbnailObjectKey: null,
thumbnailUrl: null,
duration: null,
});
const harness = renderVersionActions({
directUploadsEnabled: true,
directUploadProvider: 'r2',
});
await createFromFile(harness);
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toMatchObject({
thumbnailUrl: '/placeholder-video-thumbnail.png',
});
});
// The rollback path for R2: the object is in the bucket before the version
// row exists, so a rejected POST has to release it and its reservation.
it('releases the uploaded object when the version cannot be registered', async () => {
fetchMock.mockResolvedValue(fail(500, { error: 'Database unavailable' }));
const harness = renderVersionActions({
directUploadsEnabled: true,
directUploadProvider: 'r2',
});
await createFromFile(harness);
expect(cleanupPendingR2VideoUpload).toHaveBeenCalledWith(PROJECT_ID, {
objectKey: 'proj1/clip.mp4',
uploadToken: 'r2-upload-token',
reservationId: 'res1',
thumbnailObjectKey: 'proj1/clip.jpg',
});
expect(toastError).toHaveBeenCalledWith('Database unavailable');
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
});
it('releases nothing when the upload itself never finished', async () => {
uploadVideoToR2.mockRejectedValue(new Error('Upload aborted'));
const harness = renderVersionActions({
directUploadsEnabled: true,
directUploadProvider: 'r2',
});
await createFromFile(harness);
expect(cleanupPendingR2VideoUpload).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('Upload aborted');
});
it('surfaces the upload progress the client reports', async () => {
let report: ((progress: number) => void) | undefined;
uploadVideoToR2.mockImplementation(
(_projectId: string, _file: File, options: { onProgress: (p: number) => void }) => {
report = options.onProgress;
return new Promise(() => {});
}
);
const harness = renderVersionActions({
directUploadsEnabled: true,
directUploadProvider: 'r2',
});
act(() => {
harness.result.current.actions.setNewVersionMode('file');
harness.result.current.actions.setNewVersionFile(makeFile());
});
act(() => {
void harness.result.current.actions.handleCreateVersion();
});
act(() => report?.(37));
expect(harness.result.current.actions.newVersionUploadProgress).toBe(37);
expect(harness.result.current.actions.newVersionUploadStatus).toBe('Uploading... 37%');
});
});
describe('useVersionActions deleting a version', () => {
async function deleteVersion(harness: Harness, versionId: string) {
act(() => {
harness.result.current.actions.setVersionToDelete(versionId);
harness.result.current.actions.setShowDeleteVersionDialog(true);
});
await act(async () => {
await harness.result.current.actions.handleDeleteVersion();
});
}
it('does nothing when no version was picked', async () => {
const harness = renderVersionActions();
await act(async () => {
await harness.result.current.actions.handleDeleteVersion();
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('does nothing without a project', async () => {
const harness = renderVersionActions({ projectId: undefined });
await deleteVersion(harness, 'ver2');
expect(fetchMock).not.toHaveBeenCalled();
});
it('deletes through the project-scoped route and drops the version', async () => {
const harness = renderVersionActions();
await deleteVersion(harness, 'ver2');
expect(callsTo(`${VERSIONS_URL}/ver2`, 'DELETE')).toHaveLength(1);
expect(versionIds(harness)).toEqual(['ver1', 'ver3']);
expect(harness.result.current.actions.showDeleteVersionDialog).toBe(false);
expect(toastSuccess).toHaveBeenCalledWith('Version deleted');
expect(harness.result.current.actions.isDeletingVersion).toBe(false);
});
it('leaves the selection alone when a version other than the open one goes', async () => {
const harness = renderVersionActions();
await deleteVersion(harness, 'ver2');
expect(harness.result.current.activeVersionId).toBe('ver1');
});
it('moves to the version flagged active when the open one is deleted', async () => {
const harness = renderVersionActions();
await deleteVersion(harness, 'ver1');
expect(versionIds(harness)).toEqual(['ver2', 'ver3']);
expect(harness.result.current.activeVersionId).toBe('ver3');
});
it('falls back to the first remaining version when none is flagged active', async () => {
const harness = renderVersionActions();
await deleteVersion(harness, 'ver3');
await deleteVersion(harness, 'ver1');
expect(versionIds(harness)).toEqual(['ver2']);
expect(harness.result.current.activeVersionId).toBe('ver2');
});
it('keeps the version when the server refuses to delete it', async () => {
fetchMock.mockResolvedValue(fail(403, { error: 'Cannot delete the only version' }));
const harness = renderVersionActions();
await deleteVersion(harness, 'ver2');
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
expect(harness.result.current.actions.showDeleteVersionDialog).toBe(true);
expect(toastError).toHaveBeenCalledWith('Cannot delete the only version');
expect(toastSuccess).not.toHaveBeenCalled();
expect(harness.result.current.actions.isDeletingVersion).toBe(false);
});
it('falls back to a generic message when the refusal body says nothing', async () => {
fetchMock.mockResolvedValue(fail(500, {}));
const harness = renderVersionActions();
await deleteVersion(harness, 'ver2');
expect(toastError).toHaveBeenCalledWith('Failed to delete version');
});
it('keeps the version when the request throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderVersionActions();
await deleteVersion(harness, 'ver2');
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
expect(toastError).toHaveBeenCalledWith('offline');
});
// Confirming twice must not remove a second, unrelated version: the second
// pass runs after versionToDelete has been cleared.
it('is a no-op the second time the confirm button is pressed', async () => {
const harness = renderVersionActions();
await deleteVersion(harness, 'ver2');
await act(async () => {
await harness.result.current.actions.handleDeleteVersion();
});
expect(callsTo(`${VERSIONS_URL}/ver2`, 'DELETE')).toHaveLength(1);
expect(versionIds(harness)).toEqual(['ver1', 'ver3']);
});
});
@@ -0,0 +1,783 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
import type { VideoAsset } from '@/components/video-page/types';
const toastError = vi.fn();
vi.mock('sonner', () => ({
toast: {
error: (...args: unknown[]) => toastError(...args),
success: vi.fn(),
},
}));
type Params = Parameters<typeof useVideoAssets>[0];
const VIDEO_ID = 'vid1';
/** The page size the hook hardcodes. */
const PAGE_SIZE = 40;
const FIRST_PAGE_URL = `/api/videos/${VIDEO_ID}/assets?limit=${PAGE_SIZE}&offset=0`;
const POLL_INTERVAL_MS = 10000;
function makeAsset(overrides: Partial<VideoAsset> = {}): VideoAsset {
return {
id: 'a1',
videoId: VIDEO_ID,
kind: 'IMAGE',
provider: 'R2_IMAGE',
displayName: 'Reference frame',
sourceUrl: '/api/upload/image/proj1/ref.png',
providerVideoId: null,
thumbnailUrl: null,
uploadedByUserId: 'user1',
uploadedByGuestName: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
uploadedByUser: { id: 'user1', name: 'Ada', image: null },
canDelete: true,
...overrides,
};
}
interface ListInit {
ok?: boolean;
status?: number;
assets?: VideoAsset[];
hasMore?: boolean;
nextOffset?: number | null;
etag?: string | null;
error?: string;
}
function listResponse({
ok = true,
status = 200,
assets = [],
hasMore = false,
nextOffset = null,
etag = '"assets-1"',
error,
}: ListInit = {}) {
return {
ok,
status,
headers: { get: (name: string) => (name.toLowerCase() === 'etag' ? etag : null) },
json: () =>
Promise.resolve(
ok
? { data: { assets, pagination: { limit: PAGE_SIZE, offset: 0, hasMore, nextOffset } } }
: { error }
),
};
}
function jsonResponse(ok: boolean, payload: unknown, status = ok ? 200 : 400) {
return {
ok,
status,
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: string[];
/** What the assets list endpoint answers with; reassign to change it mid-test. */
let listed: ReturnType<typeof listResponse>;
function callsTo(url: string, method?: string) {
return fetchMock.mock.calls.filter(
(call) => call[0] === url && (call[1]?.method ?? undefined) === method
);
}
function headersOf(call: unknown[]): Record<string, string> {
return ((call[1] as { headers?: Record<string, string> }).headers ?? {}) as Record<
string,
string
>;
}
function bodyOf(call: unknown[]): unknown {
return JSON.parse((call[1] as { body: string }).body);
}
type Harness = RenderHookResult<ReturnType<typeof useVideoAssets>, Params>;
async function renderAssets(overrides: Partial<Params> = {}): Promise<Harness> {
const harness = renderHook((props: Params) => useVideoAssets(props), {
initialProps: {
videoId: VIDEO_ID,
isAuthenticated: true,
canUploadAssets: true,
canDownloadAssets: true,
...overrides,
},
});
// The mount-time read has to settle before any assertion.
await act(async () => {
await Promise.resolve();
});
return harness;
}
function assetIds(harness: Harness): string[] {
return harness.result.current.assets.map((asset) => asset.id);
}
beforeEach(() => {
clicked = [];
listed = listResponse({ assets: [makeAsset()] });
fetchMock = vi.fn((url: string) => {
if (typeof url === 'string' && url.startsWith(`/api/videos/${VIDEO_ID}/assets?`)) {
return Promise.resolve(listed);
}
return Promise.resolve(jsonResponse(true, { data: makeAsset({ id: 'a-server' }) }));
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement
) {
clicked.push(this.getAttribute('href') ?? '');
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
toastError.mockReset();
});
describe('useVideoAssets reading the list', () => {
it('reads the first page on mount, bypassing the cache', async () => {
const harness = await renderAssets();
const call = callsTo(FIRST_PAGE_URL)[0];
expect(call[1]).toMatchObject({ cache: 'no-store' });
expect(assetIds(harness)).toEqual(['a1']);
expect(harness.result.current.isLoadingAssets).toBe(false);
});
it('sends no conditional header before an etag is known', async () => {
await renderAssets();
expect(headersOf(callsTo(FIRST_PAGE_URL)[0])).toEqual({});
});
it('sends the stored etag back on the next conditional read', async () => {
const harness = await renderAssets();
await act(async () => {
await harness.result.current.fetchAssets({ useEtag: true });
});
expect(headersOf(callsTo(FIRST_PAGE_URL)[1])).toEqual({ 'If-None-Match': '"assets-1"' });
});
it('omits the etag when the caller wants a fresh read', async () => {
const harness = await renderAssets();
await act(async () => {
await harness.result.current.fetchAssets();
});
expect(headersOf(callsTo(FIRST_PAGE_URL)[1])).toEqual({});
});
it('leaves the list on screen alone when the server answers 304', async () => {
const harness = await renderAssets();
listed = listResponse({ ok: false, status: 304, assets: [] });
await act(async () => {
await harness.result.current.fetchAssets({ useEtag: true });
});
expect(assetIds(harness)).toEqual(['a1']);
expect(toastError).not.toHaveBeenCalled();
});
it('records how many more assets there are', async () => {
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
const harness = await renderAssets();
expect(harness.result.current.hasMoreAssets).toBe(true);
});
it('shows the message the server sent when the read is refused', async () => {
listed = listResponse({ ok: false, status: 403, error: 'Access denied' });
const harness = await renderAssets();
expect(toastError).toHaveBeenCalledWith('Access denied');
expect(assetIds(harness)).toEqual([]);
expect(harness.result.current.isLoadingAssets).toBe(false);
});
it('falls back to a generic message when a 500 says nothing', async () => {
listed = listResponse({ ok: false, status: 500 });
await renderAssets();
expect(toastError).toHaveBeenCalledWith('Failed to fetch assets');
});
it('keeps the list when a later read fails', async () => {
const harness = await renderAssets();
listed = listResponse({ ok: false, status: 500 });
await act(async () => {
await harness.result.current.fetchAssets();
});
expect(assetIds(harness)).toEqual(['a1']);
});
it('says nothing at all on a silent read that fails', async () => {
const harness = await renderAssets();
listed = listResponse({ ok: false, status: 500, error: 'Access denied' });
await act(async () => {
await harness.result.current.fetchAssets({ silent: true });
});
expect(toastError).not.toHaveBeenCalled();
});
it('leaves the visible spinner alone during a silent read', async () => {
const pending = deferred<unknown>();
const harness = await renderAssets();
fetchMock.mockReturnValue(pending.promise);
let read: Promise<void> | undefined;
act(() => {
read = harness.result.current.fetchAssets({ silent: true });
});
expect(harness.result.current.isLoadingAssets).toBe(false);
await act(async () => {
pending.resolve(listed);
await read;
});
});
it('reports a network failure', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = await renderAssets();
expect(toastError).toHaveBeenCalledWith('Failed to fetch assets');
expect(harness.result.current.isLoadingAssets).toBe(false);
});
});
describe('useVideoAssets loading more', () => {
it('does nothing when the first page was the whole list', async () => {
const harness = await renderAssets();
await act(async () => {
await harness.result.current.loadMoreAssets();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('asks for the offset the server named and appends the page', async () => {
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
const harness = await renderAssets();
listed = listResponse({ assets: [makeAsset({ id: 'a2' })], hasMore: false });
await act(async () => {
await harness.result.current.loadMoreAssets();
});
expect(callsTo(`/api/videos/${VIDEO_ID}/assets?limit=${PAGE_SIZE}&offset=40`)).toHaveLength(1);
expect(assetIds(harness)).toEqual(['a1', 'a2']);
expect(harness.result.current.hasMoreAssets).toBe(false);
expect(harness.result.current.isLoadingMoreAssets).toBe(false);
});
// The background poll can deliver a row the next page also contains.
it('drops a row the visible page already holds', async () => {
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
const harness = await renderAssets();
listed = listResponse({ assets: [makeAsset(), makeAsset({ id: 'a2' })] });
await act(async () => {
await harness.result.current.loadMoreAssets();
});
expect(assetIds(harness)).toEqual(['a1', 'a2']);
});
it('shows the message the server sent when the next page fails', async () => {
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
const harness = await renderAssets();
listed = listResponse({ ok: false, status: 500, error: 'Too many assets' });
await act(async () => {
await harness.result.current.loadMoreAssets();
});
expect(toastError).toHaveBeenCalledWith('Too many assets');
expect(assetIds(harness)).toEqual(['a1']);
expect(harness.result.current.isLoadingMoreAssets).toBe(false);
});
it('reports a network failure while paging', async () => {
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
const harness = await renderAssets();
fetchMock.mockRejectedValue(new Error('offline'));
await act(async () => {
await harness.result.current.loadMoreAssets();
});
expect(toastError).toHaveBeenCalledWith('Failed to load more assets');
expect(harness.result.current.isLoadingMoreAssets).toBe(false);
});
});
describe('useVideoAssets background polling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
async function renderWithTimers(overrides: Partial<Params> = {}) {
const harness = renderHook((props: Params) => useVideoAssets(props), {
initialProps: {
videoId: VIDEO_ID,
isAuthenticated: true,
canUploadAssets: true,
canDownloadAssets: true,
...overrides,
},
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
return harness;
}
it('re-reads the list silently every 10 seconds', async () => {
await renderWithTimers();
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1);
});
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(2);
expect(headersOf(callsTo(FIRST_PAGE_URL)[1])).toEqual({ 'If-None-Match': '"assets-1"' });
});
it('skips the poll while the tab is hidden', async () => {
await renderWithTimers();
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
document.dispatchEvent(new Event('visibilitychange'));
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
});
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
visibility.mockReturnValue('visible');
document.dispatchEvent(new Event('visibilitychange'));
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
});
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(2);
});
it('skips the poll while a write is still in flight', async () => {
const harness = await renderWithTimers();
const pending = deferred<unknown>();
fetchMock.mockImplementation((url: string) =>
url.startsWith(`/api/videos/${VIDEO_ID}/assets?`) ? Promise.resolve(listed) : pending.promise
);
let created: Promise<unknown> | undefined;
act(() => {
created = harness.result.current.createAsset({
provider: 'R2_IMAGE',
sourceUrl: '/api/upload/image/proj1/new.png',
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
});
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
await act(async () => {
pending.resolve(jsonResponse(true, { data: makeAsset({ id: 'a-server' }) }));
await created;
});
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
});
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(2);
});
it('stops polling after unmount', async () => {
const harness = await renderWithTimers();
harness.unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
});
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
});
});
describe('useVideoAssets creating', () => {
const payload = {
provider: 'R2_IMAGE' as const,
displayName: 'New reference',
sourceUrl: '/api/upload/image/proj1/new.png',
};
it('refuses a viewer who cannot upload, without reaching the network', async () => {
const harness = await renderAssets({ canUploadAssets: false });
fetchMock.mockClear();
let created: VideoAsset | null | undefined;
await act(async () => {
created = await harness.result.current.createAsset(payload);
});
expect(created).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('You do not have permission to upload assets');
});
it('posts the payload to the assets route and prepends the saved row', async () => {
const harness = await renderAssets();
let created: VideoAsset | null | undefined;
await act(async () => {
created = await harness.result.current.createAsset(payload);
});
const post = callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0];
expect(bodyOf(post)).toEqual(payload);
expect(created?.id).toBe('a-server');
expect(assetIds(harness)).toEqual(['a-server', 'a1']);
expect(harness.result.current.isCreatingAsset).toBe(false);
});
it('signs a guest upload with the trimmed guest name', async () => {
const harness = await renderAssets({ isAuthenticated: false, guestName: ' Kerem ' });
await act(async () => {
await harness.result.current.createAsset(payload);
});
expect(bodyOf(callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0])).toEqual({
...payload,
guestName: 'Kerem',
});
});
it('falls back to "Guest" when the viewer never gave a name', async () => {
const harness = await renderAssets({ isAuthenticated: false, guestName: ' ' });
await act(async () => {
await harness.result.current.createAsset(payload);
});
expect(bodyOf(callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0])).toMatchObject({
guestName: 'Guest',
});
});
it('sends no guest name for a signed-in uploader', async () => {
const harness = await renderAssets({ isAuthenticated: true, guestName: 'Kerem' });
await act(async () => {
await harness.result.current.createAsset(payload);
});
expect(bodyOf(callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0])).toEqual(payload);
});
it('leaves the list untouched when the server refuses the upload', async () => {
fetchMock.mockImplementation((url: string) =>
url.startsWith(`/api/videos/${VIDEO_ID}/assets?`)
? Promise.resolve(listed)
: Promise.resolve(jsonResponse(false, { error: 'Asset limit reached' }, 403))
);
const harness = await renderAssets();
let created: VideoAsset | null | undefined;
await act(async () => {
created = await harness.result.current.createAsset(payload);
});
expect(created).toBeNull();
expect(assetIds(harness)).toEqual(['a1']);
expect(toastError).toHaveBeenCalledWith('Asset limit reached');
expect(harness.result.current.isCreatingAsset).toBe(false);
});
// A 2xx with an empty body would otherwise push `undefined` into the list.
it('treats a success with no row in it as a failure', async () => {
fetchMock.mockImplementation((url: string) =>
url.startsWith(`/api/videos/${VIDEO_ID}/assets?`)
? Promise.resolve(listed)
: Promise.resolve(jsonResponse(true, {}))
);
const harness = await renderAssets();
let created: VideoAsset | null | undefined;
await act(async () => {
created = await harness.result.current.createAsset(payload);
});
expect(created).toBeNull();
expect(assetIds(harness)).toEqual(['a1']);
expect(toastError).toHaveBeenCalledWith('Failed to create asset');
});
it('reports a network failure without hanging the busy flag', async () => {
const harness = await renderAssets();
fetchMock.mockRejectedValue(new Error('offline'));
let created: VideoAsset | null | undefined;
await act(async () => {
created = await harness.result.current.createAsset(payload);
});
expect(created).toBeNull();
expect(toastError).toHaveBeenCalledWith('Failed to create asset');
expect(harness.result.current.isCreatingAsset).toBe(false);
});
});
describe('useVideoAssets deleting', () => {
it('deletes through the asset route and drops the row', async () => {
listed = listResponse({ assets: [makeAsset(), makeAsset({ id: 'a2' })] });
const harness = await renderAssets();
let deleted: boolean | undefined;
await act(async () => {
deleted = await harness.result.current.deleteAsset('a1');
});
expect(callsTo(`/api/videos/${VIDEO_ID}/assets/a1`, 'DELETE')).toHaveLength(1);
expect(deleted).toBe(true);
expect(assetIds(harness)).toEqual(['a2']);
expect(harness.result.current.activeDeleteAssetId).toBeNull();
});
it('marks which row is being deleted while the request runs', async () => {
const harness = await renderAssets();
const pending = deferred<unknown>();
fetchMock.mockReturnValue(pending.promise);
let removal: Promise<boolean> | undefined;
act(() => {
removal = harness.result.current.deleteAsset('a1');
});
expect(harness.result.current.activeDeleteAssetId).toBe('a1');
await act(async () => {
pending.resolve(jsonResponse(true, {}));
await removal;
});
expect(harness.result.current.activeDeleteAssetId).toBeNull();
});
it('keeps the row when the server refuses the delete', async () => {
const harness = await renderAssets();
fetchMock.mockResolvedValue(
jsonResponse(false, { error: 'Only the uploader can delete' }, 403)
);
let deleted: boolean | undefined;
await act(async () => {
deleted = await harness.result.current.deleteAsset('a1');
});
expect(deleted).toBe(false);
expect(assetIds(harness)).toEqual(['a1']);
expect(toastError).toHaveBeenCalledWith('Only the uploader can delete');
expect(harness.result.current.activeDeleteAssetId).toBeNull();
});
it('keeps the row when the delete throws', async () => {
const harness = await renderAssets();
fetchMock.mockRejectedValue(new Error('offline'));
let deleted: boolean | undefined;
await act(async () => {
deleted = await harness.result.current.deleteAsset('a1');
});
expect(deleted).toBe(false);
expect(assetIds(harness)).toEqual(['a1']);
expect(toastError).toHaveBeenCalledWith('Failed to delete asset');
});
it('removes both rows when two deletes are fired back to back', async () => {
listed = listResponse({ assets: [makeAsset(), makeAsset({ id: 'a2' })] });
const harness = await renderAssets();
await act(async () => {
await Promise.all([
harness.result.current.deleteAsset('a1'),
harness.result.current.deleteAsset('a2'),
]);
});
expect(assetIds(harness)).toEqual([]);
expect(harness.result.current.activeDeleteAssetId).toBeNull();
});
});
describe('useVideoAssets downloading', () => {
const downloadUrl = `/api/videos/${VIDEO_ID}/assets/a1/download`;
it('refuses a guest who cannot download', async () => {
const harness = await renderAssets({ canDownloadAssets: false });
await act(async () => {
await harness.result.current.downloadAsset(makeAsset());
});
expect(clicked).toEqual([]);
expect(toastError).toHaveBeenCalledWith('Asset downloads require an authenticated account');
});
it('refuses a YouTube asset, which has no file behind it', async () => {
const harness = await renderAssets();
await act(async () => {
await harness.result.current.downloadAsset(makeAsset({ provider: 'YOUTUBE' }));
});
expect(clicked).toEqual([]);
expect(toastError).toHaveBeenCalledWith('YouTube assets cannot be downloaded');
});
it('navigates straight to the download route for an R2 asset', async () => {
const harness = await renderAssets();
fetchMock.mockClear();
await act(async () => {
await harness.result.current.downloadAsset(makeAsset());
});
expect(fetchMock).not.toHaveBeenCalled();
expect(clicked).toEqual([downloadUrl]);
expect(document.querySelectorAll('a')).toHaveLength(0);
});
it('asks Bunny to prepare the file before navigating', async () => {
const harness = await renderAssets();
await act(async () => {
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }), 'original');
});
expect(callsTo(`${downloadUrl}?source=original&prepare=1`)[0][1]).toEqual({
cache: 'no-store',
});
expect(clicked).toEqual([`${downloadUrl}?source=original`]);
});
it('defaults a Bunny asset to the compressed file', async () => {
const harness = await renderAssets();
await act(async () => {
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }));
});
expect(clicked).toEqual([`${downloadUrl}?source=compressed`]);
});
it('shows the message Bunny sent and navigates nowhere when preparing fails', async () => {
const harness = await renderAssets();
fetchMock.mockResolvedValue(jsonResponse(false, { error: 'Still encoding' }, 409));
await act(async () => {
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }));
});
expect(clicked).toEqual([]);
expect(toastError).toHaveBeenCalledWith('Still encoding');
expect(harness.result.current.activeDownloadAssetId).toBeNull();
});
it('reports a network failure while preparing', async () => {
const harness = await renderAssets();
fetchMock.mockRejectedValue(new Error('offline'));
await act(async () => {
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }));
});
expect(toastError).toHaveBeenCalledWith('Failed to start download');
expect(harness.result.current.activeDownloadAssetId).toBeNull();
});
});
describe('useVideoAssets guest upload tokens', () => {
it('needs no token for a signed-in uploader', async () => {
const harness = await renderAssets({ isAuthenticated: true });
fetchMock.mockClear();
let token: string | null | undefined;
await act(async () => {
token = await harness.result.current.getGuestUploadToken('image');
});
expect(token).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('asks the watch route for a token scoped to the intent', async () => {
const harness = await renderAssets({ isAuthenticated: false });
fetchMock.mockResolvedValue(jsonResponse(true, { data: { token: 'guest-token' } }));
let token: string | null | undefined;
await act(async () => {
token = await harness.result.current.getGuestUploadToken('audio');
});
const post = callsTo(`/api/watch/${VIDEO_ID}/upload-token`, 'POST')[0];
expect(bodyOf(post)).toEqual({ intent: 'audio' });
expect(token).toBe('guest-token');
});
it('throws the message the server sent when the grant is refused', async () => {
const harness = await renderAssets({ isAuthenticated: false });
fetchMock.mockResolvedValue(jsonResponse(false, { error: 'Guest uploads are disabled' }, 403));
await expect(harness.result.current.getGuestUploadToken('image')).rejects.toThrow(
'Guest uploads are disabled'
);
});
it('throws when a 200 comes back with no token in it', async () => {
const harness = await renderAssets({ isAuthenticated: false });
fetchMock.mockResolvedValue(jsonResponse(true, { data: {} }));
await expect(harness.result.current.getGuestUploadToken('image')).rejects.toThrow(
'Failed to prepare upload'
);
});
});
@@ -0,0 +1,522 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { useVideoPageData } from '@/components/video-page/hooks/use-video-page-data';
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
type Params = Parameters<typeof useVideoPageData>[0];
const VIDEO_ID = 'vid1';
const PROJECT_ID = 'proj1';
const DASHBOARD_URL = `/api/projects/${PROJECT_ID}/videos/${VIDEO_ID}?includeComments=false`;
const WATCH_URL = `/api/watch/${VIDEO_ID}`;
const TAGS_URL = `/api/projects/${PROJECT_ID}/tags?videoId=${VIDEO_ID}`;
/** The page size the hook hardcodes when walking the comment list. */
const COMMENT_PAGE_SIZE = 200;
function commentsUrl(versionId: string, offset: number) {
return `/api/versions/${versionId}/comments?includeResolved=true&limit=${COMMENT_PAGE_SIZE}&offset=${offset}`;
}
function makeVersion(overrides: Partial<Version> = {}): Version {
return {
id: 'ver1',
versionNumber: 1,
versionLabel: null,
providerId: 'bunny',
videoId: VIDEO_ID,
originalUrl: 'https://cdn.example.test/a.mp4',
title: null,
thumbnailUrl: null,
duration: 600,
isActive: true,
_count: { comments: 0 },
...overrides,
};
}
function makeComment(overrides: Partial<Comment> = {}): Comment {
return {
id: 'c1',
content: 'Colour is off',
timestamp: 5,
timestampEnd: null,
voiceUrl: null,
voiceDuration: null,
imageUrl: null,
annotationData: null,
isResolved: false,
createdAt: '2026-01-01T00:00:00.000Z',
author: { id: 'user1', name: 'Ada', image: null },
guestName: null,
canEdit: true,
canDelete: true,
tag: null,
replies: [],
...overrides,
};
}
const TAGS: CommentTag[] = [
{ id: 'tag-audio', name: 'Audio', color: '#f00' },
{ id: 'tag-colour', name: 'Colour', color: '#0f0' },
];
interface Responder {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
headers: { get: (name: string) => string | null };
}
function respond({
ok = true,
status = 200,
payload = {} as unknown,
text = '',
etag = null as string | null,
}): Responder {
return {
ok,
status,
json: () => Promise.resolve(payload),
text: () => Promise.resolve(text),
headers: { get: (name: string) => (name.toLowerCase() === 'etag' ? etag : null) },
};
}
/** The three endpoints the hook touches, each reassignable per test. */
let videoResponse: Responder;
let commentPages: Responder[];
let tagsResponse: Responder;
let fetchMock: ReturnType<typeof vi.fn>;
function commentsPayload(comments: Comment[], hasMore = false) {
return { data: { comments, hasMore } };
}
function callsMatching(predicate: (url: string) => boolean) {
return fetchMock.mock.calls.filter((call) => predicate(call[0] as string));
}
function headersOf(call: unknown[]): Record<string, string> {
return ((call[1] as { headers?: Record<string, string> }).headers ?? {}) as Record<
string,
string
>;
}
type Harness = RenderHookResult<ReturnType<typeof useVideoPageData>, Params>;
/** Mount, then let the video load, the comment load and the tag load chain. */
async function renderPage(overrides: Partial<Params> = {}): Promise<Harness> {
const harness = renderHook((props: Params) => useVideoPageData(props), {
initialProps: {
mode: 'dashboard',
videoId: VIDEO_ID,
propProjectId: PROJECT_ID,
...overrides,
} as Params,
});
await settle();
return harness;
}
async function settle(rounds = 6) {
for (let i = 0; i < rounds; i++) {
await act(async () => {
await Promise.resolve();
});
}
}
function activeComments(harness: Harness, versionId = 'ver1'): Comment[] {
return harness.result.current.video?.versions.find((v) => v.id === versionId)?.comments ?? [];
}
beforeEach(() => {
videoResponse = respond({
payload: {
data: {
id: VIDEO_ID,
title: 'Cut 3',
description: null,
projectId: PROJECT_ID,
project: { name: 'Ad campaign', ownerId: 'user1' },
isAuthenticated: true,
currentUserId: 'user1',
currentUserName: 'Ada',
versions: [makeVersion(), makeVersion({ id: 'ver2', versionNumber: 2, isActive: false })],
},
},
});
commentPages = [respond({ payload: commentsPayload([makeComment()]), etag: 'W/"c-1"' })];
tagsResponse = respond({ payload: { data: TAGS } });
fetchMock = vi.fn((url: string) => {
if (url === DASHBOARD_URL || url === WATCH_URL) return Promise.resolve(videoResponse);
if (url.includes('/comments?')) {
// Serve the page the offset asks for, so a test can reassign commentPages
// and replay the same walk.
const offset = Number(new URLSearchParams(url.split('?')[1]).get('offset') ?? 0);
const index = Math.min(offset / COMMENT_PAGE_SIZE, commentPages.length - 1);
return Promise.resolve(commentPages[index]);
}
if (url.includes('/tags')) return Promise.resolve(tagsResponse);
return Promise.resolve(respond({ payload: { data: {} } }));
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('useVideoPageData loading the video', () => {
it('reads the project-scoped route without comments in dashboard mode', async () => {
const harness = await renderPage();
expect(callsMatching((url) => url === DASHBOARD_URL)[0][1]).toEqual({ cache: 'no-store' });
expect(harness.result.current.video?.title).toBe('Cut 3');
expect(harness.result.current.loading).toBe(false);
expect(harness.result.current.error).toBe('');
});
it('reads the public watch route in watch mode', async () => {
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
expect(callsMatching((url) => url === WATCH_URL)).toHaveLength(1);
expect(callsMatching((url) => url === DASHBOARD_URL)).toHaveLength(0);
expect(harness.result.current.video?.id).toBe(VIDEO_ID);
});
it('gives every version a comments array even when the route omits one', async () => {
const harness = await renderPage();
expect(harness.result.current.video?.versions.map((v) => Array.isArray(v.comments))).toEqual([
true,
true,
]);
});
it('opens the version the server flagged active', async () => {
videoResponse = respond({
payload: {
data: {
id: VIDEO_ID,
projectId: PROJECT_ID,
versions: [
makeVersion({ id: 'ver1', isActive: false }),
makeVersion({ id: 'ver2', isActive: true }),
],
},
},
});
const harness = await renderPage();
expect(harness.result.current.activeVersionId).toBe('ver2');
});
it('falls back to the first version when none is flagged', async () => {
videoResponse = respond({
payload: {
data: {
id: VIDEO_ID,
projectId: PROJECT_ID,
versions: [
makeVersion({ id: 'ver1', isActive: false }),
makeVersion({ id: 'ver2', isActive: false }),
],
},
},
});
const harness = await renderPage();
expect(harness.result.current.activeVersionId).toBe('ver1');
});
it('opens nothing for a video with no versions yet', async () => {
videoResponse = respond({
payload: { data: { id: VIDEO_ID, projectId: PROJECT_ID, versions: [] } },
});
const harness = await renderPage();
expect(harness.result.current.activeVersionId).toBeNull();
expect(callsMatching((url) => url.includes('/comments?'))).toHaveLength(0);
});
it('shows the status and body of a dashboard failure, which an editor can act on', async () => {
videoResponse = respond({ ok: false, status: 403, text: 'Forbidden' });
const harness = await renderPage();
expect(harness.result.current.error).toBe('Failed to load video: 403 Forbidden');
expect(harness.result.current.video).toBeNull();
expect(harness.result.current.loading).toBe(false);
});
// A share-link viewer must not be told whether the video exists.
it('says nothing specific about a watch failure', async () => {
videoResponse = respond({ ok: false, status: 403, text: 'Forbidden' });
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
expect(harness.result.current.error).toBe('Video not found or access denied');
});
it('reports a network failure and stops loading', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = await renderPage();
expect(harness.result.current.error).toBe('Failed to load video');
expect(harness.result.current.loading).toBe(false);
expect(console.error).toHaveBeenCalledWith(
'Error fetching video:',
expect.objectContaining({ message: 'offline' })
);
});
it('re-reads the video when the mode switches', async () => {
const harness = await renderPage();
expect(callsMatching((url) => url === DASHBOARD_URL)).toHaveLength(1);
harness.rerender({ mode: 'watch', videoId: VIDEO_ID, propProjectId: PROJECT_ID });
await settle();
expect(callsMatching((url) => url === WATCH_URL)).toHaveLength(1);
});
});
describe('useVideoPageData loading comments', () => {
it('reads the active version comments, resolved ones included', async () => {
const harness = await renderPage();
expect(callsMatching((url) => url === commentsUrl('ver1', 0))).toHaveLength(1);
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
});
it('walks every page until the server says there are no more', async () => {
commentPages = [
respond({ payload: commentsPayload([makeComment({ id: 'c1' })], true), etag: 'W/"c-1"' }),
respond({ payload: commentsPayload([makeComment({ id: 'c2' })], true) }),
respond({ payload: commentsPayload([makeComment({ id: 'c3' })], false) }),
];
const harness = await renderPage();
expect(callsMatching((url) => url.includes('/comments?')).map((call) => call[0])).toEqual([
commentsUrl('ver1', 0),
commentsUrl('ver1', 200),
commentsUrl('ver1', 400),
]);
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1', 'c2', 'c3']);
});
it('counts replies towards the badge on the version', async () => {
commentPages = [
respond({
payload: commentsPayload([
makeComment({
id: 'c1',
replies: [
{
id: 'r1',
content: 'Agreed',
timestamp: 5,
timestampEnd: null,
voiceUrl: null,
voiceDuration: null,
imageUrl: null,
annotationData: null,
createdAt: '2026-01-01T00:01:00.000Z',
author: { id: 'user2', name: 'Linus', image: null },
guestName: null,
canEdit: false,
canDelete: false,
tag: null,
},
],
}),
makeComment({ id: 'c2' }),
]),
}),
];
const harness = await renderPage();
const version = harness.result.current.video?.versions.find((v) => v.id === 'ver1');
expect(version?._count).toEqual({ comments: 3 });
});
it('touches only the version it was asked about', async () => {
const harness = await renderPage();
commentPages = [respond({ payload: commentsPayload([makeComment({ id: 'c-other' })]) })];
await act(async () => {
await harness.result.current.fetchVersionComments('ver2', false);
});
expect(activeComments(harness, 'ver1').map((c) => c.id)).toEqual(['c1']);
expect(activeComments(harness, 'ver2').map((c) => c.id)).toEqual(['c-other']);
});
it('sends no conditional header before an etag is known', async () => {
await renderPage();
expect(headersOf(callsMatching((url) => url === commentsUrl('ver1', 0))[0])).toEqual({});
});
it('sends the stored etag back on the next conditional read', async () => {
const harness = await renderPage();
await act(async () => {
await harness.result.current.fetchVersionComments('ver1', true);
});
const reads = callsMatching((url) => url === commentsUrl('ver1', 0));
expect(headersOf(reads[1])).toEqual({ 'If-None-Match': 'W/"c-1"' });
});
it('omits the etag when the caller wants the list unconditionally', async () => {
const harness = await renderPage();
await act(async () => {
await harness.result.current.fetchVersionComments('ver1', false);
});
const reads = callsMatching((url) => url === commentsUrl('ver1', 0));
expect(headersOf(reads[1])).toEqual({});
});
it('never sends a conditional header on a follow-up page', async () => {
commentPages = [
respond({ payload: commentsPayload([makeComment()], true), etag: 'W/"c-1"' }),
respond({ payload: commentsPayload([makeComment({ id: 'c2' })], false) }),
];
const harness = await renderPage();
commentPages = [
respond({ payload: commentsPayload([makeComment()], true), etag: 'W/"c-2"' }),
respond({ payload: commentsPayload([makeComment({ id: 'c2' })], false) }),
];
await act(async () => {
await harness.result.current.fetchVersionComments('ver1', true);
});
expect(headersOf(callsMatching((url) => url === commentsUrl('ver1', 200))[1])).toEqual({});
});
// A real 304 and a real 403 carry no comment list. These fakes do, so that
// the assertion proves the status is what stops the write rather than the
// body happening to be empty.
it('leaves the comments alone when the server answers 304', async () => {
const harness = await renderPage();
commentPages = [
respond({ ok: false, status: 304, payload: commentsPayload([makeComment({ id: 'c-304' })]) }),
];
await act(async () => {
await harness.result.current.fetchVersionComments('ver1', true);
});
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
});
it('leaves the comments alone when the read is refused', async () => {
const harness = await renderPage();
commentPages = [
respond({ ok: false, status: 403, payload: commentsPayload([makeComment({ id: 'c-403' })]) }),
];
await act(async () => {
await harness.result.current.fetchVersionComments('ver1', false);
});
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
});
it('leaves the comments alone when the body carries no list', async () => {
const harness = await renderPage();
commentPages = [respond({ payload: { data: {} } })];
await act(async () => {
await harness.result.current.fetchVersionComments('ver1', false);
});
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
});
it('re-reads comments when the caller switches version', async () => {
const harness = await renderPage();
act(() => harness.result.current.setActiveVersionId('ver2'));
await settle();
expect(callsMatching((url) => url === commentsUrl('ver2', 0))).toHaveLength(1);
});
});
describe('useVideoPageData loading tags', () => {
it('reads the project tags scoped to this video', async () => {
await renderPage();
expect(callsMatching((url) => url === TAGS_URL).length).toBeGreaterThan(0);
});
it('preselects the first tag for the composer', async () => {
const harness = await renderPage();
expect(harness.result.current.availableTags).toEqual(TAGS);
expect(harness.result.current.selectedTagId).toBe('tag-audio');
});
it('does not override a tag the editor already picked', async () => {
const harness = await renderPage();
act(() => harness.result.current.setSelectedTagId('tag-colour'));
await settle();
expect(harness.result.current.selectedTagId).toBe('tag-colour');
});
// KNOWN INEFFICIENCY, pinned rather than fixed. selectedTagId is in the
// effect's dependency list purely so the auto-select can read it, so the
// moment the first tag is selected the whole effect re-runs and the tag list
// is fetched a second time on every page load.
it('reads the tag list twice because selecting a tag re-runs the effect', async () => {
await renderPage();
expect(callsMatching((url) => url === TAGS_URL)).toHaveLength(2);
});
it('selects nothing when the project has no tags', async () => {
tagsResponse = respond({ payload: { data: [] } });
const harness = await renderPage();
expect(harness.result.current.availableTags).toEqual([]);
expect(harness.result.current.selectedTagId).toBeNull();
expect(callsMatching((url) => url === TAGS_URL)).toHaveLength(1);
});
it('swallows a refused tag read rather than blocking the page', async () => {
tagsResponse = respond({ ok: false, status: 403 });
const harness = await renderPage();
expect(harness.result.current.availableTags).toEqual([]);
expect(harness.result.current.error).toBe('');
expect(harness.result.current.video?.title).toBe('Cut 3');
});
it('takes the project from the loaded video in watch mode', async () => {
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
expect(harness.result.current.projectId).toBe(PROJECT_ID);
expect(callsMatching((url) => url === TAGS_URL).length).toBeGreaterThan(0);
});
it('asks for no tags while the video is still unknown', async () => {
videoResponse = respond({ ok: false, status: 404, text: 'Not found' });
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
expect(harness.result.current.projectId).toBeUndefined();
expect(callsMatching((url) => url.includes('/tags'))).toHaveLength(0);
});
});
@@ -0,0 +1,583 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useVideoPlayer } from '@/components/video-page/hooks/use-video-player';
import type { PlayerAdapter, Version } from '@/components/video-page/types';
type Params = Parameters<typeof useVideoPlayer>[0];
/** Measured from the video element's metadata, so every seek clamps to it. */
const DURATION = 60;
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2];
/** The timeline the tests drag over: 100px wide, starting at the viewport edge. */
const TIMELINE_LEFT = 0;
const TIMELINE_WIDTH = 100;
/** The hook builds the player inside a 100ms timeout. */
const PLAYER_INIT_DELAY_MS = 100;
type FrameMetadata = { mediaTime: number; presentedFrames: number };
type FrameCallback = (now: number, metadata: FrameMetadata) => void;
/**
* A stand-in for the HTMLVideoElement the R2 branch of the hook drives. jsdom
* has no media pipeline at all: it never fires 'play' or 'loadedmetadata', and
* `duration` is a read-only NaN. This object exposes only the surface the hook
* touches, and lets a test fire the media events itself so the timing is
* explicit rather than accidental.
*/
function createVideoStub() {
const listeners = new Map<string, Set<() => void>>();
let frameCallback: FrameCallback | null = null;
let nextFrameCallbackId = 1;
const video = {
currentTime: 0,
duration: DURATION,
paused: true,
muted: false,
playbackRate: 1,
seeking: false,
videoWidth: 1920,
videoHeight: 1080,
readyState: 2,
src: '',
play: vi.fn(() => {
video.paused = false;
return Promise.resolve();
}),
pause: vi.fn(() => {
video.paused = true;
}),
load: vi.fn(),
removeAttribute: vi.fn(),
addEventListener: (type: string, handler: () => void) => {
const forType = listeners.get(type) ?? new Set<() => void>();
forType.add(handler);
listeners.set(type, forType);
},
removeEventListener: (type: string, handler: () => void) => {
listeners.get(type)?.delete(handler);
},
requestVideoFrameCallback: vi.fn((callback: FrameCallback) => {
frameCallback = callback;
return nextFrameCallbackId++;
}),
cancelVideoFrameCallback: vi.fn(() => {
frameCallback = null;
}),
/** Deliver a media event to whatever the hook has subscribed. */
fire: (type: string) => {
for (const handler of [...(listeners.get(type) ?? [])]) handler();
},
/** Deliver one presented-frame sample to the frame-rate tracker. */
emitFrame: (metadata: FrameMetadata) => {
const callback = frameCallback;
frameCallback = null;
callback?.(0, metadata);
},
};
return video;
}
type VideoStub = ReturnType<typeof createVideoStub>;
function makeVersion(): Version {
return {
id: 'ver1',
versionNumber: 1,
versionLabel: null,
providerId: 'r2',
videoId: 'vid1',
originalUrl: '/api/upload/video/abc.mp4',
title: null,
thumbnailUrl: null,
// Left unset so the duration under test is the one measured from the
// element, which is what a real page ends up using.
duration: null,
isActive: true,
_count: { comments: 0 },
};
}
function makeTimeline(): HTMLDivElement {
const timeline = document.createElement('div');
// jsdom does no layout, so every rect is zero unless we supply one.
timeline.getBoundingClientRect = () =>
({ left: TIMELINE_LEFT, width: TIMELINE_WIDTH }) as DOMRect;
document.body.appendChild(timeline);
return timeline;
}
function renderPlayer() {
const video = createVideoStub();
const timeline = makeTimeline();
const readout = document.createElement('div');
const playerRef: { current: PlayerAdapter | null } = { current: null };
const params: Params = {
activeVersion: makeVersion(),
activeVersionId: 'ver1',
activeProviderId: 'r2',
embedUrl: '/api/upload/video/abc.mp4',
canInitializePlayer: true,
iframeRef: { current: null },
videoRef: { current: video as unknown as HTMLVideoElement },
bunnyViewportRef: { current: null },
timelineRef: { current: timeline },
progressRef: { current: document.createElement('div') },
playheadRef: { current: document.createElement('div') },
scrubReadoutRef: { current: readout },
hlsRef: { current: null },
playerRef,
formatTime: (seconds: number) => `${Math.floor(seconds)}s`,
formatBunnyQualityLabel: () => 'auto',
speedOptions: SPEED_OPTIONS,
scheduleWatchProgressSaveRef: { current: vi.fn() },
setViewingAnnotation: vi.fn(),
};
const rendered = renderHook(() => useVideoPlayer(params));
act(() => {
vi.advanceTimersByTime(PLAYER_INIT_DELAY_MS);
});
// Without metadata the hook has no duration, so nothing would clamp.
act(() => {
video.fire('loadedmetadata');
});
return { ...rendered, video, timeline, readout };
}
/** Put the player into the playing state the way the media element would. */
function startPlayback(video: VideoStub) {
act(() => {
video.paused = false;
video.fire('play');
});
}
function stopPlayback(video: VideoStub) {
act(() => {
video.paused = true;
video.fire('pause');
});
}
/**
* Two presented-frame samples one second apart is what the hook needs to derive
* a rate; the first sample only establishes a baseline.
*/
function measureFrameRate(video: VideoStub, fps: number) {
act(() => {
video.emitFrame({ mediaTime: 0, presentedFrames: 0 });
});
act(() => {
video.emitFrame({ mediaTime: 1, presentedFrames: fps });
});
}
function pressKey(
code: string,
options: { shiftKey?: boolean; target?: EventTarget } = {}
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
code,
shiftKey: options.shiftKey ?? false,
bubbles: true,
cancelable: true,
});
act(() => {
(options.target ?? window).dispatchEvent(event);
});
return event;
}
function mouseEventAt(clientX: number) {
return { clientX } as React.MouseEvent<HTMLDivElement>;
}
beforeEach(() => {
vi.useFakeTimers();
// The hook injects the YouTube iframe API before the first <script> on the
// page. Next always renders one; jsdom renders none, and the hook would
// dereference undefined.
document.head.appendChild(document.createElement('script'));
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.onYouTubeIframeAPIReady = undefined;
document.head.innerHTML = '';
document.body.innerHTML = '';
});
describe('useVideoPlayer seeking', () => {
it('takes its duration from the loaded metadata', () => {
const { result } = renderPlayer();
expect(result.current.isReady).toBe(true);
expect(result.current.videoDuration).toBe(DURATION);
});
it('clamps a backwards skip at the start of the video', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(3));
act(() => result.current.handleSkip(-5));
expect(result.current.currentTime).toBe(0);
expect(video.currentTime).toBe(0);
});
it('clamps a forwards skip at the end of the video', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(58));
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBe(DURATION);
expect(video.currentTime).toBe(DURATION);
});
it('seeks by the requested amount away from the ends', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(20));
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBe(25);
act(() => result.current.handleSkip(-5));
expect(result.current.currentTime).toBe(20);
expect(video.currentTime).toBe(20);
});
it('leaves a paused video paused after a seek, and a playing one playing', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSkip(5));
expect(video.pause).toHaveBeenCalled();
expect(video.play).not.toHaveBeenCalled();
startPlayback(video);
act(() => result.current.handleSkip(5));
expect(video.play).toHaveBeenCalled();
});
});
describe('useVideoPlayer frame stepping', () => {
it('derives the frame rate from presented-frame samples', () => {
const { result, video } = renderPlayer();
startPlayback(video);
expect(result.current.frameStepLabel).toBe('1s');
measureFrameRate(video, 25);
expect(result.current.frameStepSeconds).toBe(0.04);
expect(result.current.frameStepLabel).toBe('1f');
});
it('ignores samples taken across a seek', () => {
// Frames presented either side of a seek come from two different points in
// the timeline, so their ratio is not a frame rate.
const { result, video } = renderPlayer();
startPlayback(video);
video.seeking = true;
measureFrameRate(video, 25);
expect(result.current.frameStepLabel).toBe('1s');
});
it('moves exactly one frame per step once a rate is known', () => {
const { result, video } = renderPlayer();
startPlayback(video);
measureFrameRate(video, 25);
stopPlayback(video);
act(() => result.current.handleFrameModeToggle());
act(() => result.current.handleSeekToTimestamp(10));
// A 5-second skip request collapses to a single 1/25s frame.
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBeCloseTo(10.04, 10);
expect(video.currentTime).toBeCloseTo(10.04, 10);
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBeCloseTo(10.08, 10);
act(() => result.current.handleSkip(-5));
expect(result.current.currentTime).toBeCloseTo(10.04, 10);
});
it('steps a whole second while no frame rate has been measured', () => {
const { result } = renderPlayer();
act(() => result.current.handleFrameModeToggle());
act(() => result.current.handleSeekToTimestamp(10));
act(() => result.current.handleSkip(5));
expect(result.current.frameStepLabel).toBe('1s');
expect(result.current.currentTime).toBe(11);
});
it('skips the full requested amount while frame mode is off', () => {
const { result, video } = renderPlayer();
startPlayback(video);
measureFrameRate(video, 25);
act(() => result.current.handleSeekToTimestamp(10));
act(() => result.current.handleSkip(5));
expect(result.current.isFrameMode).toBe(false);
expect(result.current.currentTime).toBe(15);
});
});
describe('useVideoPlayer scrubbing', () => {
it('seeks to the fraction of the duration the pointer landed on', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(TIMELINE_WIDTH / 2)));
expect(result.current.isDragging).toBe(true);
expect(result.current.currentTime).toBe(DURATION / 2);
// The drag previews live, so the element is seeked before release.
expect(video.currentTime).toBe(DURATION / 2);
});
it('clamps a drag dragged off either end of the timeline', () => {
const { result } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(-500)));
expect(result.current.currentTime).toBe(0);
act(() => result.current.handleTimelineMouseMove(mouseEventAt(5000)));
expect(result.current.currentTime).toBe(DURATION);
});
it('tracks the pointer even when it leaves the timeline', () => {
const { result } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(10)));
act(() => {
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 75 }));
});
expect(result.current.currentTime).toBe(45);
});
it('commits the final position to the video element on release', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(10)));
act(() => result.current.handleTimelineMouseMove(mouseEventAt(90)));
act(() => result.current.handleTimelineMouseUp());
expect(result.current.isDragging).toBe(false);
expect(result.current.currentTime).toBe(54);
expect(video.currentTime).toBe(54);
});
it('freezes playback for the length of the drag and resumes it after', () => {
const { result, video } = renderPlayer();
startPlayback(video);
video.play.mockClear();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
expect(video.pause).toHaveBeenCalled();
expect(video.play).not.toHaveBeenCalled();
act(() => result.current.handleTimelineMouseUp());
expect(video.play).toHaveBeenCalled();
});
it('leaves a paused video paused after a drag', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
act(() => result.current.handleTimelineMouseUp());
expect(video.play).not.toHaveBeenCalled();
});
it('shows the frame number under the cursor while dragging', () => {
const { result, video, readout } = renderPlayer();
startPlayback(video);
measureFrameRate(video, 25);
stopPlayback(video);
act(() => result.current.handleTimelineMouseDown(mouseEventAt(TIMELINE_WIDTH / 2)));
// Halfway through a 60s clip at 25fps is second 30, frame 750.
expect(readout.textContent).toBe('30s · f750');
expect(result.current.showScrubReadout).toBe(true);
});
});
describe('useVideoPlayer keyboard shortcuts', () => {
it('starts and stops playback on space', () => {
const { video } = renderPlayer();
const first = pressKey('Space');
expect(video.play).toHaveBeenCalledTimes(1);
// Otherwise the page scrolls under the player.
expect(first.defaultPrevented).toBe(true);
startPlayback(video);
pressKey('Space');
expect(video.pause).toHaveBeenCalledTimes(1);
});
it('treats K the same as space', () => {
const { video } = renderPlayer();
pressKey('KeyK');
expect(video.play).toHaveBeenCalledTimes(1);
});
it('skips five seconds with the left and right arrows', () => {
const { result } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(20));
pressKey('ArrowRight');
expect(result.current.currentTime).toBe(25);
pressKey('ArrowLeft');
expect(result.current.currentTime).toBe(20);
});
it('jumps ten seconds with J and L, clamped to the media', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(20));
pressKey('KeyL');
expect(result.current.currentTime).toBe(30);
expect(video.currentTime).toBe(30);
pressKey('KeyJ');
expect(result.current.currentTime).toBe(20);
act(() => result.current.handleSeekToTimestamp(5));
pressKey('KeyJ');
expect(result.current.currentTime).toBe(0);
act(() => result.current.handleSeekToTimestamp(55));
pressKey('KeyL');
expect(result.current.currentTime).toBe(DURATION);
});
it('toggles mute on the element with M', () => {
const { result, video } = renderPlayer();
pressKey('KeyM');
expect(video.muted).toBe(true);
expect(result.current.isMuted).toBe(true);
pressKey('KeyM');
expect(video.muted).toBe(false);
expect(result.current.isMuted).toBe(false);
});
it('steps the speed ladder with the up and down arrows, stopping at the ends', () => {
const { result, video } = renderPlayer();
pressKey('ArrowUp');
expect(result.current.playbackSpeed).toBe(1.5);
expect(video.playbackRate).toBe(1.5);
pressKey('ArrowUp');
expect(result.current.playbackSpeed).toBe(2);
// 2x is the top of the ladder: the shortcut must not wrap around.
pressKey('ArrowUp');
expect(result.current.playbackSpeed).toBe(2);
pressKey('ArrowDown');
expect(result.current.playbackSpeed).toBe(1.5);
expect(video.playbackRate).toBe(1.5);
});
it('steps the speed ladder with shifted comma and period', () => {
const { result } = renderPlayer();
pressKey('Period', { shiftKey: true });
expect(result.current.playbackSpeed).toBe(1.5);
pressKey('Comma', { shiftKey: true });
expect(result.current.playbackSpeed).toBe(1);
});
it('leaves an unshifted comma alone so it can still be typed', () => {
const { result } = renderPlayer();
const event = pressKey('Comma');
expect(result.current.playbackSpeed).toBe(1);
expect(event.defaultPrevented).toBe(false);
});
it('requests fullscreen with F', async () => {
const { result } = renderPlayer();
const requestFullscreen = vi.fn().mockResolvedValue(undefined);
document.documentElement.requestFullscreen = requestFullscreen;
pressKey('KeyF');
await act(async () => {});
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(result.current.isFullscreenMode).toBe(true);
// Fullscreen is for watching, so the comments pane gets out of the way.
expect(result.current.showComments).toBe(false);
});
it('ignores a shortcut typed into a text field', () => {
const { result, video } = renderPlayer();
const input = document.createElement('input');
document.body.appendChild(input);
act(() => result.current.handleSeekToTimestamp(20));
const space = pressKey('Space', { target: input });
pressKey('ArrowRight', { target: input });
expect(video.play).not.toHaveBeenCalled();
expect(result.current.currentTime).toBe(20);
// Nothing was claimed, so the keystroke still reaches the field.
expect(space.defaultPrevented).toBe(false);
});
it('ignores a shortcut typed into a rich text editor', () => {
const { video } = renderPlayer();
const editor = document.createElement('div');
editor.contentEditable = 'true';
// jsdom does not derive isContentEditable from the attribute.
Object.defineProperty(editor, 'isContentEditable', { value: true });
document.body.appendChild(editor);
pressKey('Space', { target: editor });
expect(video.play).not.toHaveBeenCalled();
});
it('ignores every shortcut while a dialog is open', () => {
const { result, video } = renderPlayer();
const dialog = document.createElement('div');
dialog.setAttribute('data-slot', 'dialog-content');
document.body.appendChild(dialog);
act(() => result.current.handleSeekToTimestamp(20));
pressKey('Space');
pressKey('ArrowRight');
pressKey('KeyM');
expect(video.play).not.toHaveBeenCalled();
expect(result.current.currentTime).toBe(20);
expect(video.muted).toBe(false);
});
});