2424 * it was found — nothing registered, persisted or scheduled;
2525 * - #21585: so does an enabled job whose `body` the declaration refuses (an
2626 * expression body, a `body.timeoutMs`), naming the refused key;
27+ * - so does an enabled job whose `pull` does not bind (a mapping the package
28+ * does not declare, one with no `connectorSource`), naming the refusal the
29+ * binder's own `judgeJobPull` gives; a pull naming a declared mapping
30+ * installs and is scheduled, a DISABLED unbindable one installs, and an
31+ * entry an earlier build persisted rehydrates with its unbindable pull job
32+ * withheld and warned by name;
2733 * - a package without jobs, and one whose handler-only job is DISABLED,
2834 * install unchanged.
2935 *
@@ -190,12 +196,20 @@ async function bootPlugin() {
190196 const rawApp = makeRawApp ( ) ;
191197 const hooks = new Map < string , any > ( ) ;
192198 const logger = { info : vi . fn ( ) , warn : vi . fn ( ) , error : vi . fn ( ) , debug : vi . fn ( ) } ;
199+ // The `automation` service a pull job's run calls (`IAutomationService.pullConnectorSource`).
200+ const automation = {
201+ pullConnectorSource : vi . fn ( async ( request : { mapping : string } ) => ( {
202+ mapping : request . mapping , targetObject : 'order' , connector : 'orders_api' , action : 'request' , pulled : 0 ,
203+ summary : { total : 0 , processed : 0 , created : 0 , updated : 0 , skipped : 0 , errors : 0 , ok : 0 , cancelled : false } ,
204+ } ) ) ,
205+ } ;
193206 const services : Record < string , unknown > = {
194207 manifest : { register } ,
195208 auth : installerAuthService ( ) ,
196209 objectql : withInstallerGrants ( rec . engine ) ,
197210 job : jobs . svc ,
198211 protocol : registryProtocol ( ) ,
212+ automation,
199213 } ;
200214 const ctx = {
201215 hook : ( e : string , h : any ) => hooks . set ( e , h ) ,
@@ -214,7 +228,7 @@ async function bootPlugin() {
214228 rawApp . routes . get ( 'POST /api/v1/marketplace/install-local' ) ! ( makeC ( { manifest : bundle } ) ) ;
215229 const uninstall = async ( manifestId : string ) =>
216230 rawApp . routes . get ( 'DELETE /api/v1/marketplace/install-local/:manifestId' ) ! ( makeDeleteC ( manifestId ) ) ;
217- return { install, uninstall, rec, jobs, register, logger } ;
231+ return { install, uninstall, rec, jobs, register, logger, automation } ;
218232}
219233
220234describe ( '#21489: install-local schedules an installed package’s job bodies' , ( ) => {
@@ -378,3 +392,109 @@ describe('#21489: install-local refuses an enabled job with no body', () => {
378392 expect ( jobs . svc . schedule ) . not . toHaveBeenCalled ( ) ;
379393 } ) ;
380394} ) ;
395+
396+ describe ( 'install-local refuses an enabled job whose pull does not bind, by the binder\'s own judgeJobPull' , ( ) => {
397+ const MAPPING = {
398+ name : 'orders_pull' ,
399+ targetObject : TICK ,
400+ fieldMapping : [ { source : 'id' , target : 'name' } ] ,
401+ mode : 'upsert' ,
402+ upsertKey : [ 'name' ] ,
403+ connectorSource : { connector : 'orders_api' , action : 'request' } ,
404+ } ;
405+ const { connectorSource : _dropped , ...IMPORT_ONLY } = MAPPING ;
406+ const PULL_JOB = { name : 'jobs_app_pull' , schedule : INTERVAL , pull : { mapping : 'orders_pull' } } ;
407+ const UNDECLARED = { ...PULL_JOB , name : 'jobs_app_pull_typo' , pull : { mapping : 'orders_pul' } } ;
408+
409+ /** The compiled-artifact shape, carrying `mappings` beside `jobs`. */
410+ const withMappings = ( jobs : unknown [ ] , mappings : unknown [ ] = [ MAPPING ] ) => ( { ...artifact ( jobs ) , mappings } ) ;
411+
412+ it ( 'a pull naming a mapping the package does not declare answers 422 VALIDATION_ERROR naming the job and the refusal — and changes nothing' , async ( ) => {
413+ const { install, jobs, register, automation } = await bootPlugin ( ) ;
414+
415+ const res = await install ( withMappings ( [ BODY_JOB , UNDECLARED ] ) ) ;
416+
417+ expect ( res . status ) . toBe ( 422 ) ;
418+ expect ( res . payload . success ) . toBe ( false ) ;
419+ expect ( res . payload . error . code ) . toBe ( 'VALIDATION_ERROR' ) ;
420+ const message : string = res . payload . error . message ;
421+ expect ( message ) . toContain ( `its enabled job '${ UNDECLARED . name } ' (pull.mapping: this artifact declares no mapping 'orders_pul'` ) ;
422+ expect ( message ) . toContain ( 'has a `pull` that does not bind' ) ;
423+ expect ( message ) . toContain ( 'os validate' ) ;
424+ // The pull's own clause, never the no-`body` one: a `body` beside a `pull` is refused by the declaration.
425+ expect ( message ) . not . toMatch ( / g i v e t h e j o b a ` b o d y ` / i) ;
426+ // The runtime is left exactly as it was found.
427+ expect ( registered ( register ) , 'a refused package must not be registered' ) . toEqual ( [ ] ) ;
428+ expect ( new LocalManifestSource ( dir ) . read ( APP_ID ) . entry , 'nor persisted' ) . toBeNull ( ) ;
429+ expect ( jobs . svc . schedule , 'nor any of its jobs scheduled — not even its body job' ) . not . toHaveBeenCalled ( ) ;
430+ expect ( automation . pullConnectorSource ) . not . toHaveBeenCalled ( ) ;
431+ } ) ;
432+
433+ it ( 'a pull whose mapping declares no connectorSource is refused the same way' , async ( ) => {
434+ const { install, jobs, register } = await bootPlugin ( ) ;
435+
436+ const res = await install ( withMappings ( [ PULL_JOB ] , [ IMPORT_ONLY ] ) ) ;
437+
438+ expect ( res . status ) . toBe ( 422 ) ;
439+ expect ( res . payload . error . code ) . toBe ( 'VALIDATION_ERROR' ) ;
440+ expect ( res . payload . error . message ) . toContain ( `its enabled job '${ PULL_JOB . name } ' (pull.mapping: mapping 'orders_pull' declares no connectorSource` ) ;
441+ expect ( registered ( register ) ) . toEqual ( [ ] ) ;
442+ expect ( jobs . svc . schedule ) . not . toHaveBeenCalled ( ) ;
443+ } ) ;
444+
445+ it ( 'one answer names every kind the door cannot run — the unbindable pull beside a job with no body' , async ( ) => {
446+ const { install } = await bootPlugin ( ) ;
447+
448+ const res = await install ( withMappings ( [ HANDLER_JOB , UNDECLARED ] ) ) ;
449+
450+ expect ( res . status ) . toBe ( 422 ) ;
451+ const message : string = res . payload . error . message ;
452+ expect ( message ) . toContain ( `'${ HANDLER_JOB . name } ' (handler 'tick') has no \`body\`` ) ;
453+ expect ( message ) . toContain ( `'${ UNDECLARED . name } ' (pull.mapping: ` ) ;
454+ } ) ;
455+
456+ it ( 'a DISABLED pull job naming an undeclared mapping does not block the install, and is not scheduled' , async ( ) => {
457+ const { install, jobs, register } = await bootPlugin ( ) ;
458+
459+ const res = await install ( withMappings ( [ { ...UNDECLARED , enabled : false } ] ) ) ;
460+
461+ expect ( res . status , JSON . stringify ( res . payload ) ) . toBe ( 200 ) ;
462+ expect ( registered ( register ) ) . toEqual ( [ APP_ID ] ) ;
463+ expect ( jobs . svc . schedule ) . not . toHaveBeenCalled ( ) ;
464+ } ) ;
465+
466+ it ( 'control: a pull naming a declared mapping with a connectorSource installs, is scheduled, and a run pulls that mapping' , async ( ) => {
467+ const { install, jobs, automation } = await bootPlugin ( ) ;
468+
469+ const res = await install ( withMappings ( [ PULL_JOB ] ) ) ;
470+
471+ expect ( res . status , JSON . stringify ( res . payload ) ) . toBe ( 200 ) ;
472+ expect ( [ ...jobs . scheduled . keys ( ) ] ) . toEqual ( [ PULL_JOB . name ] ) ;
473+ await jobs . scheduled . get ( PULL_JOB . name ) ! . run ( { jobId : PULL_JOB . name } ) ;
474+ expect ( automation . pullConnectorSource ) . toHaveBeenCalledTimes ( 1 ) ;
475+ expect ( automation . pullConnectorSource . mock . calls [ 0 ] [ 0 ] ) . toMatchObject ( { mapping : 'orders_pull' } ) ;
476+ } ) ;
477+
478+ it ( 'rehydrate — an entry an earlier build persisted with an unbindable pull job: the job is withheld and warned by name, the bindable one scheduled' , async ( ) => {
479+ const { manifest : meta , ...sections } = withMappings ( [ PULL_JOB , UNDECLARED ] ) ;
480+ new LocalManifestSource ( dir ) . write ( {
481+ packageId : APP_ID ,
482+ versionId : 'local' ,
483+ manifestId : APP_ID ,
484+ version : '0.1.0' ,
485+ manifest : { ...meta , ...sections } ,
486+ installedAt : '2026-01-01T00:00:00.000Z' ,
487+ installedBy : 'admin' ,
488+ withSampleData : false ,
489+ } ) ;
490+
491+ const { jobs, logger, register } = await bootPlugin ( ) ;
492+
493+ expect ( registered ( register ) , 'the entry still rehydrates' ) . toEqual ( [ APP_ID ] ) ;
494+ expect ( [ ...jobs . scheduled . keys ( ) ] ) . toEqual ( [ PULL_JOB . name ] ) ;
495+ const warned = logger . warn . mock . calls . find ( ( [ message , meta ] ) =>
496+ String ( message ) . includes ( 'NOT scheduled' ) && ( meta as { job ?: string } | undefined ) ?. job === UNDECLARED . name ) ;
497+ expect ( warned , 'no warn names the withheld pull job' ) . toBeDefined ( ) ;
498+ expect ( String ( warned ! [ 0 ] ) ) . toContain ( "pull.mapping: this artifact declares no mapping 'orders_pul'" ) ;
499+ } ) ;
500+ } ) ;
0 commit comments