11import { Command } from 'commander'
22import { beforeEach , describe , expect , it , vi } from 'vitest'
33
4- vi . mock ( '../lib/api.js' , ( ) => ( {
5- getTwistClient : vi . fn ( ) . mockRejectedValue ( new Error ( 'MOCK_API_REACHED' ) ) ,
4+ const apiMocks = vi . hoisted ( ( ) => ( {
5+ getTwistClient : vi . fn ( ) ,
66 getCurrentWorkspaceId : vi . fn ( ) . mockResolvedValue ( 1 ) ,
7+ getSessionUser : vi . fn ( ) . mockResolvedValue ( { id : 1 , name : 'Me' } ) ,
78} ) )
89
9- vi . mock ( '../lib/refs.js' , ( ) => ( {
10- resolveConversationId : vi . fn ( ) . mockReturnValue ( 100 ) ,
10+ const refsMocks = vi . hoisted ( ( ) => ( {
11+ resolveConversationId : vi . fn ( ( ref : string ) => Number ( ref ) ) ,
1112 resolveWorkspaceRef : vi . fn ( ) ,
13+ resolveUserRefs : vi . fn ( ) ,
1214} ) )
1315
16+ vi . mock ( '../lib/api.js' , ( ) => apiMocks )
17+
18+ vi . mock ( '../lib/refs.js' , ( ) => refsMocks )
19+
1420vi . mock ( '../lib/markdown.js' , ( ) => ( {
1521 renderMarkdown : vi . fn ( ( text : string ) => text ) ,
1622} ) )
@@ -19,6 +25,143 @@ vi.mock('chalk')
1925
2026import { registerConversationCommand } from '../commands/conversation.js'
2127
28+ type TestConversation = {
29+ id : number
30+ workspaceId : number
31+ userIds : number [ ]
32+ title : string | null
33+ messageCount : number
34+ lastActive : Date
35+ archived : boolean
36+ created : Date
37+ creator : number
38+ lastObjIndex : number
39+ snippet : string
40+ snippetCreators : number [ ]
41+ url : string
42+ lastMessage : null
43+ }
44+
45+ function createConversation ( id : number , userIds : number [ ] , lastActive : string ) : TestConversation {
46+ return {
47+ id,
48+ workspaceId : 1 ,
49+ userIds,
50+ title : null ,
51+ messageCount : 1 ,
52+ lastActive : new Date ( lastActive ) ,
53+ archived : false ,
54+ created : new Date ( '2026-03-01T00:00:00.000Z' ) ,
55+ creator : userIds [ 0 ] ,
56+ lastObjIndex : 1 ,
57+ snippet : `Snippet ${ id } ` ,
58+ snippetCreators : [ userIds [ 0 ] ] ,
59+ url : `https://twist.com/a/1/msg/${ id } ` ,
60+ lastMessage : null ,
61+ }
62+ }
63+
64+ function createClient ( {
65+ activeConversations = [ ] ,
66+ archivedConversations = [ ] ,
67+ users = { } ,
68+ } : {
69+ activeConversations ?: TestConversation [ ]
70+ archivedConversations ?: TestConversation [ ]
71+ users ?: Record < number , { id : number ; name : string } >
72+ } ) {
73+ const conversationsById = new Map (
74+ [ ...activeConversations , ...archivedConversations ] . map ( ( conversation ) => [
75+ conversation . id ,
76+ conversation ,
77+ ] ) ,
78+ )
79+
80+ const getPage = (
81+ conversations : TestConversation [ ] ,
82+ { limit, beforeId } : { limit ?: number ; beforeId ?: number } ,
83+ ) => {
84+ const startIndex = beforeId
85+ ? conversations . findIndex ( ( conversation ) => conversation . id === beforeId ) + 1
86+ : 0
87+
88+ if ( beforeId && startIndex === 0 ) {
89+ return [ ]
90+ }
91+
92+ return conversations . slice ( startIndex , startIndex + ( limit ?? conversations . length ) )
93+ }
94+
95+ return {
96+ conversations : {
97+ getConversations : vi . fn (
98+ async ( {
99+ archived,
100+ limit,
101+ beforeId,
102+ } : {
103+ archived ?: boolean
104+ limit ?: number
105+ beforeId ?: number
106+ } ) =>
107+ getPage ( archived ? archivedConversations : activeConversations , {
108+ limit,
109+ beforeId,
110+ } ) ,
111+ ) ,
112+ getUnread : vi . fn ( ) ,
113+ getConversation : vi . fn ( ( id : number , options ?: { batch ?: boolean } ) => {
114+ if ( options ?. batch ) {
115+ return { kind : 'conversation' , id }
116+ }
117+ return Promise . resolve ( conversationsById . get ( id ) )
118+ } ) ,
119+ archiveConversation : vi . fn ( ) ,
120+ } ,
121+ conversationMessages : {
122+ getMessages : vi . fn (
123+ (
124+ { conversationId, limit } : { conversationId : number ; limit : number } ,
125+ options ?: { batch ?: boolean } ,
126+ ) => {
127+ if ( options ?. batch ) {
128+ return { kind : 'messages' , conversationId, limit }
129+ }
130+ return Promise . resolve ( [ ] )
131+ } ,
132+ ) ,
133+ createMessage : vi . fn ( ) ,
134+ } ,
135+ workspaceUsers : {
136+ getUserById : vi . fn (
137+ (
138+ { workspaceId, userId } : { workspaceId : number ; userId : number } ,
139+ options ?: { batch ?: boolean } ,
140+ ) => {
141+ if ( options ?. batch ) {
142+ return { kind : 'user' , workspaceId, userId }
143+ }
144+ return Promise . resolve ( users [ userId ] )
145+ } ,
146+ ) ,
147+ } ,
148+ batch : vi . fn ( async ( ...requests : Array < { kind : string ; id ?: number ; userId ?: number } > ) =>
149+ requests . map ( ( request ) => {
150+ if ( request . kind === 'conversation' && request . id ) {
151+ return { data : conversationsById . get ( request . id ) }
152+ }
153+ if ( request . kind === 'messages' ) {
154+ return { data : [ ] }
155+ }
156+ if ( request . kind === 'user' && request . userId ) {
157+ return { data : users [ request . userId ] }
158+ }
159+ throw new Error ( `Unexpected batch request: ${ JSON . stringify ( request ) } ` )
160+ } ) ,
161+ ) ,
162+ }
163+ }
164+
22165function createProgram ( ) {
23166 const program = new Command ( )
24167 program . exitOverride ( )
@@ -29,6 +172,7 @@ function createProgram() {
29172describe ( 'conversation implicit view' , ( ) => {
30173 beforeEach ( ( ) => {
31174 vi . clearAllMocks ( )
175+ apiMocks . getTwistClient . mockRejectedValue ( new Error ( 'MOCK_API_REACHED' ) )
32176 } )
33177
34178 it ( 'tw conversation <ref> routes to view (not unknown command)' , async ( ) => {
@@ -44,6 +188,10 @@ describe('conversation implicit view', () => {
44188} )
45189
46190describe ( 'conversation unread --workspace conflict' , ( ) => {
191+ beforeEach ( ( ) => {
192+ vi . clearAllMocks ( )
193+ } )
194+
47195 it ( 'errors when both positional and --workspace are provided' , async ( ) => {
48196 const program = createProgram ( )
49197
@@ -60,3 +208,136 @@ describe('conversation unread --workspace conflict', () => {
60208 ) . rejects . toThrow ( 'Cannot specify workspace both as argument and --workspace flag' )
61209 } )
62210} )
211+
212+ describe ( 'conversation with' , ( ) => {
213+ beforeEach ( ( ) => {
214+ vi . clearAllMocks ( )
215+ refsMocks . resolveUserRefs . mockResolvedValue ( [ 2 ] )
216+ } )
217+
218+ it ( 'prints the exact 1:1 conversation for a user' , async ( ) => {
219+ const directConversation = createConversation ( 42 , [ 1 , 2 ] , '2026-03-08T10:00:00.000Z' )
220+ const groupConversation = createConversation ( 43 , [ 1 , 2 , 3 ] , '2026-03-09T10:00:00.000Z' )
221+ const client = createClient ( {
222+ activeConversations : [ directConversation , groupConversation ] ,
223+ users : {
224+ 1 : { id : 1 , name : 'Me' } ,
225+ 2 : { id : 2 , name : 'Alice Example' } ,
226+ 3 : { id : 3 , name : 'Bob Example' } ,
227+ } ,
228+ } )
229+
230+ apiMocks . getTwistClient . mockResolvedValue ( client )
231+
232+ const program = createProgram ( )
233+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
234+
235+ await program . parseAsync ( [ 'node' , 'tw' , 'conversation' , 'with' , 'Alice' ] )
236+
237+ expect ( refsMocks . resolveUserRefs ) . toHaveBeenCalledWith ( 'Alice' , 1 )
238+ expect ( refsMocks . resolveConversationId ) . not . toHaveBeenCalled ( )
239+ expect ( client . conversationMessages . getMessages ) . not . toHaveBeenCalled ( )
240+ expect ( consoleSpy ) . toHaveBeenCalledWith ( 'Conversation with Me, Alice Example' )
241+
242+ consoleSpy . mockRestore ( )
243+ } )
244+
245+ it ( 'pages through older conversations to find a 1:1 DM' , async ( ) => {
246+ const recentGroups = Array . from ( { length : 100 } , ( _ , index ) =>
247+ createConversation ( 2000 - index , [ 1 , 3 ] , '2026-03-08T10:00:00.000Z' ) ,
248+ )
249+ const directConversation = createConversation ( 42 , [ 1 , 2 ] , '2024-05-31T12:52:09.000Z' )
250+ const client = createClient ( {
251+ activeConversations : [ ...recentGroups , directConversation ] ,
252+ users : {
253+ 1 : { id : 1 , name : 'Me' } ,
254+ 2 : { id : 2 , name : 'Alice Example' } ,
255+ 3 : { id : 3 , name : 'Bob Example' } ,
256+ } ,
257+ } )
258+
259+ apiMocks . getTwistClient . mockResolvedValue ( client )
260+
261+ const program = createProgram ( )
262+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
263+
264+ await program . parseAsync ( [ 'node' , 'tw' , 'conversation' , 'with' , 'Alice' ] )
265+
266+ expect ( client . conversations . getConversations ) . toHaveBeenCalledWith ( {
267+ workspaceId : 1 ,
268+ limit : 100 ,
269+ } )
270+ expect ( client . conversations . getConversations ) . toHaveBeenCalledWith ( {
271+ workspaceId : 1 ,
272+ limit : 100 ,
273+ beforeId : 1901 ,
274+ } )
275+ expect ( refsMocks . resolveConversationId ) . not . toHaveBeenCalled ( )
276+
277+ consoleSpy . mockRestore ( )
278+ } )
279+
280+ it ( 'lists matching group conversations when --include-groups is set' , async ( ) => {
281+ const directConversation = createConversation ( 42 , [ 1 , 2 ] , '2026-03-08T10:00:00.000Z' )
282+ const groupConversation = createConversation ( 43 , [ 1 , 2 , 3 ] , '2026-03-09T10:00:00.000Z' )
283+ const client = createClient ( {
284+ activeConversations : [ directConversation , groupConversation ] ,
285+ users : {
286+ 1 : { id : 1 , name : 'Me' } ,
287+ 2 : { id : 2 , name : 'Alice Example' } ,
288+ 3 : { id : 3 , name : 'Bob Example' } ,
289+ } ,
290+ } )
291+
292+ apiMocks . getTwistClient . mockResolvedValue ( client )
293+
294+ const program = createProgram ( )
295+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
296+
297+ await program . parseAsync ( [
298+ 'node' ,
299+ 'tw' ,
300+ 'conversation' ,
301+ 'with' ,
302+ 'Alice' ,
303+ '--include-groups' ,
304+ '--json' ,
305+ ] )
306+
307+ expect ( refsMocks . resolveConversationId ) . not . toHaveBeenCalled ( )
308+ expect (
309+ JSON . parse ( consoleSpy . mock . calls [ 0 ] [ 0 ] ) . map (
310+ ( conversation : { id : number } ) => conversation . id ,
311+ ) ,
312+ ) . toEqual ( [ 43 , 42 ] )
313+
314+ consoleSpy . mockRestore ( )
315+ } )
316+
317+ it ( 'prints a clean error and exits non-zero for ambiguous user refs' , async ( ) => {
318+ refsMocks . resolveUserRefs . mockRejectedValue (
319+ new Error (
320+ 'Multiple users match "Alex":\n 1 Alex <alex@example.com>\n\nUse numeric ID to specify.' ,
321+ ) ,
322+ )
323+
324+ const program = createProgram ( )
325+ const errorSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } )
326+ const exitSpy = vi . spyOn ( process , 'exit' ) . mockImplementation ( ( (
327+ code ?: string | number | null ,
328+ ) => {
329+ throw new Error ( `EXIT_${ code } ` )
330+ } ) as never )
331+
332+ await expect (
333+ program . parseAsync ( [ 'node' , 'tw' , 'conversation' , 'with' , 'Alex' ] ) ,
334+ ) . rejects . toThrow ( 'EXIT_1' )
335+
336+ expect ( errorSpy ) . toHaveBeenCalledWith (
337+ 'Multiple users match "Alex":\n 1 Alex <alex@example.com>\n\nUse numeric ID to specify.' ,
338+ )
339+
340+ exitSpy . mockRestore ( )
341+ errorSpy . mockRestore ( )
342+ } )
343+ } )
0 commit comments