test: add unit, API, component and end-to-end test suites

The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
This commit is contained in:
yusufipk
2026-07-26 11:17:26 +07:00
parent 52b2c8d2a9
commit 1d099c68f2
101 changed files with 27625 additions and 122 deletions
@@ -0,0 +1,859 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useState } from 'react';
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
import type { Comment, CommentTag, 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),
},
}));
type Params = Parameters<typeof useCommentActions>[0];
const ACTIVE_VERSION = 'ver1';
const TAGS: CommentTag[] = [
{ id: 'tag-audio', name: 'Audio', color: '#f00' },
{ id: 'tag-colour', name: 'Colour', color: '#0f0' },
];
function makeComment(overrides: Partial<Comment> = {}): Comment {
return {
id: 'c1',
content: 'Existing note',
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: TAGS[0],
replies: [],
...overrides,
};
}
function makeVideo(): VideoData {
return {
id: 'vid1',
title: 'Cut 3',
description: null,
projectId: 'proj1',
project: { name: 'Ad campaign', ownerId: 'user1' },
isAuthenticated: true,
currentUserId: 'user1',
currentUserName: 'Ada',
versions: [
{
id: ACTIVE_VERSION,
versionNumber: 1,
versionLabel: null,
providerId: 'bunny',
videoId: 'vid1',
originalUrl: 'https://cdn.example.com/a.mp4',
title: null,
thumbnailUrl: null,
duration: 600,
isActive: true,
_count: { comments: 2 },
comments: [
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', content: 'Already handled', isResolved: true, replies: [] }),
],
},
{
id: 'ver2',
versionNumber: 2,
versionLabel: null,
providerId: 'bunny',
videoId: 'vid1',
originalUrl: 'https://cdn.example.com/b.mp4',
title: null,
thumbnailUrl: null,
duration: 600,
isActive: false,
_count: { comments: 1 },
comments: [makeComment({ id: 'other', content: 'On another version' })],
},
],
};
}
function ok(payload: unknown) {
return { ok: true, json: () => Promise.resolve(payload) };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
let fetchMock: ReturnType<typeof vi.fn>;
let serverComment: Comment;
let stableDeps: Pick<
Params,
| 'setSelectedTagId'
| 'setAnnotationStrokes'
| 'setIsAnnotating'
| 'setViewingAnnotation'
| 'fetchVersionComments'
| 'fetchAssets'
>;
function useHarness(overrides: Partial<Params>) {
const [video, setVideo] = useState<VideoData | null>(makeVideo());
const activeVersion = video?.versions.find((v) => v.id === ACTIVE_VERSION);
const actions = useCommentActions({
videoId: 'vid1',
setVideo,
activeVersionId: ACTIVE_VERSION,
activeVersion,
currentTime: 12,
isGuest: false,
normalizedGuestName: '',
currentUserName: 'Ada',
canResolveComments: true,
availableTags: TAGS,
selectedTagId: null,
annotationStrokes: null,
isAnnotating: false,
annotationCanvasRef: { current: null },
editAnnotationCanvasRef: { current: null },
...stableDeps,
...overrides,
});
return { video, actions };
}
type Harness = RenderHookResult<ReturnType<typeof useHarness>, Partial<Params>>;
function renderActions(overrides: Partial<Params> = {}): Harness {
return renderHook((props: Partial<Params>) => useHarness(props), { initialProps: overrides });
}
function comments(harness: Harness): Comment[] {
const version = harness.result.current.video?.versions.find((v) => v.id === ACTIVE_VERSION);
return version?.comments ?? [];
}
function commentIds(harness: Harness): string[] {
return comments(harness).map((c) => c.id);
}
function findComment(harness: Harness, id: string): Comment | undefined {
return comments(harness).find((c) => c.id === id);
}
function otherVersionComments(harness: Harness): Comment[] {
return harness.result.current.video?.versions.find((v) => v.id === 'ver2')?.comments ?? [];
}
function bodyOf(call: unknown[]): Record<string, unknown> {
return JSON.parse((call[1] as { body: string }).body);
}
function callsTo(url: string, method?: string) {
return fetchMock.mock.calls.filter(
(call) => call[0] === url && (method === undefined || call[1]?.method === method)
);
}
beforeEach(() => {
serverComment = makeComment({ id: 'c-server', content: 'Colour is off', timestamp: 12 });
stableDeps = {
setSelectedTagId: vi.fn(),
setAnnotationStrokes: vi.fn(),
setIsAnnotating: vi.fn(),
setViewingAnnotation: vi.fn(),
fetchVersionComments: vi.fn().mockResolvedValue(undefined),
fetchAssets: vi.fn().mockResolvedValue(undefined),
};
fetchMock = vi.fn((url: string) => {
if (url === '/api/upload/image') {
return Promise.resolve(ok({ data: { url: 'https://cdn.example.com/note.png' } }));
}
if (url === '/api/upload/audio') {
return Promise.resolve(ok({ data: { url: 'https://cdn.example.com/note.webm' } }));
}
if (url === '/api/watch/vid1/upload-token') {
return Promise.resolve(ok({ data: { token: 'guest-token' } }));
}
if (url === `/api/versions/${ACTIVE_VERSION}/comments`) {
return Promise.resolve(ok({ data: serverComment }));
}
return Promise.resolve(ok({ data: {} }));
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
toastError.mockReset();
toastSuccess.mockReset();
});
describe('useCommentActions adding a comment', () => {
it('shows the comment before the server answers, then swaps in the saved row', async () => {
const pendingRequest = deferred<unknown>();
fetchMock.mockReturnValue(pendingRequest.promise);
const harness = renderActions();
act(() => harness.result.current.actions.setCommentText('Colour is off'));
let submitted: Promise<void> | undefined;
act(() => {
submitted = harness.result.current.actions.handleAddComment();
});
expect(commentIds(harness)).toHaveLength(3);
const optimistic = comments(harness)[2];
expect(optimistic.id).toMatch(/^temp-/);
expect(optimistic.content).toBe('Colour is off');
expect(optimistic.timestamp).toBe(12);
expect(optimistic.isResolved).toBe(false);
expect(optimistic.author).toEqual({ id: 'current-user', name: 'Ada', image: null });
expect(harness.result.current.actions.commentText).toBe('');
expect(harness.result.current.actions.isSubmittingComment).toBe(true);
await act(async () => {
pendingRequest.resolve(ok({ data: serverComment }));
await submitted;
});
expect(commentIds(harness)).toEqual(['c1', 'c2', 'c-server']);
expect(harness.result.current.actions.isSubmittingComment).toBe(false);
});
it('posts the text, timestamp and tag to the active version', async () => {
const harness = renderActions({ selectedTagId: 'tag-colour' });
act(() => harness.result.current.actions.setCommentText('Colour is off'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
const call = callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST')[0];
expect(bodyOf(call)).toEqual({
content: 'Colour is off',
timestamp: 12,
tagId: 'tag-colour',
});
});
it('rolls the comment back out of the list when the server rejects it', async () => {
fetchMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const harness = renderActions();
act(() => harness.result.current.actions.setCommentText('Colour is off'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
expect(commentIds(harness)).toEqual(['c1', 'c2']);
expect(toastError).toHaveBeenCalledWith('Failed to add comment');
expect(harness.result.current.actions.isSubmittingComment).toBe(false);
});
it('rolls the comment back out of the list when the request throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderActions();
act(() => harness.result.current.actions.setCommentText('Colour is off'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
expect(commentIds(harness)).toEqual(['c1', 'c2']);
expect(toastError).toHaveBeenCalledWith('Failed to add comment');
});
it('leaves other versions untouched on both success and rollback', async () => {
fetchMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const harness = renderActions();
act(() => harness.result.current.actions.setCommentText('Colour is off'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
expect(otherVersionComments(harness).map((c) => c.id)).toEqual(['other']);
});
it('refuses to post an empty or whitespace-only comment', async () => {
const harness = renderActions();
act(() => harness.result.current.actions.setCommentText(' '));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
expect(callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST')).toHaveLength(0);
expect(commentIds(harness)).toEqual(['c1', 'c2']);
});
it('does nothing while no version is loaded', async () => {
const harness = renderActions({ activeVersion: undefined });
act(() => harness.result.current.actions.setCommentText('Colour is off'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('resets the tag picker to the first tag rather than to none', async () => {
const harness = renderActions({ selectedTagId: 'tag-colour' });
act(() => harness.result.current.actions.setCommentText('Colour is off'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
expect(stableDeps.setSelectedTagId).toHaveBeenCalledWith('tag-audio');
});
it('identifies a guest by name instead of by author', async () => {
const harness = renderActions({ isGuest: true, normalizedGuestName: 'Kerem' });
act(() => harness.result.current.actions.setCommentText('Nice'));
let submitted: Promise<void> | undefined;
act(() => {
submitted = harness.result.current.actions.handleAddComment();
});
const optimistic = comments(harness)[2];
expect(optimistic.author).toBeNull();
expect(optimistic.guestName).toBe('Kerem');
await act(async () => {
await submitted;
});
const call = callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST')[0];
expect(bodyOf(call).guestName).toBe('Kerem');
});
it('sends a range comment with both ends', async () => {
const harness = renderActions();
// First toggle opens the range at the current time, second closes it.
act(() => harness.result.current.actions.toggleCommentRangeSelection());
harness.rerender({ currentTime: 30 });
act(() => harness.result.current.actions.toggleCommentRangeSelection());
expect(harness.result.current.actions.commentRangeStart).toBe(12);
expect(harness.result.current.actions.commentRangeEnd).toBe(30);
act(() => harness.result.current.actions.setCommentText('Fix this stretch'));
await act(async () => {
await harness.result.current.actions.handleAddComment();
});
const call = callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST')[0];
expect(bodyOf(call)).toMatchObject({ timestamp: 12, timestampEnd: 30 });
expect(harness.result.current.actions.commentRangeStart).toBeNull();
expect(harness.result.current.actions.commentRangeEnd).toBeNull();
});
it('orders a backwards range selection low to high', () => {
const harness = renderActions({ currentTime: 30 });
act(() => harness.result.current.actions.toggleCommentRangeSelection());
harness.rerender({ currentTime: 10 });
act(() => harness.result.current.actions.toggleCommentRangeSelection());
expect(harness.result.current.actions.commentRangeStart).toBe(10);
expect(harness.result.current.actions.commentRangeEnd).toBe(30);
});
it('restarts the range when toggled a third time', () => {
const harness = renderActions();
act(() => harness.result.current.actions.toggleCommentRangeSelection());
harness.rerender({ currentTime: 30 });
act(() => harness.result.current.actions.toggleCommentRangeSelection());
harness.rerender({ currentTime: 44 });
act(() => harness.result.current.actions.toggleCommentRangeSelection());
expect(harness.result.current.actions.commentRangeStart).toBe(44);
expect(harness.result.current.actions.commentRangeEnd).toBeNull();
});
});
describe('useCommentActions replying', () => {
const serverReply = {
id: 'r-server',
content: 'On it',
timestamp: 12,
timestampEnd: null,
voiceUrl: null,
voiceDuration: null,
imageUrl: null,
annotationData: null,
createdAt: '2026-01-02T00:00:00.000Z',
author: { id: 'user1', name: 'Ada', image: null },
guestName: null,
canEdit: true,
canDelete: true,
tag: null,
};
it('nests the optimistic reply under its parent, and nowhere else', async () => {
const pendingRequest = deferred<unknown>();
fetchMock.mockReturnValue(pendingRequest.promise);
const harness = renderActions();
act(() => harness.result.current.actions.setReplyText('On it'));
let submitted: Promise<void> | undefined;
act(() => {
submitted = harness.result.current.actions.handleReplyComment('c1');
});
expect(findComment(harness, 'c1')?.replies.map((r) => r.id)).toEqual([
'r1',
expect.stringMatching(/^temp-reply-/),
]);
expect(findComment(harness, 'c2')?.replies).toEqual([]);
expect(harness.result.current.actions.replyText).toBe('');
expect(harness.result.current.actions.replyingTo).toBeNull();
expect(harness.result.current.actions.isSubmittingReply).toBe(true);
await act(async () => {
pendingRequest.resolve(ok({ data: serverReply }));
await submitted;
});
expect(findComment(harness, 'c1')?.replies.map((r) => r.id)).toEqual(['r1', 'r-server']);
});
it('posts the reply with its parent id', async () => {
fetchMock.mockImplementation(() => Promise.resolve(ok({ data: serverReply })));
const harness = renderActions();
act(() => harness.result.current.actions.setReplyText('On it'));
await act(async () => {
await harness.result.current.actions.handleReplyComment('c1');
});
const call = callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST')[0];
expect(bodyOf(call)).toEqual({ content: 'On it', timestamp: 12, parentId: 'c1' });
});
it('removes only the failed reply and keeps the parent comment', async () => {
fetchMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const harness = renderActions();
act(() => harness.result.current.actions.setReplyText('On it'));
await act(async () => {
await harness.result.current.actions.handleReplyComment('c1');
});
expect(commentIds(harness)).toEqual(['c1', 'c2']);
expect(findComment(harness, 'c1')?.replies.map((r) => r.id)).toEqual(['r1']);
expect(toastError).toHaveBeenCalledWith('Failed to add reply');
});
it('removes the failed reply when the request throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderActions();
act(() => harness.result.current.actions.setReplyText('On it'));
await act(async () => {
await harness.result.current.actions.handleReplyComment('c1');
});
expect(findComment(harness, 'c1')?.replies.map((r) => r.id)).toEqual(['r1']);
expect(toastError).toHaveBeenCalledWith('Failed to add reply');
});
it('refuses to post an empty reply', async () => {
const harness = renderActions();
act(() => harness.result.current.actions.setReplyText(' '));
await act(async () => {
await harness.result.current.actions.handleReplyComment('c1');
});
expect(fetchMock).not.toHaveBeenCalled();
expect(findComment(harness, 'c1')?.replies).toHaveLength(1);
});
it('keeps its own range selection separate from the comment composer', () => {
const harness = renderActions();
act(() => harness.result.current.actions.toggleReplyRangeSelection());
harness.rerender({ currentTime: 30 });
act(() => harness.result.current.actions.toggleReplyRangeSelection());
expect(harness.result.current.actions.replyRangeStart).toBe(12);
expect(harness.result.current.actions.replyRangeEnd).toBe(30);
expect(harness.result.current.actions.commentRangeStart).toBeNull();
});
});
describe('useCommentActions resolving', () => {
it('flips the comment immediately and tells the server the new value', async () => {
const pendingRequest = deferred<unknown>();
fetchMock.mockReturnValue(pendingRequest.promise);
const harness = renderActions();
let submitted: Promise<void> | undefined;
act(() => {
submitted = harness.result.current.actions.handleResolveComment('c1', false);
});
expect(findComment(harness, 'c1')?.isResolved).toBe(true);
await act(async () => {
pendingRequest.resolve(ok({ data: {} }));
await submitted;
});
const call = callsTo('/api/comments/c1', 'PATCH')[0];
expect(bodyOf(call)).toEqual({ isResolved: true });
expect(findComment(harness, 'c1')?.isResolved).toBe(true);
});
it('unresolves an already resolved comment', async () => {
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handleResolveComment('c2', true);
});
expect(bodyOf(callsTo('/api/comments/c2', 'PATCH')[0])).toEqual({ isResolved: false });
expect(findComment(harness, 'c2')?.isResolved).toBe(false);
});
it('reverts the flip when the request fails', async () => {
fetchMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handleResolveComment('c1', false);
});
expect(findComment(harness, 'c1')?.isResolved).toBe(false);
expect(toastError).toHaveBeenCalledWith('Failed to update comment');
});
it('reverts the flip when the request throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handleResolveComment('c1', false);
});
expect(findComment(harness, 'c1')?.isResolved).toBe(false);
expect(toastError).toHaveBeenCalledWith('Failed to update comment');
});
it('refuses non-admins without touching state or the network', async () => {
const harness = renderActions({ canResolveComments: false });
await act(async () => {
await harness.result.current.actions.handleResolveComment('c1', false);
});
expect(fetchMock).not.toHaveBeenCalled();
expect(findComment(harness, 'c1')?.isResolved).toBe(false);
expect(toastError).toHaveBeenCalledWith('Only admins can resolve comments');
});
// KNOWN FRAGILITY, pinned rather than fixed. The optimistic flip is relative
// (`!c.isResolved`) but both the request body and the rollback are absolute,
// derived from the caller's `currentlyResolved` argument. When the two
// disagree the rollback restores a value the comment never had: here an
// unresolved comment ends up resolved after a FAILED request.
it('rolls back to the caller-supplied value, not the value it started at', async () => {
fetchMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const harness = renderActions();
expect(findComment(harness, 'c1')?.isResolved).toBe(false);
await act(async () => {
await harness.result.current.actions.handleResolveComment('c1', true);
});
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({ isResolved: false });
expect(findComment(harness, 'c1')?.isResolved).toBe(true);
});
});
describe('useCommentActions deleting', () => {
it('removes the comment at once and keeps it gone when the server agrees', async () => {
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handleDeleteComment('c1');
});
expect(callsTo('/api/comments/c1', 'DELETE')).toHaveLength(1);
expect(commentIds(harness)).toEqual(['c2']);
});
it('removes a reply by id without removing its parent', async () => {
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handleDeleteComment('r1');
});
expect(commentIds(harness)).toEqual(['c1', 'c2']);
expect(findComment(harness, 'c1')?.replies).toEqual([]);
});
it('puts the comment back when the delete fails', async () => {
fetchMock.mockResolvedValue({ ok: false });
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handleDeleteComment('c1');
});
expect(commentIds(harness)).toEqual(['c1', 'c2']);
expect(findComment(harness, 'c1')?.replies.map((r) => r.id)).toEqual(['r1']);
});
it('puts the comment back when the delete throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const harness = renderActions();
await act(async () => {
await harness.result.current.actions.handleDeleteComment('c1');
});
expect(commentIds(harness)).toEqual(['c1', 'c2']);
});
});
describe('useCommentActions editing', () => {
it('applies the new text only after the server confirms', async () => {
const pendingRequest = deferred<unknown>();
fetchMock.mockReturnValue(pendingRequest.promise);
const harness = renderActions();
act(() => {
harness.result.current.actions.setEditingCommentId('c1');
harness.result.current.actions.setEditText('Reworded note');
});
let submitted: Promise<void> | undefined;
act(() => {
submitted = harness.result.current.actions.handleEditComment('c1');
});
// No optimistic update here: the old text is still on screen.
expect(findComment(harness, 'c1')?.content).toBe('Existing note');
expect(harness.result.current.actions.isSubmittingEdit).toBe(true);
await act(async () => {
pendingRequest.resolve(ok({ data: {} }));
await submitted;
});
expect(findComment(harness, 'c1')?.content).toBe('Reworded note');
expect(harness.result.current.actions.editingCommentId).toBeNull();
expect(harness.result.current.actions.editText).toBe('');
});
it('leaves the comment alone when the edit fails', async () => {
fetchMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const harness = renderActions();
act(() => harness.result.current.actions.setEditText('Reworded note'));
await act(async () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(findComment(harness, 'c1')?.content).toBe('Existing note');
expect(harness.result.current.actions.isSubmittingEdit).toBe(false);
});
it('edits a reply by id', async () => {
const harness = renderActions();
act(() => harness.result.current.actions.setEditText('Reworded reply'));
await act(async () => {
await harness.result.current.actions.handleEditComment('r1');
});
expect(findComment(harness, 'c1')?.replies[0].content).toBe('Reworded reply');
});
it('refuses an edit that would blank the comment', async () => {
const harness = renderActions();
act(() => harness.result.current.actions.setEditText(' '));
await act(async () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(fetchMock).not.toHaveBeenCalled();
expect(findComment(harness, 'c1')?.content).toBe('Existing note');
});
// KNOWN BUG, pinned rather than fixed. `editTagId` is typed `string | null`
// and initialised to `null`, so the `editTagId !== undefined` guard in the
// hook can never be false: every edit PATCH carries a `tagId`, and every
// successful edit overwrites the comment's tag with whatever `editTagId`
// happens to hold. The comment editor in comments-pane.tsx seeds it from the
// comment, but the REPLY editor (comments-pane.tsx, "Edit" on a reply) sets
// only editingCommentId and editText, so editing a reply's text silently
// sends tagId: null.
it('always sends a tagId, and clears the tag, even when the caller never set one', async () => {
const harness = renderActions();
act(() => harness.result.current.actions.setEditText('Reworded note'));
await act(async () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({
content: 'Reworded note',
tagId: null,
});
expect(findComment(harness, 'c1')?.tag).toBeNull();
});
it('keeps the tag when the editor seeded editTagId from the comment', async () => {
const harness = renderActions();
act(() => {
harness.result.current.actions.setEditText('Reworded note');
harness.result.current.actions.setEditTagId('tag-audio');
});
await act(async () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0]).tagId).toBe('tag-audio');
expect(findComment(harness, 'c1')?.tag).toEqual(TAGS[0]);
});
});
describe('useCommentActions background refresh', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('re-reads the comment list every 10 seconds', async () => {
renderActions();
expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(9999);
});
expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(stableDeps.fetchVersionComments).toHaveBeenCalledWith(ACTIVE_VERSION, true);
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
expect(stableDeps.fetchVersionComments).toHaveBeenCalledTimes(2);
});
it('skips the refresh while a write is still in flight', async () => {
const pendingRequest = deferred<unknown>();
fetchMock.mockReturnValue(pendingRequest.promise);
const harness = renderActions();
act(() => harness.result.current.actions.setCommentText('Colour is off'));
let submitted: Promise<void> | undefined;
act(() => {
submitted = harness.result.current.actions.handleAddComment();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(20000);
});
expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled();
await act(async () => {
pendingRequest.resolve(ok({ data: serverComment }));
await submitted;
});
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
expect(stableDeps.fetchVersionComments).toHaveBeenCalledTimes(1);
});
it('skips the refresh while the tab is hidden', async () => {
renderActions();
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
document.dispatchEvent(new Event('visibilitychange'));
await act(async () => {
await vi.advanceTimersByTimeAsync(30000);
});
expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled();
visibility.mockReturnValue('visible');
document.dispatchEvent(new Event('visibilitychange'));
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
expect(stableDeps.fetchVersionComments).toHaveBeenCalledTimes(1);
visibility.mockRestore();
});
it('stops refreshing after unmount', async () => {
const harness = renderActions();
harness.unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(30000);
});
expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useCommentExport } from '@/components/video-page/hooks/use-comment-export';
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock('sonner', () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}));
interface FakeResponseInit {
ok?: boolean;
disposition?: string | null;
json?: () => Promise<unknown>;
}
function fakeResponse({ ok = true, disposition = null, json }: FakeResponseInit = {}) {
return {
ok,
headers: { get: (name: string) => (name === 'content-disposition' ? disposition : null) },
blob: () => Promise.resolve(new Blob(['id,content\n'], { type: 'text/csv' })),
json: json ?? (() => Promise.reject(new SyntaxError('not json'))),
};
}
let fetchMock: ReturnType<typeof vi.fn>;
let clicked: { download: string; href: string }[];
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
clicked = [];
fetchMock = vi.fn().mockResolvedValue(fakeResponse());
vi.stubGlobal('fetch', fetchMock);
// The hook logs every failure. Silence it here so the suite output stays
// readable; one test below asserts the log still happens.
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
// jsdom would try to navigate on a real anchor click. Record the anchor the
// hook built instead, which is also the only way to observe the filename.
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement
) {
clicked.push({ download: this.download, href: this.getAttribute('href') ?? '' });
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
toastSuccess.mockReset();
toastError.mockReset();
});
describe('useCommentExport', () => {
it('requests the chosen format and the current resolved filter', async () => {
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: true })
);
await act(async () => {
await result.current.exportComments('csv');
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/versions/ver1/comments/export?format=csv&includeResolved=true'
);
});
it('passes includeResolved=false when resolved comments are hidden', async () => {
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('pdf');
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/versions/ver1/comments/export?format=pdf&includeResolved=false'
);
});
it('does nothing without an active version', async () => {
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: null, showResolved: false })
);
await act(async () => {
await result.current.exportComments('csv');
});
expect(fetchMock).not.toHaveBeenCalled();
expect(result.current.isExportingCsv).toBe(false);
expect(toastError).not.toHaveBeenCalled();
});
it('downloads under the filename the server sent', async () => {
fetchMock.mockResolvedValue(
fakeResponse({ disposition: 'attachment; filename="Ad campaign-v2-comments.csv"' })
);
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('csv');
});
expect(clicked).toHaveLength(1);
expect(clicked[0].download).toBe('Ad campaign-v2-comments.csv');
expect(clicked[0].href).toBe('blob:openframe-test');
});
it('accepts an unquoted filename too', async () => {
fetchMock.mockResolvedValue(fakeResponse({ disposition: 'attachment; filename=report.pdf' }));
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('pdf');
});
expect(clicked[0].download).toBe('report.pdf');
});
it('falls back to comments.<format> when no filename was sent', async () => {
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('pdf');
});
expect(clicked[0].download).toBe('comments.pdf');
});
it('leaves no anchor and no object URL behind', async () => {
const revoke = vi.spyOn(URL, 'revokeObjectURL');
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('csv');
});
expect(document.querySelectorAll('a')).toHaveLength(0);
expect(revoke).toHaveBeenCalledWith('blob:openframe-test');
expect(toastSuccess).toHaveBeenCalledWith('Comments exported as CSV');
});
it('tracks csv and pdf progress independently', async () => {
let release: (value: unknown) => void = () => {};
fetchMock.mockReturnValue(
new Promise((resolve) => {
release = resolve;
})
);
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
let pending: Promise<void> | undefined;
act(() => {
pending = result.current.exportComments('csv');
});
expect(result.current.isExportingCsv).toBe(true);
expect(result.current.isExportingPdf).toBe(false);
await act(async () => {
release(fakeResponse());
await pending;
});
expect(result.current.isExportingCsv).toBe(false);
});
it('surfaces the server error message and clears the busy flag', async () => {
fetchMock.mockResolvedValue(
fakeResponse({ ok: false, json: () => Promise.resolve({ error: 'Too many comments' }) })
);
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('csv');
});
expect(toastError).toHaveBeenCalledWith('Too many comments');
expect(toastSuccess).not.toHaveBeenCalled();
expect(clicked).toHaveLength(0);
expect(result.current.isExportingCsv).toBe(false);
});
it('falls back to a generic message when the error body is not JSON', async () => {
fetchMock.mockResolvedValue(fakeResponse({ ok: false }));
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('csv');
});
expect(toastError).toHaveBeenCalledWith('Failed to export comments');
});
it('falls back to a generic message when the error body has no error string', async () => {
fetchMock.mockResolvedValue(
fakeResponse({ ok: false, json: () => Promise.resolve({ error: { code: 500 } }) })
);
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('csv');
});
expect(toastError).toHaveBeenCalledWith('Failed to export comments');
});
it('reports a network failure instead of hanging on the busy flag', async () => {
fetchMock.mockRejectedValue(new Error('Network down'));
const { result } = renderHook(() =>
useCommentExport({ activeVersionId: 'ver1', showResolved: false })
);
await act(async () => {
await result.current.exportComments('pdf');
});
await waitFor(() => expect(result.current.isExportingPdf).toBe(false));
expect(toastError).toHaveBeenCalledWith('Network down');
expect(consoleError).toHaveBeenCalledWith(
'Failed to export comments:',
expect.objectContaining({ message: 'Network down' })
);
});
});
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync';
import type { VideoData } from '@/components/video-page/types';
type Params = Parameters<typeof useVersionDurationSync>[0];
function makeVideo(versionDuration: number | null): VideoData {
return {
id: 'vid1',
title: 'Cut 3',
description: null,
projectId: 'proj1',
project: { name: 'Ad campaign', ownerId: 'user1' },
isAuthenticated: true,
currentUserId: 'user1',
currentUserName: 'Ada',
versions: [
{
id: 'ver1',
versionNumber: 1,
versionLabel: null,
providerId: 'bunny',
videoId: 'vid1',
originalUrl: 'https://cdn.example.com/a.mp4',
title: null,
thumbnailUrl: null,
duration: versionDuration,
isActive: true,
_count: { comments: 0 },
comments: [],
},
{
id: 'ver2',
versionNumber: 2,
versionLabel: null,
providerId: 'bunny',
videoId: 'vid1',
originalUrl: 'https://cdn.example.com/b.mp4',
title: null,
thumbnailUrl: null,
duration: 999,
isActive: false,
_count: { comments: 0 },
comments: [],
},
],
};
}
function baseParams(overrides: Partial<Params> = {}): Params {
return {
videoDuration: 42.4,
activeVersionDuration: null,
activeVersionId: 'ver1',
propProjectId: 'proj1',
videoId: 'vid1',
setVideo: vi.fn(),
...overrides,
};
}
/** Mirrors what React's useState does with a functional updater. */
function makeStore(initial: VideoData | null) {
const store: { current: VideoData | null } = { current: initial };
const setVideo = vi.fn((updater: unknown) => {
store.current =
typeof updater === 'function'
? (updater as (prev: VideoData | null) => VideoData | null)(store.current)
: (updater as VideoData | null);
});
return { store, setVideo: setVideo as unknown as Params['setVideo'], spy: setVideo };
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('useVersionDurationSync', () => {
it('PATCHes the measured duration, rounded, to the active version', async () => {
renderHook(() => useVersionDurationSync(baseParams({ videoDuration: 42.4 })));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/projects/proj1/videos/vid1/versions/ver1');
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({ duration: 42 });
});
it('rounds to the nearest second rather than truncating', async () => {
renderHook(() => useVersionDurationSync(baseParams({ videoDuration: 42.6 })));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ duration: 43 });
});
it('writes the rounded duration onto the active version only', async () => {
const { store, setVideo, spy } = makeStore(makeVideo(null));
renderHook(() => useVersionDurationSync(baseParams({ videoDuration: 42.4, setVideo })));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
expect(store.current?.versions[0].duration).toBe(42);
expect(store.current?.versions[1].duration).toBe(999);
});
it('leaves state null when the video has not loaded yet', async () => {
const { store, setVideo, spy } = makeStore(null);
renderHook(() => useVersionDurationSync(baseParams({ setVideo })));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
expect(store.current).toBeNull();
});
it('does nothing until a duration has been measured', () => {
const setVideo = vi.fn();
renderHook(() => useVersionDurationSync(baseParams({ videoDuration: 0, setVideo })));
expect(fetchMock).not.toHaveBeenCalled();
expect(setVideo).not.toHaveBeenCalled();
});
it('does nothing without an active version', () => {
const setVideo = vi.fn();
renderHook(() => useVersionDurationSync(baseParams({ activeVersionId: null, setVideo })));
expect(fetchMock).not.toHaveBeenCalled();
expect(setVideo).not.toHaveBeenCalled();
});
it('does nothing on a share page, where there is no project id', () => {
const setVideo = vi.fn();
renderHook(() => useVersionDurationSync(baseParams({ propProjectId: undefined, setVideo })));
expect(fetchMock).not.toHaveBeenCalled();
expect(setVideo).not.toHaveBeenCalled();
});
it('skips the write when the version already has a stored duration', () => {
const setVideo = vi.fn();
renderHook(() => useVersionDurationSync(baseParams({ activeVersionDuration: 41, setVideo })));
expect(fetchMock).not.toHaveBeenCalled();
expect(setVideo).not.toHaveBeenCalled();
});
it('treats a stored duration of 0 as missing and backfills it', async () => {
renderHook(() => useVersionDurationSync(baseParams({ activeVersionDuration: 0 })));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
});
it('still updates local state when the PATCH fails', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const { store, setVideo, spy } = makeStore(makeVideo(null));
renderHook(() => useVersionDurationSync(baseParams({ setVideo })));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
expect(store.current?.versions[0].duration).toBe(42);
});
it('writes once per measurement, not on every re-render', async () => {
const params = baseParams();
const { rerender } = renderHook(() => useVersionDurationSync(params));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
rerender();
rerender();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('writes again when the active version changes', async () => {
const setVideo = vi.fn();
const { rerender } = renderHook((props: Params) => useVersionDurationSync(props), {
initialProps: baseParams({ setVideo }),
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
rerender(baseParams({ activeVersionId: 'ver2', setVideo }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(fetchMock.mock.calls[1][0]).toBe('/api/projects/proj1/videos/vid1/versions/ver2');
});
});
@@ -0,0 +1,480 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useWatchProgress } from '@/components/video-page/hooks/use-watch-progress';
import type { PlayerAdapter } from '@/components/video-page/types';
type Params = Parameters<typeof useWatchProgress>[0];
/** The interval the hook polls the player on, and the debounce before a write. */
const SAVE_INTERVAL_MS = 5000;
const SAVE_DEBOUNCE_MS = 800;
function makePlayer(overrides: Partial<PlayerAdapter> = {}): PlayerAdapter {
return {
playVideo: vi.fn(),
pauseVideo: vi.fn(),
seekTo: vi.fn(),
mute: vi.fn(),
unMute: vi.fn(),
isMuted: () => false,
getCurrentTime: () => 0,
getDuration: () => 600,
getPlayerState: () => 1,
setPlaybackRate: vi.fn(),
destroy: vi.fn(),
...overrides,
};
}
let fetchMock: ReturnType<typeof vi.fn>;
let loadedProgress: { progress: number; percentage: number };
let loadOk: boolean;
/** Every POST to the progress endpoint, in order, already JSON-parsed. */
function saves() {
return fetchMock.mock.calls
.filter((call) => call[1]?.method === 'POST')
.map((call) => JSON.parse(call[1].body as string));
}
function loads() {
return fetchMock.mock.calls.filter((call) => call[1]?.method !== 'POST');
}
function baseParams(overrides: Partial<Params> = {}): Params {
return {
videoId: 'vid1',
activeVersionId: 'ver1',
isAuthenticated: true,
pathname: '/watch/vid1',
playerRef: { current: makePlayer() },
isReady: true,
currentTime: 0,
videoDuration: 600,
...overrides,
};
}
async function renderWatchProgress(params: Params) {
const rendered = renderHook((props: Params) => useWatchProgress(props), {
initialProps: params,
});
// Let the mount-time progress load settle before any assertion.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
return rendered;
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
beforeEach(() => {
vi.useFakeTimers();
loadedProgress = { progress: 0, percentage: 0 };
loadOk = true;
fetchMock = vi.fn((_url: string, init?: { method?: string }) => {
if (init?.method === 'POST') return Promise.resolve({ ok: true });
return Promise.resolve({
ok: loadOk,
json: () => Promise.resolve({ data: loadedProgress }),
});
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('useWatchProgress resume prompt', () => {
it('offers to resume from a part-watched position', async () => {
loadedProgress = { progress: 123.5, percentage: 42 };
const { result } = await renderWatchProgress(baseParams());
expect(loads()[0][0]).toBe('/api/watch/vid1/progress');
expect(loads()[0][1]).toEqual({ cache: 'no-store' });
expect(result.current.savedProgress).toBe(123.5);
expect(result.current.showResumePrompt).toBe(true);
});
it('stays silent for a video barely started', async () => {
loadedProgress = { progress: 10, percentage: 5 };
const { result } = await renderWatchProgress(baseParams());
expect(result.current.showResumePrompt).toBe(false);
expect(result.current.savedProgress).toBeNull();
});
it('offers to resume just past the 5 percent floor', async () => {
loadedProgress = { progress: 31, percentage: 5.1 };
const { result } = await renderWatchProgress(baseParams());
expect(result.current.showResumePrompt).toBe(true);
});
it('treats 95 percent as watched and does not offer to resume', async () => {
loadedProgress = { progress: 570, percentage: 95 };
const { result } = await renderWatchProgress(baseParams());
expect(result.current.showResumePrompt).toBe(false);
});
it('still offers to resume just below the watched boundary', async () => {
loadedProgress = { progress: 569, percentage: 94.9 };
const { result } = await renderWatchProgress(baseParams());
expect(result.current.showResumePrompt).toBe(true);
});
it('does not read progress for an anonymous viewer', async () => {
const { result } = await renderWatchProgress(baseParams({ isAuthenticated: false }));
expect(fetchMock).not.toHaveBeenCalled();
expect(result.current.showResumePrompt).toBe(false);
});
it('does not read progress before a version is selected', async () => {
await renderWatchProgress(baseParams({ activeVersionId: null }));
expect(fetchMock).not.toHaveBeenCalled();
});
it('stays silent when the read fails', async () => {
loadOk = false;
loadedProgress = { progress: 400, percentage: 60 };
const { result } = await renderWatchProgress(baseParams());
expect(result.current.showResumePrompt).toBe(false);
});
it('stays silent when the read throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
const { result } = await renderWatchProgress(baseParams());
expect(result.current.showResumePrompt).toBe(false);
expect(console.error).toHaveBeenCalledWith(
'Error loading watch progress:',
expect.objectContaining({ message: 'offline' })
);
});
it('seeks the player and closes the prompt on resume', async () => {
loadedProgress = { progress: 123.5, percentage: 42 };
const player = makePlayer();
const { result } = await renderWatchProgress(baseParams({ playerRef: { current: player } }));
let returned: number | null = null;
act(() => {
returned = result.current.handleResumeFromSaved();
});
expect(player.seekTo).toHaveBeenCalledWith(123.5, true);
expect(returned).toBe(123.5);
expect(result.current.showResumePrompt).toBe(false);
expect(result.current.savedProgress).toBeNull();
});
it('does nothing on resume when nothing was saved', async () => {
const player = makePlayer();
const { result } = await renderWatchProgress(baseParams({ playerRef: { current: player } }));
let returned: number | null = 1;
act(() => {
returned = result.current.handleResumeFromSaved();
});
expect(returned).toBeNull();
expect(player.seekTo).not.toHaveBeenCalled();
});
it('closes the prompt without seeking when dismissed', async () => {
loadedProgress = { progress: 123.5, percentage: 42 };
const player = makePlayer();
const { result } = await renderWatchProgress(baseParams({ playerRef: { current: player } }));
act(() => {
result.current.handleDismissResume();
});
expect(result.current.showResumePrompt).toBe(false);
expect(result.current.savedProgress).toBeNull();
expect(player.seekTo).not.toHaveBeenCalled();
});
it('re-reads progress when the route changes', async () => {
const params = baseParams();
const { rerender } = await renderWatchProgress(params);
expect(loads()).toHaveLength(1);
rerender({ ...params, pathname: '/watch/vid2' });
await advance(0);
expect(loads()).toHaveLength(2);
});
});
describe('useWatchProgress throttling', () => {
it('writes on the 5s poll, after the 800ms debounce, and not before', async () => {
const player = makePlayer({ getCurrentTime: () => 30 });
await renderWatchProgress(baseParams({ playerRef: { current: player } }));
await advance(SAVE_INTERVAL_MS - 1);
expect(saves()).toHaveLength(0);
await advance(1);
expect(saves()).toHaveLength(0); // polled, but still inside the debounce
await advance(SAVE_DEBOUNCE_MS - 1);
expect(saves()).toHaveLength(0);
await advance(1);
expect(saves()).toHaveLength(1);
});
it('sends the player position, duration and version', async () => {
const player = makePlayer({ getCurrentTime: () => 30, getDuration: () => 610 });
await renderWatchProgress(baseParams({ playerRef: { current: player } }));
await advance(SAVE_INTERVAL_MS + SAVE_DEBOUNCE_MS);
expect(saves()).toEqual([{ progress: 30, duration: 610, versionId: 'ver1' }]);
const post = fetchMock.mock.calls.find((call) => call[1]?.method === 'POST');
expect(post?.[0]).toBe('/api/watch/vid1/progress');
});
it('drops a second poll that has not moved at least 2 seconds on', async () => {
let now = 30;
const player = makePlayer({ getCurrentTime: () => now });
await renderWatchProgress(baseParams({ playerRef: { current: player } }));
await advance(SAVE_INTERVAL_MS + SAVE_DEBOUNCE_MS);
expect(saves()).toHaveLength(1);
now = 31.9; // 1.9s on, below the threshold
await advance(SAVE_INTERVAL_MS + SAVE_DEBOUNCE_MS);
expect(saves()).toHaveLength(1);
now = 32; // exactly 2s on, written
await advance(SAVE_INTERVAL_MS + SAVE_DEBOUNCE_MS);
expect(saves()).toHaveLength(2);
expect(saves()[1].progress).toBe(32);
});
it('coalesces several scheduled saves into one write at the highest position', async () => {
const { result } = await renderWatchProgress(baseParams());
act(() => {
result.current.scheduleWatchProgressSave({ progress: 10, duration: 600 });
result.current.scheduleWatchProgressSave({ progress: 40, duration: 600 });
result.current.scheduleWatchProgressSave({ progress: 25, duration: 600 });
});
await advance(SAVE_DEBOUNCE_MS);
expect(saves()).toEqual([{ progress: 40, duration: 600, versionId: 'ver1' }]);
});
it('writes straight away when asked to be immediate', async () => {
const { result } = await renderWatchProgress(baseParams());
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 10, immediate: true });
});
expect(saves()).toHaveLength(1);
});
it('honours force for a move smaller than the 2 second threshold', async () => {
const { result } = await renderWatchProgress(baseParams());
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 10, immediate: true });
});
expect(saves()).toHaveLength(1);
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 10.5, immediate: true });
});
expect(saves()).toHaveLength(1);
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 10.5, immediate: true, force: true });
});
expect(saves()).toHaveLength(2);
});
it('never writes a non-positive position', async () => {
const { result } = await renderWatchProgress(baseParams());
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 0, immediate: true, force: true });
result.current.scheduleWatchProgressSave({ progress: -12, immediate: true, force: true });
});
expect(saves()).toHaveLength(0);
});
it('does not write for an anonymous viewer', async () => {
const { result } = await renderWatchProgress(baseParams({ isAuthenticated: false }));
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 30, immediate: true, force: true });
});
await advance(SAVE_INTERVAL_MS + SAVE_DEBOUNCE_MS);
expect(saves()).toHaveLength(0);
});
it('does not write before a version is selected', async () => {
const { result } = await renderWatchProgress(baseParams({ activeVersionId: null }));
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 30, immediate: true, force: true });
});
expect(saves()).toHaveLength(0);
});
it('stops polling once the player is no longer ready', async () => {
const player = makePlayer({ getCurrentTime: () => 30 });
const params = baseParams({ playerRef: { current: player } });
const { rerender } = await renderWatchProgress(params);
rerender({ ...params, isReady: false });
await advance(SAVE_INTERVAL_MS * 4);
expect(saves()).toHaveLength(0);
});
it('retries a rejected write on the next poll instead of marking it saved', async () => {
fetchMock.mockImplementation((_url: string, init?: { method?: string }) => {
if (init?.method === 'POST') return Promise.resolve({ ok: false });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: loadedProgress }) });
});
let now = 30;
const player = makePlayer({ getCurrentTime: () => now });
await renderWatchProgress(baseParams({ playerRef: { current: player } }));
await advance(SAVE_INTERVAL_MS + SAVE_DEBOUNCE_MS);
expect(saves()).toHaveLength(1);
// The write failed, so lastSavedProgress stayed at 0 and a position only
// 0.5s further along is still 30.5s away from it: it must be retried.
now = 30.5;
await advance(SAVE_INTERVAL_MS + SAVE_DEBOUNCE_MS);
expect(saves()).toHaveLength(2);
});
it('resets the saved-position baseline when the version changes', async () => {
const params = baseParams();
const { result, rerender } = await renderWatchProgress(params);
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 30, immediate: true });
});
expect(saves()).toHaveLength(1);
rerender({ ...params, activeVersionId: 'ver2' });
await advance(0);
await act(async () => {
result.current.scheduleWatchProgressSave({ progress: 30, immediate: true });
});
expect(saves()).toHaveLength(2);
expect(saves()[1].versionId).toBe('ver2');
});
});
describe('useWatchProgress leaving the page', () => {
it('forces an immediate write when the tab is hidden', async () => {
const player = makePlayer({ getCurrentTime: () => 44 });
await renderWatchProgress(baseParams({ playerRef: { current: player } }));
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
visibility.mockRestore();
expect(saves()).toEqual([{ progress: 44, duration: 600, versionId: 'ver1' }]);
});
it('does not write when the tab becomes visible again', async () => {
const player = makePlayer({ getCurrentTime: () => 44 });
await renderWatchProgress(baseParams({ playerRef: { current: player } }));
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
expect(saves()).toHaveLength(0);
});
it('beacons the furthest known position on unload', async () => {
const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const player = makePlayer({ getCurrentTime: () => 44, getDuration: () => 600 });
const { result } = await renderWatchProgress(baseParams({ playerRef: { current: player } }));
// A pending (debounced) save that is further along than the player must win.
act(() => {
result.current.scheduleWatchProgressSave({ progress: 90, duration: 620 });
});
await act(async () => {
window.dispatchEvent(new Event('beforeunload'));
});
expect(beacon).toHaveBeenCalledTimes(1);
const [url, blob] = beacon.mock.calls[0] as [string, Blob];
expect(url).toBe('/api/watch/vid1/progress');
expect(JSON.parse(await blob.text())).toEqual({
progress: 90,
duration: 620,
versionId: 'ver1',
});
});
it('does not beacon from the start of the video', async () => {
const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const player = makePlayer({ getCurrentTime: () => 0 });
await renderWatchProgress(
baseParams({ playerRef: { current: player }, currentTime: 0, videoDuration: 600 })
);
await act(async () => {
window.dispatchEvent(new Event('beforeunload'));
});
expect(beacon).not.toHaveBeenCalled();
});
it('does not beacon for an anonymous viewer', async () => {
const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const player = makePlayer({ getCurrentTime: () => 44 });
await renderWatchProgress(
baseParams({ playerRef: { current: player }, isAuthenticated: false })
);
await act(async () => {
window.dispatchEvent(new Event('beforeunload'));
});
expect(beacon).not.toHaveBeenCalled();
});
it('stops polling after unmount', async () => {
const player = makePlayer({ getCurrentTime: () => 30 });
const { unmount } = await renderWatchProgress(baseParams({ playerRef: { current: player } }));
unmount();
await advance(SAVE_INTERVAL_MS * 3 + SAVE_DEBOUNCE_MS);
expect(saves()).toHaveLength(0);
});
});