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} ) )
77
8+ vi . mock ( '../lib/api.js' , ( ) => apiMocks )
9+
810vi . mock ( '../lib/public-channels.js' , ( ) => ( {
911 assertChannelIsPublic : vi . fn ( ) ,
1012} ) )
@@ -22,6 +24,96 @@ vi.mock('chalk')
2224
2325import { registerThreadCommand } from '../commands/thread.js'
2426
27+ function createThread ( id : number ) {
28+ return {
29+ id,
30+ title : 'Test Thread' ,
31+ content : 'Thread body' ,
32+ creator : 1 ,
33+ channelId : 100 ,
34+ workspaceId : 10 ,
35+ posted : new Date ( '2026-03-01T00:00:00.000Z' ) ,
36+ commentCount : 3 ,
37+ isArchived : false ,
38+ reactions : [ ] ,
39+ }
40+ }
41+
42+ function createComment ( id : number , objIndex : number ) {
43+ return {
44+ id,
45+ content : `Comment ${ id } ` ,
46+ creator : 2 ,
47+ threadId : 500 ,
48+ posted : new Date ( '2026-03-02T00:00:00.000Z' ) ,
49+ reactions : [ ] ,
50+ objIndex,
51+ }
52+ }
53+
54+ function createClient ( {
55+ thread = createThread ( 500 ) ,
56+ comments = [ ] as ReturnType < typeof createComment > [ ] ,
57+ unreadThreads = [ ] as Array < {
58+ threadId : number
59+ channelId : number
60+ objIndex : number
61+ directMention : boolean
62+ } > ,
63+ users = { } as Record < number , { id : number ; name : string } > ,
64+ channel = { id : 100 , name : 'General' } ,
65+ } = { } ) {
66+ return {
67+ threads : {
68+ getThread : vi . fn ( ( _id : number , options ?: { batch ?: boolean } ) => {
69+ if ( options ?. batch ) return { kind : 'thread' , id : _id }
70+ return Promise . resolve ( thread )
71+ } ) ,
72+ getUnread : vi . fn ( async ( ) => unreadThreads ) ,
73+ } ,
74+ comments : {
75+ getComments : vi . fn ( ( _args : unknown , options ?: { batch ?: boolean } ) => {
76+ if ( options ?. batch ) return { kind : 'comments' }
77+ return Promise . resolve ( comments )
78+ } ) ,
79+ getComment : vi . fn ( ) ,
80+ } ,
81+ channels : {
82+ getChannel : vi . fn ( ( _id : number , options ?: { batch ?: boolean } ) => {
83+ if ( options ?. batch ) return { kind : 'channel' }
84+ return Promise . resolve ( channel )
85+ } ) ,
86+ } ,
87+ workspaceUsers : {
88+ getUserById : vi . fn (
89+ (
90+ { userId } : { workspaceId : number ; userId : number } ,
91+ options ?: { batch ?: boolean } ,
92+ ) => {
93+ if ( options ?. batch ) return { kind : 'user' , userId }
94+ return Promise . resolve ( users [ userId ] )
95+ } ,
96+ ) ,
97+ } ,
98+ batch : vi . fn ( async ( ...requests : Array < { kind : string ; id ?: number ; userId ?: number } > ) =>
99+ requests . map ( ( request ) => {
100+ if ( request . kind === 'thread' ) return { data : thread }
101+ if ( request . kind === 'comments' ) return { data : comments }
102+ if ( request . kind === 'channel' ) return { data : channel }
103+ if ( request . kind === 'user' && request . userId ) {
104+ return {
105+ data : users [ request . userId ] ?? {
106+ id : request . userId ,
107+ name : `user:${ request . userId } ` ,
108+ } ,
109+ }
110+ }
111+ throw new Error ( `Unexpected batch request: ${ JSON . stringify ( request ) } ` )
112+ } ) ,
113+ ) ,
114+ }
115+ }
116+
25117function createProgram ( ) {
26118 const program = new Command ( )
27119 program . exitOverride ( )
@@ -32,6 +124,7 @@ function createProgram() {
32124describe ( 'thread implicit view' , ( ) => {
33125 beforeEach ( ( ) => {
34126 vi . clearAllMocks ( )
127+ apiMocks . getTwistClient . mockRejectedValue ( new Error ( 'MOCK_API_REACHED' ) )
35128 } )
36129
37130 it ( 'tw thread <ref> routes to view (not unknown command)' , async ( ) => {
@@ -67,3 +160,146 @@ describe('thread implicit view', () => {
67160 consoleSpy . mockRestore ( )
68161 } )
69162} )
163+
164+ describe ( 'thread view --unread' , ( ) => {
165+ beforeEach ( ( ) => {
166+ vi . resetAllMocks ( )
167+ } )
168+
169+ it ( 'shows original post and "No unread comments" when thread has no unread data' , async ( ) => {
170+ const client = createClient ( {
171+ comments : [ createComment ( 1 , 1 ) , createComment ( 2 , 2 ) ] ,
172+ unreadThreads : [ ] ,
173+ users : { 1 : { id : 1 , name : 'Alice' } , 2 : { id : 2 , name : 'Bob' } } ,
174+ } )
175+ apiMocks . getTwistClient . mockResolvedValue ( client )
176+
177+ const program = createProgram ( )
178+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
179+
180+ await program . parseAsync ( [ 'node' , 'tw' , 'thread' , 'view' , '500' , '--unread' ] )
181+
182+ const output = consoleSpy . mock . calls . map ( ( c ) => c [ 0 ] ) . join ( '\n' )
183+ expect ( output ) . toContain ( 'Test Thread' )
184+ expect ( output ) . toContain ( 'Thread body' )
185+ expect ( output ) . toContain ( 'No unread comments.' )
186+
187+ consoleSpy . mockRestore ( )
188+ } )
189+
190+ it ( 'filters to only unread comments in human-readable output' , async ( ) => {
191+ const client = createClient ( {
192+ comments : [ createComment ( 1 , 1 ) , createComment ( 2 , 2 ) , createComment ( 3 , 3 ) ] ,
193+ unreadThreads : [ { threadId : 500 , channelId : 100 , objIndex : 1 , directMention : false } ] ,
194+ users : { 1 : { id : 1 , name : 'Alice' } , 2 : { id : 2 , name : 'Bob' } } ,
195+ } )
196+ apiMocks . getTwistClient . mockResolvedValue ( client )
197+
198+ const program = createProgram ( )
199+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
200+
201+ await program . parseAsync ( [ 'node' , 'tw' , 'thread' , 'view' , '500' , '--unread' ] )
202+
203+ const output = consoleSpy . mock . calls . map ( ( c ) => c [ 0 ] ) . join ( '\n' )
204+ // Should show original post
205+ expect ( output ) . toContain ( 'Thread body' )
206+ // Should show unread comments (objIndex 2 and 3, which are > 1)
207+ expect ( output ) . toContain ( 'Comment 2' )
208+ expect ( output ) . toContain ( 'Comment 3' )
209+ // Should NOT show comment 1 (objIndex 1, which is <= lastReadObjIndex 1)
210+ expect ( output ) . not . toContain ( 'Comment 1' )
211+ // Should show unread separator
212+ expect ( output ) . toContain ( 'UNREAD (2 new)' )
213+
214+ consoleSpy . mockRestore ( )
215+ } )
216+
217+ it ( 'filters comments in --json output when --unread is set' , async ( ) => {
218+ const client = createClient ( {
219+ comments : [ createComment ( 1 , 1 ) , createComment ( 2 , 2 ) , createComment ( 3 , 3 ) ] ,
220+ unreadThreads : [ { threadId : 500 , channelId : 100 , objIndex : 2 , directMention : false } ] ,
221+ users : { 1 : { id : 1 , name : 'Alice' } , 2 : { id : 2 , name : 'Bob' } } ,
222+ } )
223+ apiMocks . getTwistClient . mockResolvedValue ( client )
224+
225+ const program = createProgram ( )
226+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
227+
228+ await program . parseAsync ( [ 'node' , 'tw' , 'thread' , 'view' , '500' , '--unread' , '--json' ] )
229+
230+ const jsonOutput = JSON . parse ( consoleSpy . mock . calls [ 0 ] [ 0 ] )
231+ expect ( jsonOutput . thread . id ) . toBe ( 500 )
232+ // Only comment 3 is unread (objIndex 3 > lastReadObjIndex 2)
233+ expect ( jsonOutput . comments ) . toHaveLength ( 1 )
234+ expect ( jsonOutput . comments [ 0 ] . id ) . toBe ( 3 )
235+
236+ consoleSpy . mockRestore ( )
237+ } )
238+
239+ it ( 'returns empty comments in --json output when no unread data exists' , async ( ) => {
240+ const client = createClient ( {
241+ comments : [ createComment ( 1 , 1 ) , createComment ( 2 , 2 ) ] ,
242+ unreadThreads : [ ] ,
243+ users : { 1 : { id : 1 , name : 'Alice' } , 2 : { id : 2 , name : 'Bob' } } ,
244+ } )
245+ apiMocks . getTwistClient . mockResolvedValue ( client )
246+
247+ const program = createProgram ( )
248+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
249+
250+ await program . parseAsync ( [ 'node' , 'tw' , 'thread' , 'view' , '500' , '--unread' , '--json' ] )
251+
252+ const jsonOutput = JSON . parse ( consoleSpy . mock . calls [ 0 ] [ 0 ] )
253+ expect ( jsonOutput . thread . id ) . toBe ( 500 )
254+ expect ( jsonOutput . comments ) . toHaveLength ( 0 )
255+
256+ consoleSpy . mockRestore ( )
257+ } )
258+
259+ it ( 'filters comments in --ndjson output when --unread is set' , async ( ) => {
260+ const client = createClient ( {
261+ comments : [ createComment ( 1 , 1 ) , createComment ( 2 , 2 ) , createComment ( 3 , 3 ) ] ,
262+ unreadThreads : [ { threadId : 500 , channelId : 100 , objIndex : 1 , directMention : false } ] ,
263+ users : { 1 : { id : 1 , name : 'Alice' } , 2 : { id : 2 , name : 'Bob' } } ,
264+ } )
265+ apiMocks . getTwistClient . mockResolvedValue ( client )
266+
267+ const program = createProgram ( )
268+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
269+
270+ await program . parseAsync ( [ 'node' , 'tw' , 'thread' , 'view' , '500' , '--unread' , '--ndjson' ] )
271+
272+ const lines = consoleSpy . mock . calls . map ( ( c ) => JSON . parse ( c [ 0 ] ) )
273+ // First line is the thread
274+ expect ( lines [ 0 ] . type ) . toBe ( 'thread' )
275+ // Only unread comments (objIndex > 1)
276+ const commentLines = lines . filter ( ( l ) => l . type === 'comment' )
277+ expect ( commentLines ) . toHaveLength ( 2 )
278+ expect ( commentLines [ 0 ] . id ) . toBe ( 2 )
279+ expect ( commentLines [ 1 ] . id ) . toBe ( 3 )
280+
281+ consoleSpy . mockRestore ( )
282+ } )
283+
284+ it ( 'returns all comments in --json without --unread' , async ( ) => {
285+ const client = createClient ( {
286+ comments : [ createComment ( 1 , 1 ) , createComment ( 2 , 2 ) ] ,
287+ unreadThreads : [ { threadId : 500 , channelId : 100 , objIndex : 1 , directMention : false } ] ,
288+ users : { 1 : { id : 1 , name : 'Alice' } , 2 : { id : 2 , name : 'Bob' } } ,
289+ } )
290+ apiMocks . getTwistClient . mockResolvedValue ( client )
291+
292+ const program = createProgram ( )
293+ const consoleSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } )
294+
295+ await program . parseAsync ( [ 'node' , 'tw' , 'thread' , 'view' , '500' , '--json' ] )
296+
297+ const jsonOutput = JSON . parse ( consoleSpy . mock . calls [ 0 ] [ 0 ] )
298+ // Without --unread, all comments are returned
299+ expect ( jsonOutput . comments ) . toHaveLength ( 2 )
300+ // getUnread should not be called
301+ expect ( client . threads . getUnread ) . not . toHaveBeenCalled ( )
302+
303+ consoleSpy . mockRestore ( )
304+ } )
305+ } )
0 commit comments