@@ -88,6 +88,15 @@ function createSpawnSuccessProcess(options?: {
8888 candidateIds ?: string [ ] ;
8989 pagesProcessed ?: number ;
9090} ) {
91+ return createSpawnProcess (
92+ JSON . stringify ( {
93+ candidateIds : options ?. candidateIds ?? [ ] ,
94+ pagesProcessed : options ?. pagesProcessed ?? 1 ,
95+ } )
96+ ) ;
97+ }
98+
99+ function createSpawnProcess ( outputLine : string , exitCode = 0 ) {
91100 const stdout = new EventEmitter ( ) ;
92101 const stderr = new EventEmitter ( ) ;
93102 const child = new EventEmitter ( ) as EventEmitter & {
@@ -97,13 +106,9 @@ function createSpawnSuccessProcess(options?: {
97106 child . stdout = stdout ;
98107 child . stderr = stderr ;
99108
100- const summary = JSON . stringify ( {
101- candidateIds : options ?. candidateIds ?? [ ] ,
102- pagesProcessed : options ?. pagesProcessed ?? 1 ,
103- } ) ;
104109 queueMicrotask ( ( ) => {
105- stdout . emit ( "data" , Buffer . from ( "Progress: 1/1\n" + summary + "\n" ) ) ;
106- child . emit ( "close" , 0 ) ;
110+ stdout . emit ( "data" , Buffer . from ( "Progress: 1/1\n" + outputLine + "\n" ) ) ;
111+ child . emit ( "close" , exitCode ) ;
107112 } ) ;
108113
109114 return child ;
@@ -312,4 +317,131 @@ describe("fetch-job-runner", () => {
312317 expect ( mockPrepareContentBranchForFetch ) . not . toHaveBeenCalled ( ) ;
313318 expect ( mockNotionPagesUpdate ) . not . toHaveBeenCalled ( ) ;
314319 } ) ;
320+
321+ it ( "fails when terminal JSON summary is missing" , async ( ) => {
322+ mockSpawn . mockImplementation ( ( ) =>
323+ createSpawnProcess ( "Progress complete without summary" )
324+ ) ;
325+
326+ const result = await runFetchJob ( {
327+ type : "fetch-ready" ,
328+ jobId : "job-missing-json" ,
329+ options : { } ,
330+ onProgress : vi . fn ( ) ,
331+ logger : createLogger ( ) ,
332+ childEnv : process . env ,
333+ signal : new AbortController ( ) . signal ,
334+ timeoutMs : 20 * 60 * 1000 ,
335+ } ) ;
336+
337+ expect ( result . success ) . toBe ( false ) ;
338+ expect ( result . terminal . error ?. code ) . toBe ( "CONTENT_GENERATION_FAILED" ) ;
339+ expect ( result . terminal . pagesProcessed ) . toBe ( 0 ) ;
340+ expect ( mockPrepareContentBranchForFetch ) . not . toHaveBeenCalled ( ) ;
341+ } ) ;
342+
343+ it ( "fails when terminal JSON summary is inconsistent for fetch-ready" , async ( ) => {
344+ mockSpawn . mockImplementation ( ( ) =>
345+ createSpawnSuccessProcess ( {
346+ pagesProcessed : 0 ,
347+ candidateIds : [ "page-1" ] ,
348+ } )
349+ ) ;
350+
351+ const result = await runFetchJob ( {
352+ type : "fetch-ready" ,
353+ jobId : "job-inconsistent-summary" ,
354+ options : { } ,
355+ onProgress : vi . fn ( ) ,
356+ logger : createLogger ( ) ,
357+ childEnv : process . env ,
358+ signal : new AbortController ( ) . signal ,
359+ timeoutMs : 20 * 60 * 1000 ,
360+ } ) ;
361+
362+ expect ( result . success ) . toBe ( false ) ;
363+ expect ( result . terminal . error ?. code ) . toBe ( "CONTENT_GENERATION_FAILED" ) ;
364+ expect ( result . terminal . pagesProcessed ) . toBe ( 0 ) ;
365+ expect ( mockPrepareContentBranchForFetch ) . not . toHaveBeenCalled ( ) ;
366+ } ) ;
367+
368+ it ( "passes fetch-ready status filter args to generation script" , async ( ) => {
369+ const result = await runFetchJob ( {
370+ type : "fetch-ready" ,
371+ jobId : "job-ready-status-filter" ,
372+ options : { dryRun : true } ,
373+ onProgress : vi . fn ( ) ,
374+ logger : createLogger ( ) ,
375+ childEnv : process . env ,
376+ signal : new AbortController ( ) . signal ,
377+ timeoutMs : 20 * 60 * 1000 ,
378+ } ) ;
379+
380+ expect ( result . success ) . toBe ( true ) ;
381+ expect ( mockSpawn ) . toHaveBeenCalledTimes ( 1 ) ;
382+ expect ( mockSpawn . mock . calls [ 0 ] ?. [ 0 ] ) . toBe ( "bun" ) ;
383+ expect ( mockSpawn . mock . calls [ 0 ] ?. [ 1 ] ) . toEqual (
384+ expect . arrayContaining ( [
385+ "--status-filter" ,
386+ NOTION_PROPERTIES . READY_TO_PUBLISH ,
387+ ] )
388+ ) ;
389+ expect ( mockSpawn . mock . calls [ 0 ] ?. [ 1 ] ) . toEqual (
390+ expect . arrayContaining ( [ "--status-filter" , "Ready to publish" ] )
391+ ) ;
392+ } ) ;
393+
394+ it ( "transitions all fetch-ready candidates on happy path" , async ( ) => {
395+ const candidateIds = [ "page-1" , "page-2" , "page-3" ] ;
396+ mockSpawn . mockImplementation ( ( ) =>
397+ createSpawnSuccessProcess ( {
398+ pagesProcessed : candidateIds . length ,
399+ candidateIds,
400+ } )
401+ ) ;
402+
403+ const result = await runFetchJob ( {
404+ type : "fetch-ready" ,
405+ jobId : "job-ready-happy-path" ,
406+ options : { } ,
407+ onProgress : vi . fn ( ) ,
408+ logger : createLogger ( ) ,
409+ childEnv : process . env ,
410+ signal : new AbortController ( ) . signal ,
411+ timeoutMs : 20 * 60 * 1000 ,
412+ } ) ;
413+
414+ expect ( result . success ) . toBe ( true ) ;
415+ expect ( result . terminal . pagesTransitioned ) . toBe ( candidateIds . length ) ;
416+ expect ( result . terminal . failedPageIds ) . toEqual ( [ ] ) ;
417+ expect ( mockNotionPagesUpdate ) . toHaveBeenCalledTimes ( candidateIds . length ) ;
418+ for ( const pageId of candidateIds ) {
419+ expect ( mockNotionPagesUpdate ) . toHaveBeenCalledWith (
420+ expect . objectContaining ( {
421+ page_id : pageId ,
422+ } )
423+ ) ;
424+ }
425+ expect ( mockVerifyRemoteHeadMatchesLocal ) . toHaveBeenCalledTimes ( 1 ) ;
426+ } ) ;
427+
428+ it ( "passes --force and --dry-run through to generation script" , async ( ) => {
429+ const result = await runFetchJob ( {
430+ type : "fetch-ready" ,
431+ jobId : "job-force-dry-run" ,
432+ options : { force : true , dryRun : true } ,
433+ onProgress : vi . fn ( ) ,
434+ logger : createLogger ( ) ,
435+ childEnv : process . env ,
436+ signal : new AbortController ( ) . signal ,
437+ timeoutMs : 20 * 60 * 1000 ,
438+ } ) ;
439+
440+ expect ( result . success ) . toBe ( true ) ;
441+ expect ( mockSpawn ) . toHaveBeenCalledTimes ( 1 ) ;
442+ expect ( mockSpawn . mock . calls [ 0 ] ?. [ 0 ] ) . toBe ( "bun" ) ;
443+ expect ( mockSpawn . mock . calls [ 0 ] ?. [ 1 ] ) . toEqual (
444+ expect . arrayContaining ( [ "--force" , "--dry-run" ] )
445+ ) ;
446+ } ) ;
315447} ) ;
0 commit comments