@@ -10,6 +10,9 @@ const apiMocks = vi.hoisted(() => ({
1010const refsMocks = vi . hoisted ( ( ) => ( {
1111 resolveWorkspaceRef : vi . fn ( ) ,
1212 resolveChannelRef : vi . fn ( ) ,
13+ parseRef : vi . fn ( ) ,
14+ getDirectChannelId : vi . fn ( ) ,
15+ resolveUserRefs : vi . fn ( ) ,
1316} ) )
1417
1518const globalArgsMocks = vi . hoisted ( ( ) => ( {
@@ -25,6 +28,9 @@ vi.mock('../../lib/api.js', () => ({
2528vi . mock ( '../../lib/refs.js' , ( ) => ( {
2629 resolveWorkspaceRef : refsMocks . resolveWorkspaceRef ,
2730 resolveChannelRef : refsMocks . resolveChannelRef ,
31+ parseRef : refsMocks . parseRef ,
32+ getDirectChannelId : refsMocks . getDirectChannelId ,
33+ resolveUserRefs : refsMocks . resolveUserRefs ,
2834} ) )
2935
3036vi . mock ( '../../lib/global-args.js' , ( ) => ( {
@@ -43,6 +49,11 @@ function createProgram() {
4349 return program
4450}
4551
52+ async function runChannelCommand ( args : string [ ] ) : Promise < void > {
53+ const program = createProgram ( )
54+ await program . parseAsync ( [ 'node' , 'tdc' , 'channel' , ...args ] )
55+ }
56+
4657function createChannel ( id : number , name : string , overrides : Partial < Record < string , unknown > > = { } ) {
4758 return {
4859 id,
@@ -60,13 +71,20 @@ function createChannel(id: number, name: string, overrides: Partial<Record<strin
6071function createClient ( {
6172 joinedChannels = [ ] ,
6273 publicChannels = [ ] ,
74+ createdChannel,
75+ updatedChannel,
6376} : {
6477 joinedChannels ?: ReturnType < typeof createChannel > [ ]
6578 publicChannels ?: ReturnType < typeof createChannel > [ ]
79+ createdChannel ?: ReturnType < typeof createChannel >
80+ updatedChannel ?: ReturnType < typeof createChannel >
6681} = { } ) {
6782 return {
6883 channels : {
6984 getChannels : vi . fn ( ) . mockResolvedValue ( joinedChannels ) ,
85+ getChannel : vi . fn ( ) ,
86+ createChannel : vi . fn ( ) . mockResolvedValue ( createdChannel ) ,
87+ updateChannel : vi . fn ( ) . mockResolvedValue ( updatedChannel ) ,
7088 } ,
7189 workspaces : {
7290 getPublicChannels : vi . fn ( ) . mockResolvedValue ( publicChannels ) ,
@@ -401,3 +419,250 @@ describe('channels list', () => {
401419 ) . rejects . toHaveProperty ( 'code' , 'INVALID_STATE' )
402420 } )
403421} )
422+
423+ describe ( 'channels create' , ( ) => {
424+ beforeEach ( ( ) => {
425+ vi . clearAllMocks ( )
426+ refsMocks . parseRef . mockImplementation ( ( ref : string ) =>
427+ ref === 'Q3' ? { type : 'id' , id : ref } : { type : 'name' , name : ref } ,
428+ )
429+ } )
430+
431+ it ( 'passes supported fields to createChannel' , async ( ) => {
432+ refsMocks . resolveWorkspaceRef . mockResolvedValue ( { id : 9 , name : 'Doist' } )
433+ refsMocks . resolveUserRefs . mockResolvedValue ( [ 10 , 20 ] )
434+ const createdChannel = createChannel ( 200 , 'Leadership' , {
435+ id : 'CH200' ,
436+ public : false ,
437+ workspaceId : 9 ,
438+ } )
439+ const client = createClient ( { createdChannel } )
440+ apiMocks . getCommsClient . mockResolvedValue ( client )
441+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
442+
443+ await runChannelCommand ( [
444+ 'create' ,
445+ 'Leadership' ,
446+ '--workspace' ,
447+ 'Doist' ,
448+ '--description' ,
449+ 'Private leadership discussions' ,
450+ '--private' ,
451+ '--users' ,
452+ 'id:10,id:20' ,
453+ ] )
454+
455+ expect ( refsMocks . resolveWorkspaceRef ) . toHaveBeenCalledWith ( 'Doist' )
456+ expect ( refsMocks . resolveUserRefs ) . toHaveBeenCalledWith ( 'id:10,id:20' , 9 )
457+ expect ( client . channels . createChannel ) . toHaveBeenCalledWith ( {
458+ workspaceId : 9 ,
459+ name : 'Leadership' ,
460+ description : 'Private leadership discussions' ,
461+ userIds : [ 10 , 20 ] ,
462+ public : false ,
463+ } )
464+ expect ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) . toContain ( 'Leadership' )
465+
466+ consoleSpy . mockRestore ( )
467+ } )
468+
469+ it ( 'outputs created channel JSON' , async ( ) => {
470+ const createdChannel = createChannel ( 300 , 'Product' , {
471+ id : 'CH300' ,
472+ url : 'https://comms.todoist.com/a/1/ch/CH300' ,
473+ } )
474+ const client = createClient ( { createdChannel } )
475+ apiMocks . getCommsClient . mockResolvedValue ( client )
476+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
477+
478+ await runChannelCommand ( [ 'create' , 'Product' , '--json' ] )
479+
480+ expect ( JSON . parse ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) ) . toEqual ( {
481+ id : 'CH300' ,
482+ name : 'Product' ,
483+ workspaceId : 1 ,
484+ public : true ,
485+ archived : false ,
486+ url : 'https://comms.todoist.com/a/1/ch/CH300' ,
487+ } )
488+
489+ consoleSpy . mockRestore ( )
490+ } )
491+
492+ it ( 'does not create in dry-run mode' , async ( ) => {
493+ const client = createClient ( )
494+ apiMocks . getCommsClient . mockResolvedValue ( client )
495+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
496+
497+ await runChannelCommand ( [ 'create' , 'Engineering' , '--dry-run' ] )
498+
499+ expect ( client . channels . createChannel ) . not . toHaveBeenCalled ( )
500+ expect ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) . toContain ( '[dry-run] Would create channel' )
501+
502+ consoleSpy . mockRestore ( )
503+ } )
504+
505+ it ( 'rejects invalid create options' , async ( ) => {
506+ await expect ( runChannelCommand ( [ 'create' , ' ' ] ) ) . rejects . toHaveProperty (
507+ 'code' ,
508+ 'INVALID_NAME' ,
509+ )
510+ await expect ( runChannelCommand ( [ 'create' , 'Q3' ] ) ) . rejects . toHaveProperty (
511+ 'code' ,
512+ 'INVALID_NAME' ,
513+ )
514+
515+ vi . clearAllMocks ( )
516+ await expect (
517+ runChannelCommand ( [
518+ 'create' ,
519+ 'Engineering' ,
520+ '--public' ,
521+ '--private' ,
522+ '--users' ,
523+ 'id:1' ,
524+ ] ) ,
525+ ) . rejects . toHaveProperty ( 'code' , 'CONFLICTING_OPTIONS' )
526+ expect ( apiMocks . getCurrentWorkspaceId ) . not . toHaveBeenCalled ( )
527+ expect ( refsMocks . resolveUserRefs ) . not . toHaveBeenCalled ( )
528+ } )
529+ } )
530+
531+ describe ( 'channels update' , ( ) => {
532+ const engineering = createChannel ( 10 , 'Engineering' , {
533+ id : 'CH10' ,
534+ description : 'Engineering discussion' ,
535+ url : 'https://comms.todoist.com/a/1/ch/CH10' ,
536+ } )
537+
538+ beforeEach ( ( ) => {
539+ vi . clearAllMocks ( )
540+ refsMocks . parseRef . mockImplementation ( ( ref : string ) => ( { type : 'name' , name : ref } ) )
541+ refsMocks . getDirectChannelId . mockReturnValue ( null )
542+ refsMocks . resolveChannelRef . mockResolvedValue ( engineering )
543+ } )
544+
545+ it ( 'renames direct refs via --name without resolving workspace or channel' , async ( ) => {
546+ refsMocks . getDirectChannelId . mockReturnValue ( 'CH10' )
547+ const updatedChannel = { ...engineering , name : 'Platform Engineering' }
548+ const client = createClient ( { updatedChannel } )
549+ apiMocks . getCommsClient . mockResolvedValue ( client )
550+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
551+
552+ await runChannelCommand ( [ 'update' , 'id:CH10' , '--name' , 'Platform Engineering' , '--json' ] )
553+
554+ expect ( apiMocks . getCurrentWorkspaceId ) . not . toHaveBeenCalled ( )
555+ expect ( refsMocks . resolveChannelRef ) . not . toHaveBeenCalled ( )
556+ expect ( client . channels . updateChannel ) . toHaveBeenCalledWith ( {
557+ id : 'CH10' ,
558+ name : 'Platform Engineering' ,
559+ } )
560+ expect ( JSON . parse ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) ) . toMatchObject ( {
561+ id : 'CH10' ,
562+ name : 'Platform Engineering' ,
563+ workspaceId : 1 ,
564+ public : true ,
565+ archived : false ,
566+ } )
567+
568+ consoleSpy . mockRestore ( )
569+ } )
570+
571+ it ( 'updates direct refs by fetching the current name only when needed' , async ( ) => {
572+ refsMocks . getDirectChannelId . mockReturnValue ( 'CH10' )
573+ const updatedChannel = { ...engineering , description : 'Team discussion' }
574+ const client = createClient ( { updatedChannel } )
575+ client . channels . getChannel = vi . fn ( ) . mockResolvedValue ( engineering )
576+ apiMocks . getCommsClient . mockResolvedValue ( client )
577+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
578+
579+ await runChannelCommand ( [ 'update' , 'id:CH10' , '--description' , 'Team discussion' , '--json' ] )
580+
581+ expect ( refsMocks . resolveChannelRef ) . not . toHaveBeenCalled ( )
582+ expect ( client . channels . getChannel ) . toHaveBeenCalledWith ( 'CH10' )
583+ expect ( client . channels . updateChannel ) . toHaveBeenCalledWith ( {
584+ id : 'CH10' ,
585+ name : 'Engineering' ,
586+ description : 'Team discussion' ,
587+ } )
588+ expect ( JSON . parse ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) ) . toMatchObject ( {
589+ id : 'CH10' ,
590+ description : 'Team discussion' ,
591+ } )
592+
593+ consoleSpy . mockRestore ( )
594+ } )
595+
596+ it ( 'updates metadata in a selected workspace while keeping the current name' , async ( ) => {
597+ refsMocks . resolveWorkspaceRef . mockResolvedValue ( { id : 9 , name : 'Doist' } )
598+ const selectedWorkspaceChannel = { ...engineering , workspaceId : 9 }
599+ refsMocks . resolveChannelRef . mockResolvedValue ( selectedWorkspaceChannel )
600+ const updatedChannel = { ...selectedWorkspaceChannel , description : null , public : false }
601+ const client = createClient ( { updatedChannel } )
602+ apiMocks . getCommsClient . mockResolvedValue ( client )
603+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
604+
605+ await runChannelCommand ( [
606+ 'update' ,
607+ 'Engineering' ,
608+ '--workspace' ,
609+ 'Doist' ,
610+ '--clear-description' ,
611+ '--private' ,
612+ ] )
613+
614+ expect ( refsMocks . resolveWorkspaceRef ) . toHaveBeenCalledWith ( 'Doist' )
615+ expect ( refsMocks . resolveChannelRef ) . toHaveBeenCalledWith ( 'Engineering' , 9 )
616+ expect ( client . channels . updateChannel ) . toHaveBeenCalledWith ( {
617+ id : 'CH10' ,
618+ name : 'Engineering' ,
619+ description : null ,
620+ public : false ,
621+ } )
622+ expect ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) . toContain ( 'Engineering' )
623+
624+ consoleSpy . mockRestore ( )
625+ } )
626+
627+ it ( 'does not update or fetch direct refs in dry-run mode' , async ( ) => {
628+ refsMocks . getDirectChannelId . mockReturnValue ( 'CH10' )
629+ const client = createClient ( )
630+ apiMocks . getCommsClient . mockResolvedValue ( client )
631+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
632+
633+ await runChannelCommand ( [
634+ 'update' ,
635+ 'id:CH10' ,
636+ '--description' ,
637+ 'New description' ,
638+ '--dry-run' ,
639+ ] )
640+
641+ expect ( client . channels . getChannel ) . not . toHaveBeenCalled ( )
642+ expect ( client . channels . updateChannel ) . not . toHaveBeenCalled ( )
643+ expect ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) . toContain ( '[dry-run] Would update channel' )
644+
645+ consoleSpy . mockRestore ( )
646+ } )
647+
648+ it ( 'rejects invalid update options' , async ( ) => {
649+ await expect ( runChannelCommand ( [ 'update' , 'Engineering' ] ) ) . rejects . toHaveProperty (
650+ 'code' ,
651+ 'INVALID_VALUE' ,
652+ )
653+
654+ await expect (
655+ runChannelCommand ( [ 'update' , 'Engineering' , 'New Name' , '--name' , 'Other Name' ] ) ,
656+ ) . rejects . toHaveProperty ( 'code' , 'CONFLICTING_OPTIONS' )
657+
658+ await expect (
659+ runChannelCommand ( [
660+ 'update' ,
661+ 'Engineering' ,
662+ '--description' ,
663+ 'Text' ,
664+ '--clear-description' ,
665+ ] ) ,
666+ ) . rejects . toHaveProperty ( 'code' , 'CONFLICTING_OPTIONS' )
667+ } )
668+ } )
0 commit comments