Files
OpenFrame/tests/e2e/failure-recovery.spec.ts
T
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine
agents wrote suites in parallel against private databases, then a tenth read all
of it adversarially and five of its findings were fixed.

  unit + component  2076 -> 2079 (+888 over the round)
  api                647 -> 1015
  e2e                 18 -> 29

What was closed:

- lib/route-access.ts, the page-level authorization layer, went from zero tests
  to 48. Every API route was guarded and none of the pages were.
- The five media proxy routes now have a real 2xx beside every 403. The blocker
  was the positive control, solved by stubbing r2Client.send() and leaving
  lib/r2-media-proxy.ts itself real.
- Every remaining server-side lib module: invitations, email verification, the
  upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle,
  notifications and admin stats.
- Six video-page hooks, and the chunking arithmetic extracted out of
  lib/client/r2-video-upload.ts as a pure module.
- Five end-to-end flows: workspace members, bulk operations, the admin area,
  player interaction and failure recovery.

Three things about the harness itself turned out to be wrong:

- Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every
  route reaching finalizeR2VideoUpload silently took the "not a valid video"
  branch and no test noticed.
- The auth matrix asserted only "not 2xx", which two entries satisfied without
  their guard existing. It now requires 401 or 403, which makes both
  load-bearing, and all 60 routes pass the stricter form.
- Both admin API routes had no positive control anywhere: replacing their guard
  with an unconditional refusal left the entire suite green. Found by the
  adversarial review, now covered.

Process:

- bun run test:mutation runs StrykerJS over the authorization and validation
  modules. Diagnostic, not a gate, weekly in CI rather than on a push.
- playwright.config.ts gains an opt-in webkit project for the player spec.
- AGENTS.md now requires a batch of new tests to be reviewed by somebody who
  did not write them.

Only two production files change, both deliberate: lib/auth.ts loses a verbatim
copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the
extracted arithmetic. No behaviour change in either.
2026-07-26 13:25:11 +07:00

181 lines
8.2 KiB
TypeScript

