Skip to content

Commit 2451bec

Browse files
committed
feat: add conversation lookup by user
1 parent b4d41d3 commit 2451bec

File tree

4 files changed

+515
-10
lines changed

4 files changed

+515
-10
lines changed

src/__tests__/conversation.test.ts

Lines changed: 285 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import { Command } from 'commander'
22
import { 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+
1420
vi.mock('../lib/markdown.js', () => ({
1521
renderMarkdown: vi.fn((text: string) => text),
1622
}))
@@ -19,6 +25,143 @@ vi.mock('chalk')
1925

2026
import { 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+
22165
function createProgram() {
23166
const program = new Command()
24167
program.exitOverride()
@@ -29,6 +172,7 @@ function createProgram() {
29172
describe('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

46190
describe('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

Comments
 (0)