mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
// Exercises lib/video-delete.ts, the cascade behind the bulk-delete route.
|
||||
//
|
||||
// The module does three things in a fixed order and the order is the whole
|
||||
// story: it reads the media URLs while the rows still exist, it deletes the
|
||||
// rows, and only then does it talk to storage. Reading the URLs first is
|
||||
// mandatory (the cascade takes the comment rows with the video), and deleting
|
||||
// the rows first means a storage failure cannot be retried, which is a
|
||||
// behaviour the tests below pin down rather than paper over.
|
||||
//
|
||||
// tests/setup/api.ts does not stub `r2Client`, which deleteMediaFilesBestEffort
|
||||
// reaches through, so this file replaces it with a recorder. Bunny goes over
|
||||
// fetch(), which is stubbed per test.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/lib/db';
|
||||
import { deleteProjectVideosWithCleanup } from '@/lib/video-delete';
|
||||
import {
|
||||
createComment,
|
||||
createProject,
|
||||
createUser,
|
||||
createVersion,
|
||||
createVideo,
|
||||
createVideoAsset,
|
||||
createWorkspace,
|
||||
seedProject,
|
||||
} from '../factories';
|
||||
|
||||
const r2 = vi.hoisted(() => ({
|
||||
bucket: 'openframe-delete-test-bucket',
|
||||
deletedKeys: [] as string[],
|
||||
rejectKeys: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/r2', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/r2')>();
|
||||
return {
|
||||
...actual,
|
||||
R2_BUCKET_NAME: r2.bucket,
|
||||
r2Client: {
|
||||
send: async (command: { input?: { Key?: string } }) => {
|
||||
const key = command.input?.Key ?? '';
|
||||
if (r2.rejectKeys.has(key)) throw new Error(`storage refused ${key}`);
|
||||
r2.deletedKeys.push(key);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const TARGET_VIDEO_URL = '/api/upload/video/cccccccc-cccc-4ccc-8ccc-ccccccccccc1.mp4';
|
||||
const TARGET_VIDEO_KEY = 'videos/cccccccc-cccc-4ccc-8ccc-ccccccccccc1.mp4';
|
||||
const TARGET_COMMENT_IMAGE = '/api/upload/image/cccccccc-cccc-4ccc-8ccc-ccccccccccc2.png';
|
||||
const TARGET_COMMENT_IMAGE_KEY = 'images/cccccccc-cccc-4ccc-8ccc-ccccccccccc2.png';
|
||||
const TARGET_ASSET_IMAGE = '/api/upload/image/cccccccc-cccc-4ccc-8ccc-ccccccccccc3.png';
|
||||
const TARGET_ASSET_IMAGE_KEY = 'images/cccccccc-cccc-4ccc-8ccc-ccccccccccc3.png';
|
||||
|
||||
const SURVIVOR_VIDEO_URL = '/api/upload/video/dddddddd-dddd-4ddd-8ddd-ddddddddddd1.mp4';
|
||||
const SURVIVOR_VIDEO_KEY = 'videos/dddddddd-dddd-4ddd-8ddd-ddddddddddd1.mp4';
|
||||
const SURVIVOR_COMMENT_IMAGE = '/api/upload/image/dddddddd-dddd-4ddd-8ddd-ddddddddddd2.png';
|
||||
const SURVIVOR_COMMENT_IMAGE_KEY = 'images/dddddddd-dddd-4ddd-8ddd-ddddddddddd2.png';
|
||||
|
||||
beforeEach(() => {
|
||||
r2.deletedKeys.length = 0;
|
||||
r2.rejectKeys.clear();
|
||||
vi.mocked(revalidatePath).mockClear();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** A video with an r2 version, a commented image and an R2 image asset. */
|
||||
async function seedDeletableVideo(input: {
|
||||
projectId: string;
|
||||
ownerId: string;
|
||||
videoUrl: string;
|
||||
commentImageUrl: string;
|
||||
assetUrl?: string;
|
||||
}) {
|
||||
const video = await createVideo({ projectId: input.projectId });
|
||||
const version = await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'r2',
|
||||
originalUrl: input.videoUrl,
|
||||
});
|
||||
const comment = await createComment({
|
||||
versionId: version.id,
|
||||
imageUrl: input.commentImageUrl,
|
||||
});
|
||||
const asset = input.assetUrl
|
||||
? await createVideoAsset({
|
||||
videoId: video.id,
|
||||
billedUserId: input.ownerId,
|
||||
provider: 'R2_IMAGE',
|
||||
sourceUrl: input.assetUrl,
|
||||
})
|
||||
: null;
|
||||
return { video, version, comment, asset };
|
||||
}
|
||||
|
||||
describe('deleteProjectVideosWithCleanup input validation', () => {
|
||||
it('throws EMPTY_VIDEO_IDS for an empty list', async () => {
|
||||
const scenario = await seedProject();
|
||||
|
||||
await expect(deleteProjectVideosWithCleanup(scenario.project.id, [])).rejects.toThrow(
|
||||
'EMPTY_VIDEO_IDS'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws VIDEO_NOT_FOUND for an id that does not exist', async () => {
|
||||
const scenario = await seedProject();
|
||||
|
||||
await expect(
|
||||
deleteProjectVideosWithCleanup(scenario.project.id, ['no-such-video'])
|
||||
).rejects.toThrow('VIDEO_NOT_FOUND');
|
||||
});
|
||||
|
||||
// The projectId in the lookup is the only thing stopping a caller who is an
|
||||
// admin of project A from naming a video in project B. If the lookup ever
|
||||
// stopped scoping on it, this is where it shows.
|
||||
it('refuses the whole batch and deletes nothing when one id belongs to another project', async () => {
|
||||
const scenario = await seedProject();
|
||||
const otherProject = await createProject({
|
||||
ownerId: scenario.owner.id,
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
const mine = await createVideo({ projectId: scenario.project.id });
|
||||
const theirs = await createVideo({ projectId: otherProject.id });
|
||||
|
||||
await expect(
|
||||
deleteProjectVideosWithCleanup(scenario.project.id, [mine.id, theirs.id])
|
||||
).rejects.toThrow('VIDEO_NOT_FOUND');
|
||||
|
||||
expect(await db.video.count()).toBe(2);
|
||||
expect(r2.deletedKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteProjectVideosWithCleanup cascade', () => {
|
||||
it('removes the video with its versions, comments and assets', async () => {
|
||||
const scenario = await seedProject();
|
||||
const seeded = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
assetUrl: TARGET_ASSET_IMAGE,
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [seeded.video.id]);
|
||||
|
||||
expect(result.deletedCount).toBe(1);
|
||||
expect(await db.video.count()).toBe(0);
|
||||
expect(await db.videoVersion.count()).toBe(0);
|
||||
expect(await db.comment.count()).toBe(0);
|
||||
expect(await db.videoAsset.count()).toBe(0);
|
||||
});
|
||||
|
||||
// The counterpart of the cascade: everything not named survives, rows and
|
||||
// objects alike.
|
||||
it('leaves a sibling video in the same project untouched, rows and objects', async () => {
|
||||
const scenario = await seedProject();
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
assetUrl: TARGET_ASSET_IMAGE,
|
||||
});
|
||||
const survivor = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: SURVIVOR_VIDEO_URL,
|
||||
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
|
||||
|
||||
expect((await db.video.findMany({ select: { id: true } })).map((row) => row.id)).toEqual([
|
||||
survivor.video.id,
|
||||
]);
|
||||
expect(await db.videoVersion.count()).toBe(1);
|
||||
expect(await db.comment.count()).toBe(1);
|
||||
expect(r2.deletedKeys).not.toContain(SURVIVOR_VIDEO_KEY);
|
||||
expect(r2.deletedKeys).not.toContain(SURVIVOR_COMMENT_IMAGE_KEY);
|
||||
});
|
||||
|
||||
it('leaves a video in another project of the same workspace untouched', async () => {
|
||||
const scenario = await seedProject();
|
||||
const otherProject = await createProject({
|
||||
ownerId: scenario.owner.id,
|
||||
workspaceId: scenario.workspace.id,
|
||||
});
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
await seedDeletableVideo({
|
||||
projectId: otherProject.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: SURVIVOR_VIDEO_URL,
|
||||
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
|
||||
|
||||
expect(await db.video.count()).toBe(1);
|
||||
expect(r2.deletedKeys).not.toContain(SURVIVOR_VIDEO_KEY);
|
||||
});
|
||||
|
||||
it('deletes exactly the storage objects the removed video referenced', async () => {
|
||||
const scenario = await seedProject();
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
assetUrl: TARGET_ASSET_IMAGE,
|
||||
});
|
||||
await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: SURVIVOR_VIDEO_URL,
|
||||
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
|
||||
|
||||
expect(new Set(r2.deletedKeys)).toEqual(
|
||||
new Set([TARGET_VIDEO_KEY, TARGET_COMMENT_IMAGE_KEY, TARGET_ASSET_IMAGE_KEY])
|
||||
);
|
||||
});
|
||||
|
||||
it('counts a repeated id once', async () => {
|
||||
const scenario = await seedProject();
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [
|
||||
target.video.id,
|
||||
target.video.id,
|
||||
]);
|
||||
|
||||
// Without the dedupe, videos.length (1) would not match uniqueVideoIds
|
||||
// (2) and the call would throw VIDEO_NOT_FOUND on a perfectly valid request.
|
||||
expect(result.deletedCount).toBe(1);
|
||||
expect(await db.video.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('deletes several videos in one call and reports the count', async () => {
|
||||
const scenario = await seedProject();
|
||||
const first = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
const second = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: SURVIVOR_VIDEO_URL,
|
||||
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [
|
||||
first.video.id,
|
||||
second.video.id,
|
||||
]);
|
||||
|
||||
expect(result.deletedCount).toBe(2);
|
||||
expect(await db.video.count()).toBe(0);
|
||||
expect(new Set(r2.deletedKeys)).toEqual(
|
||||
new Set([
|
||||
TARGET_VIDEO_KEY,
|
||||
TARGET_COMMENT_IMAGE_KEY,
|
||||
SURVIVOR_VIDEO_KEY,
|
||||
SURVIVOR_COMMENT_IMAGE_KEY,
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('revalidates the project page so the video list is not served from cache', async () => {
|
||||
const scenario = await seedProject();
|
||||
const target = await createVideo({ projectId: scenario.project.id });
|
||||
|
||||
await deleteProjectVideosWithCleanup(scenario.project.id, [target.id]);
|
||||
|
||||
expect(vi.mocked(revalidatePath)).toHaveBeenCalledWith(`/projects/${scenario.project.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteProjectVideosWithCleanup and Bunny', () => {
|
||||
/** Stubs the Bunny API and records the URL of every request made to it. */
|
||||
function stubBunny(status: number) {
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '9999');
|
||||
const requestedUrls: string[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
requestedUrls.push(String(input));
|
||||
return new Response(null, { status });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return { fetchMock, requestedUrls };
|
||||
}
|
||||
|
||||
it('asks Bunny to delete bunny versions and bunny assets, and nothing else', async () => {
|
||||
const { requestedUrls } = stubBunny(200);
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
versionNumber: 1,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-version-id-1',
|
||||
});
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
versionNumber: 2,
|
||||
providerId: 'youtube',
|
||||
providerVideoId: 'youtube-video-id-1',
|
||||
});
|
||||
await createVideoAsset({
|
||||
videoId: video.id,
|
||||
billedUserId: scenario.owner.id,
|
||||
provider: 'BUNNY',
|
||||
providerVideoId: 'bunny-asset-id-1',
|
||||
sourceUrl: 'https://iframe.mediadelivery.net/play/9999/bunny-asset-id-1',
|
||||
});
|
||||
await createVideoAsset({
|
||||
videoId: video.id,
|
||||
billedUserId: scenario.owner.id,
|
||||
provider: 'YOUTUBE',
|
||||
providerVideoId: 'youtube-asset-id-1',
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [video.id]);
|
||||
|
||||
expect(new Set(requestedUrls)).toEqual(
|
||||
new Set([
|
||||
'https://video.bunnycdn.com/library/9999/videos/bunny-version-id-1',
|
||||
'https://video.bunnycdn.com/library/9999/videos/bunny-asset-id-1',
|
||||
])
|
||||
);
|
||||
expect(result.cleanupInput.bunny).toEqual({ attempted: 2, failed: 0, failedIds: [] });
|
||||
expect(result.cleanupWarnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('makes no Bunny request when the video has no bunny media', async () => {
|
||||
const { fetchMock } = stubBunny(200);
|
||||
const scenario = await seedProject();
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result.cleanupInput.bunny.attempted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteProjectVideosWithCleanup when storage fails', () => {
|
||||
// The rows go first and the objects go second, with no transaction spanning
|
||||
// the two. A refused DELETE therefore leaves the object behind with nothing
|
||||
// in the database still pointing at it: the caller cannot retry, because the
|
||||
// video id it would retry with no longer resolves. The result object is the
|
||||
// only trace, which is why the warnings are asserted here.
|
||||
it('still removes the rows and surfaces the orphaned key as a warning', async () => {
|
||||
const scenario = await seedProject();
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
r2.rejectKeys.add(TARGET_VIDEO_KEY);
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
|
||||
|
||||
expect(result.deletedCount).toBe(1);
|
||||
// The row is gone even though its object is not.
|
||||
expect(await db.video.count()).toBe(0);
|
||||
expect(result.cleanupInput.r2).toEqual({
|
||||
attempted: 2,
|
||||
failed: 1,
|
||||
failedKeys: [TARGET_VIDEO_KEY],
|
||||
});
|
||||
expect(result.cleanupWarnings).toEqual({ r2: { attempted: 2, failed: 1 } });
|
||||
// The rest of the sweep still ran.
|
||||
expect(r2.deletedKeys).toEqual([TARGET_COMMENT_IMAGE_KEY]);
|
||||
});
|
||||
|
||||
it('reports a Bunny failure without failing the delete', async () => {
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '9999');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 500 }))
|
||||
);
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-version-id-2',
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [video.id]);
|
||||
|
||||
expect(await db.video.count()).toBe(0);
|
||||
expect(result.cleanupWarnings).toEqual({ bunny: { attempted: 1, failed: 1 } });
|
||||
});
|
||||
|
||||
it('reports no warnings when both providers succeed', async () => {
|
||||
const scenario = await seedProject();
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
|
||||
|
||||
expect(result.cleanupWarnings).toBeUndefined();
|
||||
expect(result.cleanupInput.r2.failed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteProjectVideosWithCleanup media collection order', () => {
|
||||
// collectVideoMediaUrls runs before the deleteMany. If it ran after, the
|
||||
// cascade would already have taken the comment rows and their images would
|
||||
// stay in storage forever, silently.
|
||||
it('deletes comment media even though the cascade removes the comment rows', async () => {
|
||||
const scenario = await seedProject();
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: scenario.project.id,
|
||||
ownerId: scenario.owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
|
||||
|
||||
expect(await db.comment.count()).toBe(0);
|
||||
expect(r2.deletedKeys).toContain(TARGET_COMMENT_IMAGE_KEY);
|
||||
});
|
||||
|
||||
it('scopes media collection per video so two videos sharing a project do not cross over', async () => {
|
||||
const owner = await createUser();
|
||||
const workspace = await createWorkspace({ ownerId: owner.id });
|
||||
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
|
||||
const target = await seedDeletableVideo({
|
||||
projectId: project.id,
|
||||
ownerId: owner.id,
|
||||
videoUrl: TARGET_VIDEO_URL,
|
||||
commentImageUrl: TARGET_COMMENT_IMAGE,
|
||||
});
|
||||
await seedDeletableVideo({
|
||||
projectId: project.id,
|
||||
ownerId: owner.id,
|
||||
videoUrl: SURVIVOR_VIDEO_URL,
|
||||
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
|
||||
});
|
||||
|
||||
const result = await deleteProjectVideosWithCleanup(project.id, [target.video.id]);
|
||||
|
||||
expect(result.cleanupInput.r2.attempted).toBe(2);
|
||||
expect(new Set(r2.deletedKeys)).toEqual(new Set([TARGET_VIDEO_KEY, TARGET_COMMENT_IMAGE_KEY]));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user