// What the user is shown when something fails: an upload that dies mid-flight,
// a mutation that comes back 500, and a page whose data never arrives.
//
// The failures are injected with `page.route`, which is the only honest way in:
// the server under test is a production build with no fault injection, and
// tearing down MinIO or Postgres mid-run would take the other workers with it.
//
// Every test here has a positive control. Asserting "an error message appeared"
// is worth nothing on its own, because a page that renders an error for every
// request would pass it; each test therefore also proves the same flow succeeds
// once the interception is removed, or that the data the failed request would
// have changed is still exactly as it was.
import path from 'node:path';
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { createVideo, createVersion } from '../factories';
import { REPO_ROOT } from '../helpers/env';
import { db } from '@/lib/db';
const SAMPLE_VIDEO = path.join(REPO_ROOT, 'tests', 'fixtures', 'sample.mp4');
test.setTimeout(120_000);
/** A video with an active version, cheap enough to make several of. */
async function seedVideo(projectId: string, title: string): Promise<void> {
const video = await createVideo({ projectId, title });
await createVersion({
videoParentId: video.id,
providerId: 'youtube',
providerVideoId: 'dQw4w9WgXcQ',
title,
duration: 120,
});
}
/** Puts the project page into selection mode via the first card's menu. */
async function enterSelectionMode(page: Page, anyTitle: string): Promise<void> {
await page
.getByRole('heading', { name: anyTitle, level: 3 })
.locator('xpath=../..')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Select' }).click();
await expect(page.getByText('Selection mode')).toBeVisible();
}
test('an upload that fails at the storage PUT leaves the form up and creates nothing', async ({
page,
seed,
seededUser,
}) => {
const { project } = await seed.project(seededUser);
// Only the bytes are refused. The presign (`r2-init`) and everything else the
// app serves still work, so the failure is exactly the one this test claims:
// object storage rejected the upload halfway through.
await page.route('http://minio-test:9000/**', async (route) => {
if (route.request().method() !== 'PUT') {
await route.continue();
return;
}
await route.fulfill({ status: 500, contentType: 'text/plain', body: 'storage is down' });
});
await page.goto(`/projects/${project.id}/videos/new`);
await page.getByRole('tab', { name: 'Direct Upload' }).click();
await page.getByLabel('Video Files').setInputFiles(SAMPLE_VIDEO);
await page.getByLabel('Title').fill('Doomed Upload');
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
// The status reaches the user rather than being flattened into "something
// went wrong": lib/client/r2-video-upload.ts builds the message from the XHR
// status and handleSubmit's outer catch renders `error.message` verbatim.
// (A single file takes uploadSingleFileWithForm, which is why the message has
// no `sample.mp4:` prefix; only the multi-file loop adds one.)
await expect(page.getByText('Upload failed with status 500')).toBeVisible({
timeout: 60_000,
});
// Still on the form, so the file list and the title survive for a retry.
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}/videos/new$`));
await expect(page.getByLabel('Title')).toHaveValue('Doomed Upload');
// Nothing half-created. A version row pointing at an object that was never
// stored would be worse than the failure itself.
await expect.poll(() => db.video.count({ where: { projectId: project.id } })).toEqual(0);
// Positive control: with storage healthy the very same steps succeed, so the
// assertions above are about the injected failure and not about the form
// being broken.
await page.unroute('http://minio-test:9000/**');
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}$`), { timeout: 90_000 });
await expect(page.getByRole('heading', { name: 'Doomed Upload', level: 3 })).toBeVisible();
});
test('a bulk delete that comes back 500 says so and leaves every video in place', async ({
page,
seed,
seededUser,
}) => {
const stamp = Date.now();
const first = `Recovery Keep A ${stamp}`;
const second = `Recovery Keep B ${stamp}`;
const { project } = await seed.project(seededUser);
await seedVideo(project.id, first);
await seedVideo(project.id, second);
// A non-JSON body on purpose: it drives the client's own fallback message
// rather than echoing a string this test supplied.
await page.route('**/api/projects/*/videos/bulk-delete', (route) =>
route.fulfill({ status: 500, contentType: 'text/plain', body: 'boom' })
);
await page.goto(`/projects/${project.id}`);
await enterSelectionMode(page, first);
await page.getByRole('checkbox', { name: `Select ${first}` }).click();
await page.getByRole('checkbox', { name: `Select ${second}` }).click();
await expect(page.getByText('2 selected')).toBeVisible();
await page.getByRole('button', { name: 'Delete selected' }).click();
const dialog = page.getByRole('alertdialog');
await dialog.getByRole('button', { name: 'Delete selected' }).click();
await expect(page.getByText('Failed to delete selected videos')).toBeVisible();
// The dialog stays open so the failed action can be retried from where it
// was. It has to be dismissed before the cards behind it can be asserted on:
// Radix marks everything outside an open alertdialog aria-hidden, so a
// heading behind it is not in the accessibility tree at all.
await expect(dialog.getByRole('button', { name: 'Delete selected' })).toBeEnabled();
await dialog.getByRole('button', { name: 'Cancel' }).click();
// The optimistic filter in handleDeleteSelected must not have run: both cards
// are still on screen, and both rows are still in the database.
await expect(page.getByRole('heading', { name: first, level: 3 })).toBeVisible();
await expect(page.getByRole('heading', { name: second, level: 3 })).toBeVisible();
expect(await db.video.count({ where: { projectId: project.id } })).toEqual(2);
// Positive control: the same click succeeds once the route is released, which
// proves the selection and the confirm dialog were driving a real request.
await page.unroute('**/api/projects/*/videos/bulk-delete');
await page.getByRole('button', { name: 'Delete selected' }).click();
await dialog.getByRole('button', { name: 'Delete selected' }).click();
await expect(page.getByText('2 videos deleted')).toBeVisible();
await expect.poll(() => db.video.count({ where: { projectId: project.id } })).toEqual(0);
});
test('a video page whose data request 500s offers a way back instead of an empty player', async ({
page,
seed,
seededUser,
}) => {
const seeded = await seed.version(seededUser, { title: `Recovery Video ${Date.now()}` });
const videoRequest = /\/api\/projects\/[^/]+\/videos\/[^/?]+\?includeComments=false/;
await page.route(videoRequest, (route) =>
route.fulfill({ status: 500, contentType: 'text/plain', body: 'database unavailable' })
);
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
// The status is surfaced rather than swallowed into a generic spinner.
await expect(page.getByText(/Failed to load video: 500/)).toBeVisible();
await expect(page.getByPlaceholder('Add a comment...')).toHaveCount(0);
// The escape hatch actually goes somewhere.
await page.getByRole('link', { name: 'Back to Project' }).click();
await expect(page).toHaveURL(new RegExp(`/projects/${seeded.project.id}$`));
// Positive control: without the interception the same URL renders the player
// page, so the error state above was caused by the 500 and not by the video
// being unreachable for some other reason.
await page.unroute(videoRequest);
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
await expect(page.getByPlaceholder('Add a comment...')).toBeVisible();
await expect(page.getByText(/Failed to load video/)).toHaveCount(0);
});