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.
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
// 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]));
|
|
});
|
|
});
|