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
+261
View File
@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CommentRichText } from '@/components/video-page/comment-rich-text';
import type { VideoAsset } from '@/components/video-page/types';
function makeAsset(overrides: Partial<VideoAsset> = {}): VideoAsset {
return {
id: 'a1b2c3',
videoId: 'vid1',
kind: 'IMAGE',
provider: 'R2_IMAGE',
displayName: 'Reference frame.png',
sourceUrl: null,
providerVideoId: null,
thumbnailUrl: null,
uploadedByUserId: 'user1',
uploadedByGuestName: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
uploadedByUser: null,
canDelete: true,
...overrides,
};
}
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Mentions mixed with text make React complain about duplicate keys (see the
// pinned bug at the bottom of this file). Silence it so the suite output stays
// readable; that one test asserts the warning is still there.
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('CommentRichText linkification', () => {
it('renders a bare http(s) URL as a new-tab link', () => {
render(<CommentRichText text="See https://example.com/shot-3 for the grade" />);
const link = screen.getByRole('link', { name: 'https://example.com/shot-3' });
expect(link).toHaveAttribute('href', 'https://example.com/shot-3');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('keeps the surrounding text intact', () => {
const { container } = render(
<CommentRichText text="See https://example.com/shot-3 for the grade" />
);
expect(container).toHaveTextContent('See https://example.com/shot-3 for the grade');
});
it('links every URL in the comment', () => {
render(<CommentRichText text="http://a.test/1 and https://b.test/2 and https://c.test/3" />);
expect(screen.getAllByRole('link').map((a) => a.getAttribute('href'))).toEqual([
'http://a.test/1',
'https://b.test/2',
'https://c.test/3',
]);
});
it('renders text with no URL as plain text', () => {
render(<CommentRichText text="Just a note about the cut" />);
expect(screen.queryAllByRole('link')).toHaveLength(0);
expect(screen.getByText('Just a note about the cut')).toBeInTheDocument();
});
it('renders nothing for an empty comment', () => {
const { container } = render(<CommentRichText text="" />);
expect(container).toBeEmptyDOMElement();
});
});
describe('CommentRichText URL scheme safety', () => {
it('does not turn a javascript: URL into a link', () => {
const { container } = render(<CommentRichText text="javascript:alert(document.cookie)" />);
expect(screen.queryAllByRole('link')).toHaveLength(0);
expect(container.querySelector('a')).toBeNull();
expect(container).toHaveTextContent('javascript:alert(document.cookie)');
});
it('does not turn a javascript: URL into a link mid-sentence either', () => {
const { container } = render(
<CommentRichText text="click javascript:alert(1) now, or JavaScript:alert(1)" />
);
expect(container.querySelector('a')).toBeNull();
});
it('does not turn a data: URL into a link', () => {
const { container } = render(
<CommentRichText text="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" />
);
expect(container.querySelector('a')).toBeNull();
expect(container).toHaveTextContent('data:text/html;base64');
});
it('does not link vbscript:, file: or protocol-relative URLs', () => {
const { container } = render(
<CommentRichText text="vbscript:msgbox(1) file:///etc/passwd //evil.test/x" />
);
expect(container.querySelector('a')).toBeNull();
});
it('never injects markup from the comment body', () => {
const { container } = render(
<CommentRichText text={'<img src=x onerror="alert(1)"> <script>alert(2)</script>'} />
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('script')).toBeNull();
expect(container).toHaveTextContent('<img src=x onerror="alert(1)">');
});
it('is case sensitive about the scheme, so HTTPS:// is left as text', () => {
// Pinning current behaviour: the regex has no `i` flag, so an uppercase
// scheme is not linkified. Harmless, but worth knowing before someone
// "fixes" the regex and widens what becomes clickable.
const { container } = render(<CommentRichText text="HTTPS://EXAMPLE.COM/a" />);
expect(container.querySelector('a')).toBeNull();
});
it('swallows trailing punctuation into the href', () => {
// Pinning current behaviour: `[^\s]+` is greedy to the next whitespace, so
// the sentence-ending period lands inside the link.
render(<CommentRichText text="Fixed in https://example.com/pr/12." />);
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com/pr/12.');
});
});
describe('CommentRichText asset mentions', () => {
it('renders a mention as a button labelled with the asset name', () => {
render(<CommentRichText text="Compare with @[Reference frame.png](asset:a1b2c3)" />);
expect(screen.getByRole('button', { name: '@Reference frame.png' })).toBeInTheDocument();
});
it('reports the mentioned asset id when clicked', async () => {
const onAssetMentionClick = vi.fn();
render(
<CommentRichText
text="Compare with @[Reference frame.png](asset:a1b2c3)"
onAssetMentionClick={onAssetMentionClick}
/>
);
await userEvent.click(screen.getByRole('button', { name: '@Reference frame.png' }));
expect(onAssetMentionClick).toHaveBeenCalledWith('a1b2c3');
});
it('prefers the current asset name over the name stored in the comment', () => {
render(
<CommentRichText
text="Compare with @[old-name.png](asset:a1b2c3)"
assets={[makeAsset({ displayName: 'Renamed.png' })]}
/>
);
expect(screen.getByRole('button', { name: '@Renamed.png' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '@old-name.png' })).not.toBeInTheDocument();
});
it('falls back to the stored name when the asset is gone', () => {
render(<CommentRichText text="@[deleted.png](asset:zzz999)" assets={[makeAsset()]} />);
expect(screen.getByRole('button', { name: '@deleted.png' })).toBeInTheDocument();
});
it('does not throw when clicked without a handler', async () => {
render(<CommentRichText text="@[Reference frame.png](asset:a1b2c3)" />);
await userEvent.click(screen.getByRole('button', { name: '@Reference frame.png' }));
expect(screen.getByRole('button', { name: '@Reference frame.png' })).toBeInTheDocument();
});
it('renders text, mentions and links together in reading order', () => {
const { container } = render(
<CommentRichText text="Before @[One](asset:aaa111) middle https://example.com/x after" />
);
expect(container).toHaveTextContent('Before @One middle https://example.com/x after');
expect(screen.getByRole('button', { name: '@One' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'https://example.com/x' })).toBeInTheDocument();
});
it('renders several mentions in one comment', () => {
render(<CommentRichText text="@[One](asset:aaa111) then @[Two](asset:bbb222)" />);
expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual(['@One', '@Two']);
});
it('accepts an uppercase asset id', () => {
const onAssetMentionClick = vi.fn();
render(
<CommentRichText text="@[One](asset:AAA111)" onAssetMentionClick={onAssetMentionClick} />
);
expect(screen.getByRole('button', { name: '@One' })).toBeInTheDocument();
});
it('leaves a mention with a non-alphanumeric id as plain text', () => {
const { container } = render(<CommentRichText text="@[One](asset:aa-11)" />);
expect(screen.queryAllByRole('button')).toHaveLength(0);
expect(container).toHaveTextContent('@[One](asset:aa-11)');
});
it('leaves a malformed mention as plain text', () => {
const { container } = render(<CommentRichText text="@[One](assets:aaa111) @[Two] (asset:b)" />);
expect(screen.queryAllByRole('button')).toHaveLength(0);
expect(container).toHaveTextContent('@[One](assets:aaa111)');
});
it('does not inject markup through the mention label', () => {
const { container } = render(
<CommentRichText text={'@[<img src=x onerror="alert(1)">](asset:aaa111)'} />
);
expect(container.querySelector('img')).toBeNull();
expect(container).toHaveTextContent('@<img src=x onerror="alert(1)">');
});
it('does not linkify a URL used as a mention label', () => {
const { container } = render(<CommentRichText text="@[https://evil.test/x](asset:aaa111)" />);
expect(container.querySelector('a')).toBeNull();
expect(screen.getByRole('button', { name: '@https://evil.test/x' })).toBeInTheDocument();
});
// KNOWN BUG, pinned rather than fixed. `renderUrls` keys its fragments by the
// index within its own slice, and CommentRichText calls it once per gap
// between mentions, so the same key ("txt-0") is emitted for several
// siblings. React logs "Encountered two children with the same key" and warns
// that children may be duplicated or omitted. The output happens to be
// correct today; the text assertion locks that in, and the warning assertion
// is the thing to delete once the keys are made unique.
it('produces duplicate React keys when text surrounds a mention', () => {
const { container } = render(
<CommentRichText text="Before @[One](asset:aaa111) middle @[Two](asset:bbb222) after" />
);
expect(container).toHaveTextContent('Before @One middle @Two after');
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('same key'), 'txt-0');
});
});
+272
View File
@@ -0,0 +1,272 @@
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest';
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ErrorBoundary, withErrorBoundary } from '@/components/error-boundary';
function Boom({ message = 'render blew up' }: { message?: string }): never {
throw new Error(message);
}
/**
* Throws while the shared flag is set. React retries a failed render before it
* gives up, so a counter would be consumed by the retry; a flag the test flips
* explicitly keeps "Try again" deterministic.
*/
function ConditionalBoom({ shouldThrow }: { shouldThrow: { value: boolean } }) {
if (shouldThrow.value) {
throw new Error('transient');
}
return <p>Recovered content</p>;
}
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// React itself logs every caught error, on top of the boundary's own log.
// Silence both; the assertions below check the boundary's log explicitly.
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('ErrorBoundary', () => {
it('renders its children while nothing throws', () => {
render(
<ErrorBoundary>
<p>Healthy content</p>
</ErrorBoundary>
);
expect(screen.getByText('Healthy content')).toBeInTheDocument();
expect(consoleError).not.toHaveBeenCalled();
});
it('replaces a crashed subtree with the recovery fallback', () => {
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>
);
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reload page' })).toBeInTheDocument();
});
it('names the crashed area when given a context', () => {
render(
<ErrorBoundary context="Assets pane">
<Boom />
</ErrorBoundary>
);
expect(screen.getByRole('heading', { name: 'Assets pane crashed' })).toBeInTheDocument();
expect(
screen.getByText(
'An unexpected error occurred. Try resetting the component or reload the page.'
)
).toBeInTheDocument();
});
it('offers video-specific guidance for a video context', () => {
render(
<ErrorBoundary context="VideoPlayer">
<Boom />
</ErrorBoundary>
);
expect(screen.getByRole('heading', { name: 'VideoPlayer crashed' })).toBeInTheDocument();
expect(
screen.getByText(
'The video player encountered an error. Try reloading or go back to the project.'
)
).toBeInTheDocument();
});
it('does not swallow the error: it reports it to onError', () => {
const onError = vi.fn();
render(
<ErrorBoundary onError={onError}>
<Boom message="player adapter missing" />
</ErrorBoundary>
);
expect(onError).toHaveBeenCalledTimes(1);
const [error, errorInfo] = onError.mock.calls[0];
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('player adapter missing');
expect(errorInfo).toHaveProperty('componentStack');
expect(String((errorInfo as { componentStack: string }).componentStack)).toContain('Boom');
});
it('does not swallow the error: it logs it with the context', () => {
render(
<ErrorBoundary context="VideoPlayer">
<Boom message="player adapter missing" />
</ErrorBoundary>
);
expect(consoleError).toHaveBeenCalledWith(
'ErrorBoundary [VideoPlayer] caught an error:',
expect.objectContaining({ message: 'player adapter missing' }),
expect.anything()
);
});
it('logs without a context prefix when none was given', () => {
render(
<ErrorBoundary>
<Boom message="nameless" />
</ErrorBoundary>
);
expect(consoleError).toHaveBeenCalledWith(
'ErrorBoundary caught an error:',
expect.objectContaining({ message: 'nameless' }),
expect.anything()
);
});
it('renders a custom fallback instead of the built-in one', () => {
const onError = vi.fn();
render(
<ErrorBoundary fallback={<p>Could not load the timeline</p>} onError={onError}>
<Boom />
</ErrorBoundary>
);
expect(screen.getByText('Could not load the timeline')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
// The error still propagates to the caller even with a custom fallback.
expect(onError).toHaveBeenCalledTimes(1);
});
it('re-renders the children when Try again is pressed', async () => {
const shouldThrow = { value: true };
render(
<ErrorBoundary>
<ConditionalBoom shouldThrow={shouldThrow} />
</ErrorBoundary>
);
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.queryByText('Recovered content')).not.toBeInTheDocument();
shouldThrow.value = false;
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(screen.getByText('Recovered content')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Something went wrong' })).not.toBeInTheDocument();
});
it('shows the fallback again if the retry crashes too', async () => {
const shouldThrow = { value: true };
render(
<ErrorBoundary>
<ConditionalBoom shouldThrow={shouldThrow} />
</ErrorBoundary>
);
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.queryByText('Recovered content')).not.toBeInTheDocument();
});
it('reloads the page when Reload page is pressed', async () => {
const reload = vi.fn();
// Restored by hand. vi.restoreAllMocks() undoes spies, not a
// defineProperty, so without this the whole file runs on a fake
// window.location from here on and the next test to touch it would be
// reading a stub left behind by this one.
const realLocation = Object.getOwnPropertyDescriptor(window, 'location');
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, reload },
});
onTestFinished(() => {
if (realLocation) {
Object.defineProperty(window, 'location', realLocation);
}
});
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>
);
await userEvent.click(screen.getByRole('button', { name: 'Reload page' }));
expect(reload).toHaveBeenCalledTimes(1);
});
it('keeps a healthy sibling boundary mounted when one crashes', () => {
render(
<div>
<ErrorBoundary context="Left">
<Boom />
</ErrorBoundary>
<ErrorBoundary context="Right">
<p>Right pane still here</p>
</ErrorBoundary>
</div>
);
expect(screen.getByRole('heading', { name: 'Left crashed' })).toBeInTheDocument();
expect(screen.getByText('Right pane still here')).toBeInTheDocument();
});
it('catches an error thrown from a state updater, not just from render', async () => {
function ThrowOnClick() {
const [, setState] = useState(0);
return (
<button
type="button"
onClick={() => {
setState(() => {
throw new Error('from updater');
});
}}
>
Break it
</button>
);
}
render(
<ErrorBoundary>
<ThrowOnClick />
</ErrorBoundary>
);
await userEvent.click(screen.getByRole('button', { name: 'Break it' }));
expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Break it' })).not.toBeInTheDocument();
});
});
describe('withErrorBoundary', () => {
it('wraps a component and forwards its props', () => {
function Panel({ label }: { label: string }) {
return <p>{label}</p>;
}
const Wrapped = withErrorBoundary(Panel);
render(<Wrapped label="Timeline" />);
expect(screen.getByText('Timeline')).toBeInTheDocument();
});
it('applies the boundary options to a crash inside the wrapped component', () => {
const onError = vi.fn();
const Wrapped = withErrorBoundary(Boom, { context: 'VideoPlayer', onError });
render(<Wrapped />);
expect(screen.getByRole('heading', { name: 'VideoPlayer crashed' })).toBeInTheDocument();
expect(onError).toHaveBeenCalledTimes(1);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GuestGate } from '@/components/guest-gate';
// next/link needs the App Router context to mount. The sign-in link is
// incidental to the validation branches under test, so stub it to an anchor.
vi.mock('next/link', () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
const STORAGE_KEY = 'openframe_guest_name';
function renderGate() {
return render(
<GuestGate>
<p>Gated video page</p>
</GuestGate>
);
}
/**
* ACCESSIBILITY FINDING: the name field has no <label> and no aria-label, so it
* has no accessible name. `getByRole('textbox')` finds it only because it is the
* one textbox on the page. Reported, not worked around.
*/
function nameField() {
return screen.getByRole('textbox');
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
describe('GuestGate', () => {
it('hides the content behind a name prompt for a first-time visitor', () => {
renderGate();
expect(screen.getByRole('heading', { name: 'Welcome to OpenFrame' })).toBeInTheDocument();
expect(screen.queryByText('Gated video page')).not.toBeInTheDocument();
expect(nameField()).toHaveValue('');
});
it('skips the prompt for a visitor who already gave a name', () => {
localStorage.setItem(STORAGE_KEY, 'Kerem');
renderGate();
expect(screen.getByText('Gated video page')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Welcome to OpenFrame' })).not.toBeInTheDocument();
});
it('still prompts when the stored name is an empty string', () => {
localStorage.setItem(STORAGE_KEY, '');
renderGate();
expect(screen.getByRole('heading', { name: 'Welcome to OpenFrame' })).toBeInTheDocument();
expect(screen.queryByText('Gated video page')).not.toBeInTheDocument();
});
it('keeps Continue disabled until a real name is typed', async () => {
renderGate();
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
await userEvent.type(nameField(), ' ');
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
await userEvent.type(nameField(), 'K');
expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled();
});
it('reveals the content and remembers the name on Continue', async () => {
renderGate();
await userEvent.type(nameField(), 'Kerem');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(screen.getByText('Gated video page')).toBeInTheDocument();
expect(localStorage.getItem(STORAGE_KEY)).toBe('Kerem');
});
it('confirms on Enter as well as on the button', async () => {
renderGate();
await userEvent.type(nameField(), 'Kerem{Enter}');
expect(screen.getByText('Gated video page')).toBeInTheDocument();
expect(localStorage.getItem(STORAGE_KEY)).toBe('Kerem');
});
it('stores the trimmed name', async () => {
renderGate();
await userEvent.type(nameField(), ' Kerem {Enter}');
expect(localStorage.getItem(STORAGE_KEY)).toBe('Kerem');
});
it('does not confirm on Enter with a whitespace-only name', async () => {
renderGate();
await userEvent.type(nameField(), ' {Enter}');
expect(screen.queryByText('Gated video page')).not.toBeInTheDocument();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('caps the name at 100 characters in the field itself', async () => {
renderGate();
expect(nameField()).toHaveAttribute('maxLength', '100');
await userEvent.type(nameField(), 'x'.repeat(140));
// The `trimmed.length > 100` guard in the component is therefore
// unreachable through the UI: the field can never hold a longer value.
expect(nameField()).toHaveValue('x'.repeat(100));
expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled();
});
it('offers a sign-in escape hatch instead of the gate', () => {
renderGate();
expect(screen.getByRole('link', { name: 'sign in' })).toHaveAttribute('href', '/login');
});
});
@@ -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);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Linkify } from '@/components/linkify';
describe('Linkify', () => {
it('renders a bare http(s) URL as a new-tab link', () => {
render(<Linkify>{'Ticket at https://tracker.test/OF-12 please'}</Linkify>);
const link = screen.getByRole('link', { name: 'https://tracker.test/OF-12' });
expect(link).toHaveAttribute('href', 'https://tracker.test/OF-12');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('keeps the surrounding text intact', () => {
const { container } = render(
<Linkify>{'Ticket at https://tracker.test/OF-12 please'}</Linkify>
);
expect(container).toHaveTextContent('Ticket at https://tracker.test/OF-12 please');
});
it('links every URL in the string', () => {
render(<Linkify>{'http://a.test/1 then https://b.test/2'}</Linkify>);
expect(screen.getAllByRole('link').map((a) => a.getAttribute('href'))).toEqual([
'http://a.test/1',
'https://b.test/2',
]);
});
it('leaves plain text alone', () => {
render(<Linkify>{'No links in this sentence'}</Linkify>);
expect(screen.queryAllByRole('link')).toHaveLength(0);
expect(screen.getByText('No links in this sentence')).toBeInTheDocument();
});
it('passes non-string children straight through', () => {
render(
<Linkify>
<span data-testid="child">https://not-parsed.test/x</span>
</Linkify>
);
expect(screen.queryAllByRole('link')).toHaveLength(0);
expect(screen.getByTestId('child')).toHaveTextContent('https://not-parsed.test/x');
});
it('does not turn a javascript: URL into a link', () => {
const { container } = render(<Linkify>{'javascript:alert(document.cookie)'}</Linkify>);
expect(container.querySelector('a')).toBeNull();
expect(container).toHaveTextContent('javascript:alert(document.cookie)');
});
it('does not turn a data: URL into a link', () => {
const { container } = render(
<Linkify>{'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='}</Linkify>
);
expect(container.querySelector('a')).toBeNull();
});
it('does not link vbscript:, file: or protocol-relative URLs', () => {
const { container } = render(
<Linkify>{'vbscript:msgbox(1) file:///etc/passwd //evil.test/x'}</Linkify>
);
expect(container.querySelector('a')).toBeNull();
});
it('never injects markup from the string', () => {
const { container } = render(<Linkify>{'<img src=x onerror="alert(1)">'}</Linkify>);
expect(container.querySelector('img')).toBeNull();
expect(container).toHaveTextContent('<img src=x onerror="alert(1)">');
});
it('is case sensitive about the scheme, matching CommentRichText', () => {
const { container } = render(<Linkify>{'HTTPS://EXAMPLE.COM/a'}</Linkify>);
expect(container.querySelector('a')).toBeNull();
});
it('swallows trailing punctuation into the href, matching CommentRichText', () => {
render(<Linkify>{'Fixed in https://example.com/pr/12.'}</Linkify>);
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com/pr/12.');
});
it('does not let a click on the link reach an enclosing handler', async () => {
const onRowClick = vi.fn();
render(
// Mirrors the real call sites, where Linkify sits inside a clickable
// comment row.
<div onClick={onRowClick}>
<Linkify>{'https://tracker.test/OF-12'}</Linkify>
</div>
);
const link = screen.getByRole('link');
link.addEventListener('click', (event) => event.preventDefault());
await userEvent.click(link);
expect(onRowClick).not.toHaveBeenCalled();
});
});
+193
View File
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ShareLinkUnlock } from '@/components/share-link-unlock';
const replace = vi.fn();
const refresh = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace, refresh, push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }),
}));
// next/link needs the App Router context to mount. The link is incidental to
// the validation branches under test, so stub it down to an anchor.
vi.mock('next/link', () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
let fetchMock: ReturnType<typeof vi.fn>;
/**
* ACCESSIBILITY FINDING: the password field has no <label>, no aria-label and
* no aria-labelledby, only a placeholder. A password input has no ARIA role
* either, so there is no `getByRole` route to it at all. Reported, not papered
* over: this helper documents that the placeholder is the only handle we have.
*/
function passwordField() {
return screen.getByPlaceholderText('Password');
}
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
replace.mockReset();
refresh.mockReset();
});
describe('ShareLinkUnlock', () => {
it('asks for the password and offers a sign-in escape hatch', () => {
render(<ShareLinkUnlock videoId="vid1" />);
expect(screen.getByRole('heading', { name: 'Password Required' })).toBeInTheDocument();
expect(passwordField()).toHaveAttribute('type', 'password');
expect(passwordField()).toHaveAttribute('maxLength', '128');
expect(screen.getByRole('link', { name: 'sign in' })).toHaveAttribute('href', '/login');
});
it('ignores a submit with an empty field', async () => {
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(fetchMock).not.toHaveBeenCalled();
expect(replace).not.toHaveBeenCalled();
});
it('ignores a submit with only whitespace', async () => {
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), ' ');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(fetchMock).not.toHaveBeenCalled();
});
it('posts the password to the session endpoint for this video', async () => {
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/watch/vid1/session');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({ password: 'hunter2' });
});
it('sends the password verbatim, without trimming', async () => {
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), ' hunter2 ');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({
password: ' hunter2 ',
});
});
it('navigates into the video on success', async () => {
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => expect(replace).toHaveBeenCalledWith('/watch/vid1'));
expect(refresh).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Invalid password')).not.toBeInTheDocument();
});
it('submits on Enter as well as on the button', async () => {
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'hunter2{Enter}');
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(replace).toHaveBeenCalledWith('/watch/vid1');
});
it('shows the server message for a rejected password', async () => {
fetchMock.mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'This link has expired' }),
});
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'wrong');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(await screen.findByText('This link has expired')).toBeInTheDocument();
expect(replace).not.toHaveBeenCalled();
});
it('falls back to "Invalid password" when the error body is not JSON', async () => {
fetchMock.mockResolvedValue({ ok: false, json: () => Promise.reject(new SyntaxError('nope')) });
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'wrong');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(await screen.findByText('Invalid password')).toBeInTheDocument();
});
it('reports a network failure separately from a wrong password', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(await screen.findByText('Failed to verify password')).toBeInTheDocument();
});
it('clears a stale error when the password is retried', async () => {
fetchMock.mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'This link has expired' }),
});
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'wrong');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(await screen.findByText('This link has expired')).toBeInTheDocument();
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => expect(replace).toHaveBeenCalledWith('/watch/vid1'));
expect(screen.queryByText('This link has expired')).not.toBeInTheDocument();
});
it('blocks a second submit while the first is still in flight', async () => {
let release: (value: unknown) => void = () => {};
fetchMock.mockReturnValue(
new Promise((resolve) => {
release = resolve;
})
);
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'hunter2');
// Capture the node first: while submitting, the label is swapped for a
// spinner, which leaves the button with no accessible name to query by.
// ACCESSIBILITY FINDING, reported rather than worked around.
const submit = screen.getByRole('button', { name: 'Continue' });
await userEvent.click(submit);
expect(submit).toBeDisabled();
expect(submit).toHaveAccessibleName('');
release({ ok: true, json: () => Promise.resolve({}) });
await waitFor(() => expect(replace).toHaveBeenCalledTimes(1));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});