11import type { VideoEditSpec } from "@cap/database/types" ;
22import { User , Video } from "@cap/web-domain" ;
3- import { Effect , Option } from "effect" ;
3+ import { Effect , Option , Schema } from "effect" ;
44import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
55
66const mocks = vi . hoisted ( ( ) => ( {
@@ -76,6 +76,11 @@ import {
7676 invalidateVideoCache ,
7777} from "@/actions/admin/replace-video" ;
7878import { prepareDesktopReupload } from "@/lib/desktop-reupload" ;
79+ import {
80+ createDesktopReuploadKey ,
81+ createDesktopReuploadToken ,
82+ decodeDesktopReuploadToken ,
83+ } from "@/lib/desktop-reupload-token" ;
7984import { saveMetadataAndComplete } from "@/workflows/admin-reprocess-video" ;
8085import {
8186 saveEditResultAndComplete ,
@@ -128,7 +133,13 @@ function createClient() {
128133 : [ video ] ;
129134 return Object . assign ( Promise . resolve ( rows ) , {
130135 for : async ( ) => {
131- events . push ( table . table === "jobs" ? "lock-job" : "lock-video" ) ;
136+ events . push (
137+ table . table === "jobs"
138+ ? "lock-job"
139+ : table . table === "uploads"
140+ ? "lock-upload"
141+ : "lock-video" ,
142+ ) ;
132143 return rows ;
133144 } ,
134145 } ) ;
@@ -145,9 +156,10 @@ function createClient() {
145156 updates . push ( values ) ;
146157 Object . assign ( video , values ) ;
147158 if ( values . source && reorderSourceKeys ) {
148- video . source = Object . fromEntries (
149- Object . entries ( video . source ) . reverse ( ) ,
150- ) as typeof video . source ;
159+ video . source = Object . assign (
160+ { type : video . source . type } ,
161+ Object . fromEntries ( Object . entries ( video . source ) . reverse ( ) ) ,
162+ ) ;
151163 }
152164 }
153165 return [ { affectedRows : 1 } ] ;
@@ -236,6 +248,7 @@ beforeEach(() => {
236248 events . push ( "retire-job" ) ;
237249 } ) ;
238250 mocks . env . mockReturnValue ( {
251+ NEXTAUTH_SECRET : "test-only-desktop-reupload-signing-key" ,
239252 MEDIA_SERVER_URL : "https://media.test" ,
240253 MEDIA_SERVER_WEBHOOK_SECRET : "secret" ,
241254 WEB_URL : "https://cap.test" ,
@@ -253,35 +266,234 @@ describe("desktop reupload publication", () => {
253266 ownerId : User . UserId . make ( video . ownerId ) ,
254267 bucketId : Option . none ( ) ,
255268 storageIntegrationId : Option . none ( ) ,
269+ source : Schema . decodeUnknownSync ( Video . Video . fields . source ) ( video . source ) ,
256270 } ) ;
271+ const tokenFor = ( snapshot = original ( ) ) => {
272+ const token = decodeDesktopReuploadToken (
273+ createDesktopReuploadToken ( snapshot , {
274+ uploadId : "provider-upload-id" ,
275+ provider : "s3" ,
276+ outputKey : createDesktopReuploadKey ( snapshot ) ,
277+ } ) ,
278+ ) ;
279+ if ( ! token ) throw new Error ( "Expected a replacement token" ) ;
280+ upload . rawFileKey = token . outputKey ;
281+ return token ;
282+ } ;
283+ const transaction = ( ) =>
284+ createClient ( ) as unknown as Parameters < typeof prepareDesktopReupload > [ 0 ] ;
257285
258286 it ( "replaces processed playback and stale AI while preserving the link's other metadata" , async ( ) => {
259287 video . source . audioLevelOutputKey =
260288 "user/video/.recording/outputs/old-audio.mp4" ;
261289 video . metadata . editProcessing = { token : "old-edit" } ;
262- const tx = createClient ( ) as unknown as Parameters <
263- typeof prepareDesktopReupload
264- > [ 0 ] ;
265- const replacement = await prepareDesktopReupload ( tx , original ( ) ) ;
290+ video . metadata . completedVideoEdit = { token : "completed-edit" } ;
291+ video . metadata . chapters = [ { title : "old chapter" } ] ;
292+ video . metadata . aiGenerationStatus = "complete" ;
293+ const before = structuredClone ( video ) ;
294+ const snapshot = original ( ) ;
295+ const token = tokenFor ( snapshot ) ;
296+ const tx = transaction ( ) ;
297+ const replacement = await prepareDesktopReupload ( tx , snapshot , token ) ;
266298 expect ( replacement ) . toEqual ( {
267- source : { type : "desktopMP4" } ,
299+ source : { type : "desktopMP4" , outputKey : token . outputKey } ,
268300 metadata : { customCreatedAt : "2020-01-01T00:00:00Z" } ,
269301 transcriptionStatus : null ,
270302 } ) ;
271- expect ( video . source . outputKey ) . toBeDefined ( ) ;
272- expect ( events ) . toEqual ( [ "retire-job" , "lock-video" ] ) ;
303+ expect ( video ) . toEqual ( before ) ;
304+ expect ( events ) . toEqual ( [
305+ "lock-job" ,
306+ "lock-video" ,
307+ "lock-upload" ,
308+ "retire-job" ,
309+ ] ) ;
310+ expect ( mocks . retire ) . toHaveBeenCalledWith ( tx , {
311+ videoId : "video" ,
312+ userId : "user" ,
313+ } ) ;
314+ expect ( mocks . head ) . not . toHaveBeenCalled ( ) ;
315+ expect ( mocks . access ) . not . toHaveBeenCalled ( ) ;
273316 } ) ;
274317
275- it ( "rejects publication if storage changed during upload" , async ( ) => {
318+ it . each ( [ "desktopSegments" , "webMP4" ] ) (
319+ "publishes a %s replacement with its immutable MP4 key" ,
320+ async ( type ) => {
321+ video . source = { type } ;
322+ const replacement = await prepareDesktopReupload (
323+ transaction ( ) ,
324+ original ( ) ,
325+ tokenFor ( ) ,
326+ ) ;
327+ expect ( replacement ?. source . type ) . toBe (
328+ type === "webMP4" ? "webMP4" : "desktopMP4" ,
329+ ) ;
330+ expect ( replacement ?. source . outputKey ) . toContain (
331+ "user/video/.recording/outputs/reupload-" ,
332+ ) ;
333+ } ,
334+ ) ;
335+
336+ it . each ( [ "bucket" , "storageIntegrationId" , "ownerId" ] as const ) (
337+ "rejects publication if %s changed during upload before retiring any job" ,
338+ async ( field ) => {
339+ const snapshot = original ( ) ;
340+ const token = tokenFor ( snapshot ) ;
341+ video [ field ] = "different-identity" ;
342+ const before = structuredClone ( video ) ;
343+ await expect (
344+ prepareDesktopReupload ( transaction ( ) , snapshot , token ) ,
345+ ) . rejects . toThrow ( "storage changed" ) ;
346+ expect ( video ) . toEqual ( before ) ;
347+ expect ( updates ) . toEqual ( [ ] ) ;
348+ expect ( events ) . toEqual ( [ "lock-job" , "lock-video" ] ) ;
349+ expect ( mocks . retire ) . not . toHaveBeenCalled ( ) ;
350+ } ,
351+ ) ;
352+
353+ it . each ( [ null , "newer-upload" ] ) (
354+ "rejects canceled or superseded upload %s under the lock" ,
355+ async ( rawFileKey ) => {
356+ const snapshot = original ( ) ;
357+ const token = tokenFor ( snapshot ) ;
358+ upload . rawFileKey = rawFileKey ;
359+ const before = structuredClone ( video ) ;
360+ await expect (
361+ prepareDesktopReupload ( transaction ( ) , snapshot , token ) ,
362+ ) . rejects . toThrow ( "canceled or superseded" ) ;
363+ expect ( video ) . toEqual ( before ) ;
364+ expect ( mocks . retire ) . not . toHaveBeenCalled ( ) ;
365+ expect ( events ) . toEqual ( [ "lock-job" , "lock-video" , "lock-upload" ] ) ;
366+ } ,
367+ ) ;
368+ it ( "rejects an older upload completion after another publication" , async ( ) => {
276369 const snapshot = original ( ) ;
277- video . bucket = "different-bucket" ;
278- const tx = createClient ( ) as unknown as Parameters <
279- typeof prepareDesktopReupload
280- > [ 0 ] ;
281- await expect ( prepareDesktopReupload ( tx , snapshot ) ) . rejects . toThrow (
282- "storage changed" ,
370+ const token = tokenFor ( snapshot ) ;
371+ video . source . outputKey = "user/video/.recording/outputs/newer/result.mp4" ;
372+ const before = structuredClone ( video ) ;
373+ await expect (
374+ prepareDesktopReupload ( transaction ( ) , snapshot , token ) ,
375+ ) . rejects . toThrow ( "source changed" ) ;
376+ expect ( video ) . toEqual ( before ) ;
377+ expect ( mocks . retire ) . not . toHaveBeenCalled ( ) ;
378+ } ) ;
379+
380+ it ( "compares normalized source fields under the lock" , async ( ) => {
381+ const snapshot = original ( ) ;
382+ const token = tokenFor ( snapshot ) ;
383+ video . source = Object . assign (
384+ { type : video . source . type } ,
385+ Object . fromEntries ( Object . entries ( video . source ) . reverse ( ) ) ,
386+ { legacyExtra : "ignored by Video.source" } ,
283387 ) ;
388+ await expect (
389+ prepareDesktopReupload ( transaction ( ) , snapshot , token ) ,
390+ ) . resolves . toMatchObject ( { source : { outputKey : token . outputKey } } ) ;
391+ expect ( events ) . toEqual ( [
392+ "lock-job" ,
393+ "lock-video" ,
394+ "lock-upload" ,
395+ "retire-job" ,
396+ ] ) ;
397+ } ) ;
398+
399+ it ( "returns no update for the exact already-published attempt without retiring a new job" , async ( ) => {
400+ const snapshot = original ( ) ;
401+ const token = tokenFor ( snapshot ) ;
402+ video . source = { type : "desktopMP4" , outputKey : token . outputKey } ;
403+ video . metadata . summary = "new summary" ;
404+ const before = structuredClone ( video ) ;
405+ await expect (
406+ prepareDesktopReupload ( transaction ( ) , snapshot , token ) ,
407+ ) . resolves . toBeNull ( ) ;
408+ expect ( video ) . toEqual ( before ) ;
409+ expect ( events ) . toEqual ( [ "lock-job" , "lock-video" ] ) ;
410+ expect ( mocks . retire ) . not . toHaveBeenCalled ( ) ;
411+ expect ( updates ) . toEqual ( [ ] ) ;
412+ } ) ;
413+
414+ it ( "still validates storage on an already-published retry" , async ( ) => {
415+ const snapshot = original ( ) ;
416+ const token = tokenFor ( snapshot ) ;
417+ video . source = { type : "desktopMP4" , outputKey : token . outputKey } ;
418+ video . bucket = "changed-bucket" ;
419+ await expect (
420+ prepareDesktopReupload ( transaction ( ) , snapshot , token ) ,
421+ ) . rejects . toThrow ( "storage changed" ) ;
422+ expect ( mocks . retire ) . not . toHaveBeenCalled ( ) ;
423+ } ) ;
424+
425+ it ( "leaves the publication and metadata intact if retiring the job fails" , async ( ) => {
426+ const before = structuredClone ( video ) ;
427+ mocks . retire . mockImplementationOnce ( async ( ) => {
428+ events . push ( "retire-job" ) ;
429+ throw new Error ( "Job retirement failed" ) ;
430+ } ) ;
431+ await expect (
432+ prepareDesktopReupload ( transaction ( ) , original ( ) , tokenFor ( ) ) ,
433+ ) . rejects . toThrow ( "Job retirement failed" ) ;
434+ expect ( video ) . toEqual ( before ) ;
284435 expect ( updates ) . toEqual ( [ ] ) ;
436+ expect ( events ) . toEqual ( [
437+ "lock-job" ,
438+ "lock-video" ,
439+ "lock-upload" ,
440+ "retire-job" ,
441+ ] ) ;
442+ } ) ;
443+
444+ it ( "keeps job retirement inside the caller's publication transaction" , async ( ) => {
445+ const before = structuredClone ( video ) ;
446+ const retainedJob = {
447+ generation : "original-generation" ,
448+ state : "complete" ,
449+ sourceKey : "user/video/original-source.mp4" ,
450+ outputKey : video . source . outputKey ,
451+ } ;
452+ let job = { ...retainedJob } ;
453+ const tx = transaction ( ) ;
454+ mocks . retire . mockImplementationOnce ( async ( owner ) => {
455+ expect ( owner ) . toBe ( tx ) ;
456+ events . push ( "retire-job" ) ;
457+ job = {
458+ ...job ,
459+ generation : "retired-generation" ,
460+ state : "source-blocked" ,
461+ } ;
462+ } ) ;
463+ const publishTransaction = async ( ) => {
464+ events . push ( "transaction" ) ;
465+ const savedVideo = structuredClone ( video ) ;
466+ const savedJob = { ...job } ;
467+ try {
468+ const replacement = await prepareDesktopReupload (
469+ tx ,
470+ original ( ) ,
471+ tokenFor ( ) ,
472+ ) ;
473+ expect ( replacement ) . not . toBeNull ( ) ;
474+ expect ( job . state ) . toBe ( "source-blocked" ) ;
475+ Object . assign ( video , replacement ) ;
476+ events . push ( "publication-failed" ) ;
477+ throw new Error ( "Publication failed" ) ;
478+ } catch ( error ) {
479+ video = savedVideo ;
480+ job = savedJob ;
481+ events . push ( "rollback" ) ;
482+ throw error ;
483+ }
484+ } ;
485+ await expect ( publishTransaction ( ) ) . rejects . toThrow ( "Publication failed" ) ;
486+ expect ( video ) . toEqual ( before ) ;
487+ expect ( job ) . toEqual ( retainedJob ) ;
488+ expect ( events ) . toEqual ( [
489+ "transaction" ,
490+ "lock-job" ,
491+ "lock-video" ,
492+ "lock-upload" ,
493+ "retire-job" ,
494+ "publication-failed" ,
495+ "rollback" ,
496+ ] ) ;
285497 } ) ;
286498} ) ;
287499
0 commit comments