mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
784 lines
25 KiB
TypeScript
784 lines
25 KiB
TypeScript
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'
|
|
);
|
|
});
|
|
});
|