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.
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
// Exercises lib/r2-cleanup.ts, the module that decides which objects get
|
|
// removed from storage when a video, a project or a workspace is deleted.
|
|
//
|
|
// Everything here turns on one property: the module must only ever nominate an
|
|
// object that belongs to the entity being deleted. A widened `where` clause
|
|
// costs a customer their footage, and unlike a widened read it is not
|
|
// recoverable, so the first test in each collect* block is the negative one (a
|
|
// sibling's media is left alone) and the positive one comes second.
|
|
//
|
|
// tests/setup/api.ts stubs the named helpers in `@/lib/r2` but not `r2Client`,
|
|
// which is what deleteMediaFilesBestEffort() actually reaches for. The mock
|
|
// below replaces the client with a recorder, so a test can assert on the exact
|
|
// object keys the module chose. Nothing speaks S3.
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
cleanupProjectMediaFiles,
|
|
cleanupVideoMediaFiles,
|
|
cleanupWorkspaceMediaFiles,
|
|
collectProjectMediaUrls,
|
|
collectVideoMediaUrls,
|
|
collectWorkspaceMediaUrls,
|
|
deleteMediaFilesBestEffort,
|
|
mediaUrlToKey,
|
|
} from '@/lib/r2-cleanup';
|
|
import {
|
|
createComment,
|
|
createProject,
|
|
createUser,
|
|
createVersion,
|
|
createVideo,
|
|
createVideoAsset,
|
|
createWorkspace,
|
|
seedProject,
|
|
} from '../factories';
|
|
|
|
// vi.mock factories are hoisted above every const in the file, so the recorder
|
|
// and the bucket name have to be hoisted with them.
|
|
const r2 = vi.hoisted(() => ({
|
|
bucket: 'openframe-cleanup-test-bucket',
|
|
/** Every object key handed to a DeleteObjectCommand, in call order. */
|
|
deletedKeys: [] as string[],
|
|
/** Buckets seen alongside those keys. */
|
|
deletedBuckets: [] as string[],
|
|
/** Keys the fake client refuses, to drive the best-effort failure path. */
|
|
rejectKeys: new Set<string>(),
|
|
/** Commands that were not a DeleteObjectCommand, which would be a bug. */
|
|
otherCommands: [] as string[],
|
|
}));
|
|
|
|
vi.mock('@/lib/r2', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/r2')>();
|
|
const { DeleteObjectCommand: Delete } = await import('@aws-sdk/client-s3');
|
|
return {
|
|
...actual,
|
|
R2_BUCKET_NAME: r2.bucket,
|
|
r2Client: {
|
|
send: async (command: { input?: { Bucket?: string; Key?: string } }) => {
|
|
if (!(command instanceof Delete)) {
|
|
r2.otherCommands.push(command.constructor.name);
|
|
return {};
|
|
}
|
|
const key = command.input?.Key ?? '';
|
|
if (r2.rejectKeys.has(key)) {
|
|
throw new Error(`storage refused ${key}`);
|
|
}
|
|
r2.deletedKeys.push(key);
|
|
r2.deletedBuckets.push(command.input?.Bucket ?? '');
|
|
return {};
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
r2.deletedKeys.length = 0;
|
|
r2.deletedBuckets.length = 0;
|
|
r2.otherCommands.length = 0;
|
|
r2.rejectKeys.clear();
|
|
// Non-canonical URLs are reported through console.error by design; keep the
|
|
// suite output readable without hiding a genuine failure.
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
});
|
|
|
|
// Canonical proxy URLs and the storage keys they map to. Both sides are written
|
|
// out rather than derived, so a change to the prefix scheme fails here loudly.
|
|
const OWN_COMMENT_IMAGE = '/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png';
|
|
const OWN_COMMENT_IMAGE_KEY = 'images/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png';
|
|
const OWN_COMMENT_VOICE = '/api/upload/audio/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2.webm';
|
|
const OWN_COMMENT_VOICE_KEY = 'voice/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2.webm';
|
|
const OWN_ASSET_IMAGE = '/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3.png';
|
|
const OWN_ASSET_IMAGE_KEY = 'images/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3.png';
|
|
const OWN_VERSION_VIDEO = '/api/upload/video/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4.mp4';
|
|
const OWN_VERSION_VIDEO_KEY = 'videos/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4.mp4';
|
|
const OWN_VERSION_THUMB = '/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5.jpg';
|
|
|
|
// Media belonging to a live neighbour. None of these keys may ever appear in
|
|
// r2.deletedKeys when the neighbour is out of scope.
|
|
const OTHER_COMMENT_IMAGE = '/api/upload/image/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1.png';
|
|
const OTHER_COMMENT_IMAGE_KEY = 'images/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1.png';
|
|
const OTHER_ASSET_IMAGE = '/api/upload/image/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2.png';
|
|
const OTHER_VERSION_VIDEO = '/api/upload/video/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb3.mp4';
|
|
const OTHER_VERSION_VIDEO_KEY = 'videos/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb3.mp4';
|
|
|
|
/** A video with one r2 version carrying `videoUrl`, plus a commented image. */
|
|
async function seedMediaVideo(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,
|
|
});
|
|
await createComment({ versionId: version.id, imageUrl: input.commentImageUrl });
|
|
await createVideoAsset({
|
|
videoId: video.id,
|
|
billedUserId: input.ownerId,
|
|
provider: 'R2_IMAGE',
|
|
sourceUrl: input.assetUrl,
|
|
});
|
|
return { video, version };
|
|
}
|
|
|
|
describe('mediaUrlToKey', () => {
|
|
it.each([
|
|
[OWN_COMMENT_IMAGE, OWN_COMMENT_IMAGE_KEY],
|
|
[OWN_COMMENT_VOICE, OWN_COMMENT_VOICE_KEY],
|
|
[OWN_VERSION_VIDEO, OWN_VERSION_VIDEO_KEY],
|
|
])('maps the canonical proxy URL %s to %s', (url, key) => {
|
|
expect(mediaUrlToKey(url)).toBe(key);
|
|
});
|
|
|
|
// The regexes are anchored for exactly this reason: a key derived from a
|
|
// caller-supplied path is a key that can point at somebody else's object.
|
|
it.each([
|
|
['/api/upload/image/../../videos/live.mp4', 'a traversal segment'],
|
|
['/api/upload/audio/../images/live.png', 'a traversal segment in the audio branch'],
|
|
['/api/upload/video/../../etc/passwd', 'a traversal segment in the video branch'],
|
|
['https://evil.test/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png', 'a host'],
|
|
['/api/upload/image/not-a-uuid.png', 'a non-uuid basename'],
|
|
['/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'no extension'],
|
|
['/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png?x=1', 'a query string'],
|
|
['/api/upload/image/', 'an empty basename'],
|
|
['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'an unrelated provider URL'],
|
|
['', 'an empty string'],
|
|
])('refuses to derive a key from %s (%s)', (url) => {
|
|
expect(mediaUrlToKey(url)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('deleteMediaFilesBestEffort', () => {
|
|
it('deletes each distinct key once against the configured bucket', async () => {
|
|
const result = await deleteMediaFilesBestEffort([
|
|
OWN_COMMENT_IMAGE,
|
|
OWN_COMMENT_VOICE,
|
|
// The same URL twice: one DELETE is enough, and a second one is a wasted
|
|
// request against a key that no longer exists.
|
|
OWN_COMMENT_IMAGE,
|
|
]);
|
|
|
|
expect(r2.deletedKeys).toEqual([OWN_COMMENT_IMAGE_KEY, OWN_COMMENT_VOICE_KEY]);
|
|
expect(new Set(r2.deletedBuckets)).toEqual(new Set([r2.bucket]));
|
|
expect(r2.otherCommands).toEqual([]);
|
|
expect(result).toEqual({ attempted: 2, failed: 0, failedKeys: [] });
|
|
});
|
|
|
|
it('skips non-canonical URLs and does not count them as attempted', async () => {
|
|
const result = await deleteMediaFilesBestEffort([
|
|
OWN_COMMENT_IMAGE,
|
|
'/api/upload/image/../../videos/live.mp4',
|
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
]);
|
|
|
|
expect(r2.deletedKeys).toEqual([OWN_COMMENT_IMAGE_KEY]);
|
|
expect(result.attempted).toBe(1);
|
|
expect(result.failed).toBe(0);
|
|
});
|
|
|
|
// The caller turns this into a warning on the response rather than a 500, so
|
|
// one dead object must not abort the rest of the sweep.
|
|
it('reports a refused key and still deletes the others', async () => {
|
|
r2.rejectKeys.add(OWN_COMMENT_IMAGE_KEY);
|
|
|
|
const result = await deleteMediaFilesBestEffort([
|
|
OWN_COMMENT_IMAGE,
|
|
OWN_COMMENT_VOICE,
|
|
OWN_VERSION_VIDEO,
|
|
]);
|
|
|
|
expect(r2.deletedKeys).toEqual([OWN_COMMENT_VOICE_KEY, OWN_VERSION_VIDEO_KEY]);
|
|
expect(result).toEqual({
|
|
attempted: 3,
|
|
failed: 1,
|
|
failedKeys: [OWN_COMMENT_IMAGE_KEY],
|
|
});
|
|
});
|
|
|
|
it('does nothing for an empty list', async () => {
|
|
const result = await deleteMediaFilesBestEffort([]);
|
|
|
|
expect(r2.deletedKeys).toEqual([]);
|
|
expect(result).toEqual({ attempted: 0, failed: 0, failedKeys: [] });
|
|
});
|
|
});
|
|
|
|
describe('collectVideoMediaUrls', () => {
|
|
// The load-bearing test of this file. A neighbouring video in the same
|
|
// project is live; none of its media may be nominated for deletion.
|
|
it('leaves the media of another video in the same project alone', async () => {
|
|
const scenario = await seedProject();
|
|
const { video } = await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OWN_VERSION_VIDEO,
|
|
commentImageUrl: OWN_COMMENT_IMAGE,
|
|
assetUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OTHER_VERSION_VIDEO,
|
|
commentImageUrl: OTHER_COMMENT_IMAGE,
|
|
assetUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
const urls = await collectVideoMediaUrls(video.id);
|
|
|
|
expect(new Set(urls)).toEqual(new Set([OWN_VERSION_VIDEO, OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE]));
|
|
expect(urls).not.toContain(OTHER_VERSION_VIDEO);
|
|
expect(urls).not.toContain(OTHER_COMMENT_IMAGE);
|
|
expect(urls).not.toContain(OTHER_ASSET_IMAGE);
|
|
});
|
|
|
|
it('collects comment voice and image URLs, R2 image assets and r2 version media', async () => {
|
|
const scenario = await seedProject();
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const version = await createVersion({
|
|
videoParentId: video.id,
|
|
providerId: 'r2',
|
|
originalUrl: OWN_VERSION_VIDEO,
|
|
thumbnailUrl: OWN_VERSION_THUMB,
|
|
});
|
|
await createComment({
|
|
versionId: version.id,
|
|
imageUrl: OWN_COMMENT_IMAGE,
|
|
voiceUrl: OWN_COMMENT_VOICE,
|
|
});
|
|
await createVideoAsset({
|
|
videoId: video.id,
|
|
billedUserId: scenario.owner.id,
|
|
provider: 'R2_IMAGE',
|
|
sourceUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
|
|
const urls = await collectVideoMediaUrls(video.id);
|
|
|
|
expect(new Set(urls)).toEqual(
|
|
new Set([
|
|
OWN_VERSION_VIDEO,
|
|
OWN_VERSION_THUMB,
|
|
OWN_COMMENT_IMAGE,
|
|
OWN_COMMENT_VOICE,
|
|
OWN_ASSET_IMAGE,
|
|
])
|
|
);
|
|
});
|
|
|
|
// A youtube or bunny version's originalUrl is not an object this deployment
|
|
// owns, and a BUNNY asset is cleaned up through the Bunny API instead.
|
|
it('ignores versions from other providers and assets that are not R2 images', async () => {
|
|
const scenario = await seedProject();
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
await createVersion({
|
|
videoParentId: video.id,
|
|
versionNumber: 1,
|
|
providerId: 'youtube',
|
|
originalUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/hq.jpg',
|
|
});
|
|
await createVersion({
|
|
videoParentId: video.id,
|
|
versionNumber: 2,
|
|
providerId: 'r2',
|
|
originalUrl: OWN_VERSION_VIDEO,
|
|
});
|
|
await createVideoAsset({
|
|
videoId: video.id,
|
|
billedUserId: scenario.owner.id,
|
|
provider: 'BUNNY',
|
|
providerVideoId: 'bunny-asset-1',
|
|
sourceUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
const urls = await collectVideoMediaUrls(video.id);
|
|
|
|
expect(urls).toEqual([OWN_VERSION_VIDEO]);
|
|
});
|
|
|
|
it('returns nothing for a video whose only version is hosted elsewhere', async () => {
|
|
const scenario = await seedProject();
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
await createVersion({
|
|
videoParentId: video.id,
|
|
providerId: 'youtube',
|
|
originalUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
});
|
|
|
|
expect(await collectVideoMediaUrls(video.id)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('collectProjectMediaUrls', () => {
|
|
it('leaves the media of another project in the same workspace alone', async () => {
|
|
const scenario = await seedProject();
|
|
const sibling = await createProject({
|
|
ownerId: scenario.owner.id,
|
|
workspaceId: scenario.workspace.id,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OWN_VERSION_VIDEO,
|
|
commentImageUrl: OWN_COMMENT_IMAGE,
|
|
assetUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: sibling.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OTHER_VERSION_VIDEO,
|
|
commentImageUrl: OTHER_COMMENT_IMAGE,
|
|
assetUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
const urls = await collectProjectMediaUrls(scenario.project.id);
|
|
|
|
expect(new Set(urls)).toEqual(new Set([OWN_VERSION_VIDEO, OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE]));
|
|
});
|
|
|
|
it('collects across every video in the project', async () => {
|
|
const scenario = await seedProject();
|
|
await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OWN_VERSION_VIDEO,
|
|
commentImageUrl: OWN_COMMENT_IMAGE,
|
|
assetUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OTHER_VERSION_VIDEO,
|
|
commentImageUrl: OTHER_COMMENT_IMAGE,
|
|
assetUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
const urls = await collectProjectMediaUrls(scenario.project.id);
|
|
|
|
expect(urls).toHaveLength(6);
|
|
expect(new Set(urls)).toEqual(
|
|
new Set([
|
|
OWN_VERSION_VIDEO,
|
|
OWN_COMMENT_IMAGE,
|
|
OWN_ASSET_IMAGE,
|
|
OTHER_VERSION_VIDEO,
|
|
OTHER_COMMENT_IMAGE,
|
|
OTHER_ASSET_IMAGE,
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('collectWorkspaceMediaUrls', () => {
|
|
it('leaves the media of another workspace alone', async () => {
|
|
const owner = await createUser();
|
|
const workspace = await createWorkspace({ ownerId: owner.id });
|
|
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
|
|
// Same owner, different workspace: the scoping has to come from the join,
|
|
// not from who happens to be billed.
|
|
const otherWorkspace = await createWorkspace({ ownerId: owner.id });
|
|
const otherProject = await createProject({
|
|
ownerId: owner.id,
|
|
workspaceId: otherWorkspace.id,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: project.id,
|
|
ownerId: owner.id,
|
|
videoUrl: OWN_VERSION_VIDEO,
|
|
commentImageUrl: OWN_COMMENT_IMAGE,
|
|
assetUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: otherProject.id,
|
|
ownerId: owner.id,
|
|
videoUrl: OTHER_VERSION_VIDEO,
|
|
commentImageUrl: OTHER_COMMENT_IMAGE,
|
|
assetUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
const urls = await collectWorkspaceMediaUrls(workspace.id);
|
|
|
|
expect(new Set(urls)).toEqual(new Set([OWN_VERSION_VIDEO, OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE]));
|
|
});
|
|
});
|
|
|
|
// The three cleanup* wrappers are what the delete routes call, so they are the
|
|
// place to assert on keys rather than URLs: this is the last hop before an
|
|
// object stops existing.
|
|
describe('cleanupVideoMediaFiles', () => {
|
|
it('deletes only the target video objects and leaves the sibling video objects in storage', async () => {
|
|
const scenario = await seedProject();
|
|
const { video } = await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OWN_VERSION_VIDEO,
|
|
commentImageUrl: OWN_COMMENT_IMAGE,
|
|
assetUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OTHER_VERSION_VIDEO,
|
|
commentImageUrl: OTHER_COMMENT_IMAGE,
|
|
assetUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
await cleanupVideoMediaFiles(video.id);
|
|
|
|
expect(new Set(r2.deletedKeys)).toEqual(
|
|
new Set([OWN_VERSION_VIDEO_KEY, OWN_COMMENT_IMAGE_KEY, OWN_ASSET_IMAGE_KEY])
|
|
);
|
|
expect(r2.deletedKeys).not.toContain(OTHER_VERSION_VIDEO_KEY);
|
|
expect(r2.deletedKeys).not.toContain(OTHER_COMMENT_IMAGE_KEY);
|
|
});
|
|
});
|
|
|
|
describe('cleanupProjectMediaFiles', () => {
|
|
it('deletes only the target project objects', async () => {
|
|
const scenario = await seedProject();
|
|
const sibling = await createProject({
|
|
ownerId: scenario.owner.id,
|
|
workspaceId: scenario.workspace.id,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: scenario.project.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OWN_VERSION_VIDEO,
|
|
commentImageUrl: OWN_COMMENT_IMAGE,
|
|
assetUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: sibling.id,
|
|
ownerId: scenario.owner.id,
|
|
videoUrl: OTHER_VERSION_VIDEO,
|
|
commentImageUrl: OTHER_COMMENT_IMAGE,
|
|
assetUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
await cleanupProjectMediaFiles(scenario.project.id);
|
|
|
|
expect(new Set(r2.deletedKeys)).toEqual(
|
|
new Set([OWN_VERSION_VIDEO_KEY, OWN_COMMENT_IMAGE_KEY, OWN_ASSET_IMAGE_KEY])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('cleanupWorkspaceMediaFiles', () => {
|
|
it('deletes only the target workspace objects', async () => {
|
|
const owner = await createUser();
|
|
const workspace = await createWorkspace({ ownerId: owner.id });
|
|
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
|
|
const otherWorkspace = await createWorkspace({ ownerId: owner.id });
|
|
const otherProject = await createProject({
|
|
ownerId: owner.id,
|
|
workspaceId: otherWorkspace.id,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: project.id,
|
|
ownerId: owner.id,
|
|
videoUrl: OWN_VERSION_VIDEO,
|
|
commentImageUrl: OWN_COMMENT_IMAGE,
|
|
assetUrl: OWN_ASSET_IMAGE,
|
|
});
|
|
await seedMediaVideo({
|
|
projectId: otherProject.id,
|
|
ownerId: owner.id,
|
|
videoUrl: OTHER_VERSION_VIDEO,
|
|
commentImageUrl: OTHER_COMMENT_IMAGE,
|
|
assetUrl: OTHER_ASSET_IMAGE,
|
|
});
|
|
|
|
await cleanupWorkspaceMediaFiles(workspace.id);
|
|
|
|
expect(new Set(r2.deletedKeys)).toEqual(
|
|
new Set([OWN_VERSION_VIDEO_KEY, OWN_COMMENT_IMAGE_KEY, OWN_ASSET_IMAGE_KEY])
|
|
);
|
|
expect(r2.deletedKeys).not.toContain(OTHER_VERSION_VIDEO_KEY);
|
|
});
|
|
});
|