|
| 1 | +import { User, Video } from "@cap/web-domain"; |
| 2 | +import { Effect } from "effect"; |
| 3 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | + |
| 5 | +const mocks = vi.hoisted(() => ({ |
| 6 | + video: {} as Record<string, unknown>, |
| 7 | + upload: undefined as Record<string, unknown> | undefined, |
| 8 | + edit: undefined as Record<string, unknown> | undefined, |
| 9 | + writes: [] as string[], |
| 10 | + auth: vi.fn(), |
| 11 | + start: vi.fn(), |
| 12 | + quiescent: vi.fn(), |
| 13 | + head: vi.fn(), |
| 14 | + copy: vi.fn(), |
| 15 | + access: vi.fn(), |
| 16 | + fetch: vi.fn(), |
| 17 | + clear: vi.fn(), |
| 18 | +})); |
| 19 | +vi.mock("@cap/database/schema", () => ({ |
| 20 | + videos: { name: "video", id: "id" }, |
| 21 | + videoUploads: { name: "upload", videoId: "videoId" }, |
| 22 | + videoEdits: { name: "edit", videoId: "videoId" }, |
| 23 | +})); |
| 24 | +vi.mock("@cap/database", () => { |
| 25 | + const client = { |
| 26 | + select: () => ({ |
| 27 | + from: (table: { name: "video" | "upload" | "edit" }) => ({ |
| 28 | + where: () => { |
| 29 | + const rows = mocks[table.name] |
| 30 | + ? [structuredClone(mocks[table.name])] |
| 31 | + : []; |
| 32 | + return Object.assign(Promise.resolve(rows), { |
| 33 | + for: async () => rows, |
| 34 | + }); |
| 35 | + }, |
| 36 | + }), |
| 37 | + }), |
| 38 | + insert: (table: { name: string }) => ({ |
| 39 | + values: async (data: Record<string, unknown>) => { |
| 40 | + mocks.writes.push(`insert-${table.name}`); |
| 41 | + mocks.upload = data; |
| 42 | + }, |
| 43 | + }), |
| 44 | + delete: (table: { name: string }) => ({ |
| 45 | + where: async () => { |
| 46 | + mocks.writes.push(`delete-${table.name}`); |
| 47 | + mocks.upload = undefined; |
| 48 | + }, |
| 49 | + }), |
| 50 | + update: (table: { name: string }) => ({ |
| 51 | + set: (data: Record<string, unknown>) => ({ |
| 52 | + where: async () => { |
| 53 | + mocks.writes.push(`update-${table.name}`); |
| 54 | + Object.assign(mocks.video, data); |
| 55 | + }, |
| 56 | + }), |
| 57 | + }), |
| 58 | + }; |
| 59 | + return { |
| 60 | + db: () => ({ |
| 61 | + ...client, |
| 62 | + transaction: async (run: (tx: typeof client) => Promise<unknown>) => { |
| 63 | + const snapshot = structuredClone({ |
| 64 | + video: mocks.video, |
| 65 | + upload: mocks.upload, |
| 66 | + writes: mocks.writes, |
| 67 | + }); |
| 68 | + try { |
| 69 | + return await run(client); |
| 70 | + } catch (error) { |
| 71 | + Object.assign(mocks, snapshot); |
| 72 | + throw error; |
| 73 | + } |
| 74 | + }, |
| 75 | + }), |
| 76 | + }; |
| 77 | +}); |
| 78 | +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: mocks.auth })); |
| 79 | +vi.mock("@cap/utils", () => ({ |
| 80 | + userIsPro: (user: { isPro: boolean }) => user.isPro, |
| 81 | +})); |
| 82 | +vi.mock("@cap/env", () => ({ |
| 83 | + serverEnv: () => ({ MEDIA_SERVER_URL: "https://media.test" }), |
| 84 | +})); |
| 85 | +vi.mock("@cap/web-backend", () => ({ |
| 86 | + Storage: { getAccessForVideo: mocks.access }, |
| 87 | +})); |
| 88 | +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); |
| 89 | +vi.mock("workflow/api", () => ({ start: mocks.start })); |
| 90 | +vi.mock("@/workflows/edit-video", () => ({ |
| 91 | + editVideoWorkflow: Object.assign(vi.fn(), { workflowId: "edit-workflow" }), |
| 92 | +})); |
| 93 | +vi.mock("@/lib/legacy-video-edit-recovery", () => ({ |
| 94 | + assertLegacyEditsQuiescent: mocks.quiescent, |
| 95 | +})); |
| 96 | +vi.mock("@/lib/server", async () => ({ |
| 97 | + runPromise: (await import("effect")).Effect.runPromise, |
| 98 | +})); |
| 99 | +vi.mock("@/lib/video-storage", () => ({ |
| 100 | + decodeStorageVideo: (video: unknown) => video, |
| 101 | +})); |
| 102 | +vi.mock("@/lib/video-edit-operation", () => ({ |
| 103 | + clearPendingEdit: mocks.clear, |
| 104 | +})); |
| 105 | +vi.mock("@/utils/flags", () => ({ isAiGenerationEnabled: async () => false })); |
| 106 | + |
| 107 | +import { |
| 108 | + restoreVideoToOriginal, |
| 109 | + saveVideoEdits, |
| 110 | +} from "@/actions/videos/save-edits"; |
| 111 | + |
| 112 | +const videoId = Video.VideoId.make("video"); |
| 113 | +const sourceKey = "owner/video/source/original.mp4"; |
| 114 | +const trim = { |
| 115 | + version: 1 as const, |
| 116 | + sourceDuration: 10, |
| 117 | + keepRanges: [{ start: 0, end: 5 }], |
| 118 | +}; |
| 119 | + |
| 120 | +beforeEach(() => { |
| 121 | + vi.resetAllMocks(); |
| 122 | + mocks.video = { |
| 123 | + id: videoId, |
| 124 | + ownerId: User.UserId.make("owner"), |
| 125 | + bucket: null, |
| 126 | + storageIntegrationId: null, |
| 127 | + source: { type: "webMP4" }, |
| 128 | + duration: 10, |
| 129 | + metadata: null, |
| 130 | + }; |
| 131 | + mocks.upload = undefined; |
| 132 | + mocks.edit = undefined; |
| 133 | + mocks.writes = []; |
| 134 | + mocks.auth.mockResolvedValue({ id: "owner", isPro: true }); |
| 135 | + mocks.head.mockReturnValue(Effect.succeed({})); |
| 136 | + mocks.copy.mockReturnValue(Effect.void); |
| 137 | + mocks.access.mockReturnValue( |
| 138 | + Effect.succeed([ |
| 139 | + { |
| 140 | + bucketName: "bucket", |
| 141 | + headObject: mocks.head, |
| 142 | + copyObject: mocks.copy, |
| 143 | + getInternalSignedObjectUrl: () => |
| 144 | + Effect.succeed("https://storage.test/original.mp4"), |
| 145 | + }, |
| 146 | + ]), |
| 147 | + ); |
| 148 | + mocks.fetch.mockResolvedValue( |
| 149 | + new Response(JSON.stringify({ metadata: { duration: 20 } }), { |
| 150 | + status: 200, |
| 151 | + }), |
| 152 | + ); |
| 153 | + vi.stubGlobal("fetch", mocks.fetch); |
| 154 | +}); |
| 155 | +afterEach(() => vi.unstubAllGlobals()); |
| 156 | + |
| 157 | +function legacyUpload() { |
| 158 | + mocks.upload = { |
| 159 | + videoId, |
| 160 | + rawFileKey: sourceKey, |
| 161 | + phase: "processing", |
| 162 | + startedAt: new Date("2026-09-01T00:00:00Z"), |
| 163 | + updatedAt: new Date("2026-09-01T01:00:00Z"), |
| 164 | + }; |
| 165 | +} |
| 166 | + |
| 167 | +describe("video edit claims and recovery", () => { |
| 168 | + it("claims the recording before copying its original source", async () => { |
| 169 | + mocks.head.mockReturnValue(Effect.fail(new Error("Missing"))); |
| 170 | + mocks.copy.mockImplementation(() => { |
| 171 | + expect(mocks.upload?.phase).toBe("processing"); |
| 172 | + return Effect.void; |
| 173 | + }); |
| 174 | + await saveVideoEdits(videoId, trim); |
| 175 | + expect(mocks.copy).toHaveBeenCalledOnce(); |
| 176 | + expect(mocks.start).toHaveBeenCalledOnce(); |
| 177 | + }); |
| 178 | + it("releases a pending claim when the workflow start fails", async () => { |
| 179 | + mocks.start.mockRejectedValue(new Error("Enqueue failed")); |
| 180 | + await expect(saveVideoEdits(videoId, trim)).rejects.toThrow( |
| 181 | + "Enqueue failed", |
| 182 | + ); |
| 183 | + expect(mocks.clear).toHaveBeenCalledWith( |
| 184 | + videoId, |
| 185 | + sourceKey, |
| 186 | + expect.objectContaining({ token: expect.any(String) }), |
| 187 | + ); |
| 188 | + }); |
| 189 | + it("preserves a legacy upload when recovery checks fail", async () => { |
| 190 | + legacyUpload(); |
| 191 | + const upload = structuredClone(mocks.upload); |
| 192 | + mocks.quiescent.mockRejectedValue(new Error("Still running")); |
| 193 | + await expect(restoreVideoToOriginal(videoId)).rejects.toThrow( |
| 194 | + "Still running", |
| 195 | + ); |
| 196 | + expect(mocks.upload).toEqual(upload); |
| 197 | + expect(mocks.writes).toEqual([]); |
| 198 | + expect(mocks.fetch).not.toHaveBeenCalled(); |
| 199 | + }); |
| 200 | + it("restores an interrupted first edit using the original duration even without an edit record", async () => { |
| 201 | + legacyUpload(); |
| 202 | + await restoreVideoToOriginal(videoId); |
| 203 | + expect(mocks.quiescent).toHaveBeenCalledWith("edit-workflow"); |
| 204 | + expect(mocks.writes).toEqual([ |
| 205 | + "delete-upload", |
| 206 | + "insert-upload", |
| 207 | + "update-video", |
| 208 | + ]); |
| 209 | + expect(mocks.start).toHaveBeenCalledWith(expect.any(Function), [ |
| 210 | + expect.objectContaining({ |
| 211 | + sourceKey, |
| 212 | + editSpec: { |
| 213 | + version: 1, |
| 214 | + sourceDuration: 20, |
| 215 | + keepRanges: [{ start: 0, end: 20 }], |
| 216 | + }, |
| 217 | + }), |
| 218 | + ]); |
| 219 | + expect(mocks.copy).not.toHaveBeenCalled(); |
| 220 | + }); |
| 221 | + it("refuses to replace a legacy row that changed during original verification", async () => { |
| 222 | + legacyUpload(); |
| 223 | + mocks.fetch.mockImplementation(async () => { |
| 224 | + if (mocks.upload) |
| 225 | + mocks.upload.updatedAt = new Date("2026-09-08T00:00:00Z"); |
| 226 | + return new Response(JSON.stringify({ metadata: { duration: 20 } }), { |
| 227 | + status: 200, |
| 228 | + }); |
| 229 | + }); |
| 230 | + await expect(restoreVideoToOriginal(videoId)).rejects.toThrow( |
| 231 | + "already uploading", |
| 232 | + ); |
| 233 | + expect(mocks.writes).toEqual([]); |
| 234 | + expect(mocks.start).not.toHaveBeenCalled(); |
| 235 | + }); |
| 236 | + it("preserves recovery state when the original cannot be verified", async () => { |
| 237 | + legacyUpload(); |
| 238 | + mocks.fetch.mockResolvedValue( |
| 239 | + new Response(JSON.stringify({ metadata: { duration: 0 } }), { |
| 240 | + status: 200, |
| 241 | + }), |
| 242 | + ); |
| 243 | + await expect(restoreVideoToOriginal(videoId)).rejects.toThrow( |
| 244 | + "could not be verified", |
| 245 | + ); |
| 246 | + expect(mocks.writes).toEqual([]); |
| 247 | + }); |
| 248 | + it.each([ |
| 249 | + [{ id: "other", isPro: true }, "Forbidden"], |
| 250 | + [{ id: "owner", isPro: false }, "Cap Pro"], |
| 251 | + ] as const)( |
| 252 | + "requires the recording owner with edit access", |
| 253 | + async (user, message) => { |
| 254 | + legacyUpload(); |
| 255 | + mocks.auth.mockResolvedValue(user); |
| 256 | + await expect(restoreVideoToOriginal(videoId)).rejects.toThrow(message); |
| 257 | + expect(mocks.quiescent).not.toHaveBeenCalled(); |
| 258 | + expect(mocks.writes).toEqual([]); |
| 259 | + }, |
| 260 | + ); |
| 261 | + it("does not use legacy recovery to replace a current operation", async () => { |
| 262 | + legacyUpload(); |
| 263 | + mocks.video.metadata = { editProcessing: { token: "current" } }; |
| 264 | + await expect(restoreVideoToOriginal(videoId)).rejects.toThrow( |
| 265 | + "already uploading", |
| 266 | + ); |
| 267 | + expect(mocks.quiescent).not.toHaveBeenCalled(); |
| 268 | + expect(mocks.writes).toEqual([]); |
| 269 | + }); |
| 270 | +}); |
0 commit comments