mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
154 lines
6.0 KiB
TypeScript
154 lines
6.0 KiB
TypeScript
// Multi-select on the project page: deleting several videos at once, and moving
|
|
// several videos into another project in the same workspace.
|
|
//
|
|
// Both flows are behind a selection mode that is only reachable through a card's
|
|
// overflow menu, so the entry point is exercised here too. The assertions are
|
|
// deliberately about rows that disappear from one page and appear on another,
|
|
// not about the toast: a toast can be rendered by a handler that then does
|
|
// nothing.
|
|
import { test, expect } from './fixtures';
|
|
import { createProject, createVideo, createVersion } from '../factories';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
/**
|
|
* The overflow ("more") button on the card for `title`.
|
|
*
|
|
* That button is icon-only and has no accessible name, so it cannot be reached
|
|
* by role and name. The <h3> title can, and the button is a sibling of the link
|
|
* that wraps it: h3 -> link -> the flex row that holds both. See
|
|
* components/video-card.tsx.
|
|
*/
|
|
function cardMenuFor(page: Page, title: string) {
|
|
return page
|
|
.getByRole('heading', { name: title, level: 3 })
|
|
.locator('xpath=../..')
|
|
.getByRole('button');
|
|
}
|
|
|
|
/** Puts the page into selection mode through the first card's overflow menu. */
|
|
async function enterSelectionMode(page: Page, anyTitle: string): Promise<void> {
|
|
await cardMenuFor(page, anyTitle).click();
|
|
await page.getByRole('menuitem', { name: 'Select' }).click();
|
|
await expect(page.getByText('Selection mode')).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Three videos in one project, each with an active version.
|
|
*
|
|
* The provider is `youtube`, which needs no object storage: nothing here plays
|
|
* a video, it only lists and deletes them.
|
|
*/
|
|
async function seedVideos(projectId: string, titles: string[]): Promise<void> {
|
|
for (const title of titles) {
|
|
const video = await createVideo({ projectId, title });
|
|
await createVersion({
|
|
videoParentId: video.id,
|
|
providerId: 'youtube',
|
|
providerVideoId: 'dQw4w9WgXcQ',
|
|
title,
|
|
duration: 120,
|
|
});
|
|
}
|
|
}
|
|
|
|
test('two of three videos are selected and deleted, and the third survives', async ({
|
|
page,
|
|
seed,
|
|
seededUser,
|
|
}) => {
|
|
const stamp = Date.now();
|
|
const doomedA = `Bulk Doomed A ${stamp}`;
|
|
const doomedB = `Bulk Doomed B ${stamp}`;
|
|
const survivor = `Bulk Survivor ${stamp}`;
|
|
|
|
const { project } = await seed.project(seededUser);
|
|
await seedVideos(project.id, [doomedA, doomedB, survivor]);
|
|
|
|
await page.goto(`/projects/${project.id}`);
|
|
for (const title of [doomedA, doomedB, survivor]) {
|
|
await expect(page.getByRole('heading', { name: title, level: 3 })).toBeVisible();
|
|
}
|
|
|
|
await enterSelectionMode(page, doomedA);
|
|
|
|
// Nothing is selected by the act of entering the mode, so the destructive
|
|
// button starts disabled. That is the control for the click below.
|
|
const deleteSelected = page.getByRole('button', { name: 'Delete selected' });
|
|
await expect(deleteSelected).toBeDisabled();
|
|
|
|
await page.getByRole('checkbox', { name: `Select ${doomedA}` }).click();
|
|
await page.getByRole('checkbox', { name: `Select ${doomedB}` }).click();
|
|
await expect(page.getByText('2 selected')).toBeVisible();
|
|
await expect(deleteSelected).toBeEnabled();
|
|
|
|
await deleteSelected.click();
|
|
|
|
const dialog = page.getByRole('alertdialog');
|
|
await expect(dialog.getByRole('heading', { name: 'Delete 2 videos?' })).toBeVisible();
|
|
await dialog.getByRole('button', { name: 'Delete selected' }).click();
|
|
|
|
await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0);
|
|
await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0);
|
|
await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible();
|
|
|
|
// Gone from the database, not just from the client-side list that
|
|
// handleDeleteSelected filters. A reload re-renders from the server.
|
|
await page.reload();
|
|
await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0);
|
|
await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0);
|
|
});
|
|
|
|
test('selected videos are moved into another project in the same workspace', async ({
|
|
page,
|
|
seed,
|
|
seededUser,
|
|
}) => {
|
|
const stamp = Date.now();
|
|
const moving = `Bulk Moving ${stamp}`;
|
|
const staying = `Bulk Staying ${stamp}`;
|
|
|
|
// Both projects must share a workspace: the move dialog offers only
|
|
// destinations inside it (app/api/projects/[projectId]/videos/move GET).
|
|
const { project: source, workspaceId } = await seed.project(seededUser);
|
|
const destination = await createProject({
|
|
ownerId: seededUser.id,
|
|
workspaceId,
|
|
name: `Bulk Destination ${stamp}`,
|
|
slug: `e2e-bulk-destination-${stamp}`,
|
|
});
|
|
await seedVideos(source.id, [moving, staying]);
|
|
|
|
await page.goto(`/projects/${source.id}`);
|
|
await enterSelectionMode(page, moving);
|
|
await page.getByRole('checkbox', { name: `Select ${moving}` }).click();
|
|
await expect(page.getByText('1 selected')).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Move to project' }).click();
|
|
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(
|
|
dialog.getByRole('heading', { name: 'Move video to another project' })
|
|
).toBeVisible();
|
|
|
|
// The destination list is fetched when the dialog opens; the combobox does
|
|
// not exist until it arrives.
|
|
const destinationSelect = dialog.getByRole('combobox');
|
|
await expect(destinationSelect).toBeVisible();
|
|
await destinationSelect.click();
|
|
await page.getByRole('option', { name: destination.name }).click();
|
|
await dialog.getByRole('button', { name: 'Move' }).click();
|
|
|
|
await expect(page.getByText('1 video moved')).toBeVisible();
|
|
|
|
// Left the source...
|
|
await page.reload();
|
|
await expect(page.getByRole('heading', { name: moving, level: 3 })).toHaveCount(0);
|
|
await expect(page.getByRole('heading', { name: staying, level: 3 })).toBeVisible();
|
|
|
|
// ...and arrived in the destination. Without both halves this passes for a
|
|
// delete as readily as for a move.
|
|
await page.goto(`/projects/${destination.id}`);
|
|
await expect(page.getByRole('heading', { name: moving, level: 3 })).toBeVisible();
|
|
});
|