Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 285 additions & 4 deletions src/__tests__/conversation.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { Command } from 'commander'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('../lib/api.js', () => ({
getTwistClient: vi.fn().mockRejectedValue(new Error('MOCK_API_REACHED')),
const apiMocks = vi.hoisted(() => ({
getTwistClient: vi.fn(),
getCurrentWorkspaceId: vi.fn().mockResolvedValue(1),
getSessionUser: vi.fn().mockResolvedValue({ id: 1, name: 'Me' }),
}))

vi.mock('../lib/refs.js', () => ({
resolveConversationId: vi.fn().mockReturnValue(100),
const refsMocks = vi.hoisted(() => ({
resolveConversationId: vi.fn((ref: string) => Number(ref)),
resolveWorkspaceRef: vi.fn(),
resolveUserRefs: vi.fn(),
}))

vi.mock('../lib/api.js', () => apiMocks)

vi.mock('../lib/refs.js', () => refsMocks)

vi.mock('../lib/markdown.js', () => ({
renderMarkdown: vi.fn((text: string) => text),
}))
Expand All @@ -19,6 +25,143 @@ vi.mock('chalk')

import { registerConversationCommand } from '../commands/conversation.js'

type TestConversation = {
id: number
workspaceId: number
userIds: number[]
title: string | null
messageCount: number
lastActive: Date
archived: boolean
created: Date
creator: number
lastObjIndex: number
snippet: string
snippetCreators: number[]
url: string
lastMessage: null
}

function createConversation(id: number, userIds: number[], lastActive: string): TestConversation {
return {
id,
workspaceId: 1,
userIds,
title: null,
messageCount: 1,
lastActive: new Date(lastActive),
archived: false,
created: new Date('2026-03-01T00:00:00.000Z'),
creator: userIds[0],
lastObjIndex: 1,
snippet: `Snippet ${id}`,
snippetCreators: [userIds[0]],
url: `https://twist.com/a/1/msg/${id}`,
lastMessage: null,
}
}

function createClient({
activeConversations = [],
archivedConversations = [],
users = {},
}: {
activeConversations?: TestConversation[]
archivedConversations?: TestConversation[]
users?: Record<number, { id: number; name: string }>
}) {
const conversationsById = new Map(
[...activeConversations, ...archivedConversations].map((conversation) => [
conversation.id,
conversation,
]),
)

const getPage = (
conversations: TestConversation[],
{ limit, beforeId }: { limit?: number; beforeId?: number },
) => {
const startIndex = beforeId
? conversations.findIndex((conversation) => conversation.id === beforeId) + 1
: 0

if (beforeId && startIndex === 0) {
return []
}

return conversations.slice(startIndex, startIndex + (limit ?? conversations.length))
}

return {
conversations: {
getConversations: vi.fn(
async ({
archived,
limit,
beforeId,
}: {
archived?: boolean
limit?: number
beforeId?: number
}) =>
getPage(archived ? archivedConversations : activeConversations, {
limit,
beforeId,
}),
),
getUnread: vi.fn(),
getConversation: vi.fn((id: number, options?: { batch?: boolean }) => {
if (options?.batch) {
return { kind: 'conversation', id }
}
return Promise.resolve(conversationsById.get(id))
}),
archiveConversation: vi.fn(),
},
conversationMessages: {
getMessages: vi.fn(
(
{ conversationId, limit }: { conversationId: number; limit: number },
options?: { batch?: boolean },
) => {
if (options?.batch) {
return { kind: 'messages', conversationId, limit }
}
return Promise.resolve([])
},
),
createMessage: vi.fn(),
},
workspaceUsers: {
getUserById: vi.fn(
(
{ workspaceId, userId }: { workspaceId: number; userId: number },
options?: { batch?: boolean },
) => {
if (options?.batch) {
return { kind: 'user', workspaceId, userId }
}
return Promise.resolve(users[userId])
},
),
},
batch: vi.fn(async (...requests: Array<{ kind: string; id?: number; userId?: number }>) =>
requests.map((request) => {
if (request.kind === 'conversation' && request.id) {
return { data: conversationsById.get(request.id) }
}
if (request.kind === 'messages') {
return { data: [] }
}
if (request.kind === 'user' && request.userId) {
return { data: users[request.userId] }
}
throw new Error(`Unexpected batch request: ${JSON.stringify(request)}`)
}),
),
}
}

function createProgram() {
const program = new Command()
program.exitOverride()
Expand All @@ -29,6 +172,7 @@ function createProgram() {
describe('conversation implicit view', () => {
beforeEach(() => {
vi.clearAllMocks()
apiMocks.getTwistClient.mockRejectedValue(new Error('MOCK_API_REACHED'))
})

it('tw conversation <ref> routes to view (not unknown command)', async () => {
Expand All @@ -44,6 +188,10 @@ describe('conversation implicit view', () => {
})

describe('conversation unread --workspace conflict', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('errors when both positional and --workspace are provided', async () => {
const program = createProgram()

Expand All @@ -60,3 +208,136 @@ describe('conversation unread --workspace conflict', () => {
).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag')
})
})

describe('conversation with', () => {
beforeEach(() => {
vi.clearAllMocks()
refsMocks.resolveUserRefs.mockResolvedValue([2])
})

it('prints the exact 1:1 conversation for a user', async () => {
const directConversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
const groupConversation = createConversation(43, [1, 2, 3], '2026-03-09T10:00:00.000Z')
const client = createClient({
activeConversations: [directConversation, groupConversation],
users: {
1: { id: 1, name: 'Me' },
2: { id: 2, name: 'Alice Example' },
3: { id: 3, name: 'Bob Example' },
},
})

apiMocks.getTwistClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice'])

expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 1)
expect(refsMocks.resolveConversationId).not.toHaveBeenCalled()
expect(client.conversationMessages.getMessages).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalledWith('Conversation with Me, Alice Example')

consoleSpy.mockRestore()
})

it('pages through older conversations to find a 1:1 DM', async () => {
const recentGroups = Array.from({ length: 100 }, (_, index) =>
createConversation(2000 - index, [1, 3], '2026-03-08T10:00:00.000Z'),
)
const directConversation = createConversation(42, [1, 2], '2024-05-31T12:52:09.000Z')
const client = createClient({
activeConversations: [...recentGroups, directConversation],
users: {
1: { id: 1, name: 'Me' },
2: { id: 2, name: 'Alice Example' },
3: { id: 3, name: 'Bob Example' },
},
})

apiMocks.getTwistClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice'])

expect(client.conversations.getConversations).toHaveBeenCalledWith({
workspaceId: 1,
limit: 100,
})
expect(client.conversations.getConversations).toHaveBeenCalledWith({
workspaceId: 1,
limit: 100,
beforeId: 1901,
})
expect(refsMocks.resolveConversationId).not.toHaveBeenCalled()

consoleSpy.mockRestore()
})

it('lists matching group conversations when --include-groups is set', async () => {
const directConversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
const groupConversation = createConversation(43, [1, 2, 3], '2026-03-09T10:00:00.000Z')
const client = createClient({
activeConversations: [directConversation, groupConversation],
users: {
1: { id: 1, name: 'Me' },
2: { id: 2, name: 'Alice Example' },
3: { id: 3, name: 'Bob Example' },
},
})

apiMocks.getTwistClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync([
'node',
'tw',
'conversation',
'with',
'Alice',
'--include-groups',
'--json',
])

expect(refsMocks.resolveConversationId).not.toHaveBeenCalled()
expect(
JSON.parse(consoleSpy.mock.calls[0][0]).map(
(conversation: { id: number }) => conversation.id,
),
).toEqual([43, 42])

consoleSpy.mockRestore()
})

it('prints a clean error and exits non-zero for ambiguous user refs', async () => {
refsMocks.resolveUserRefs.mockRejectedValue(
new Error(
'Multiple users match "Alex":\n 1 Alex <alex@example.com>\n\nUse numeric ID to specify.',
),
)

const program = createProgram()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
code?: string | number | null,
) => {
throw new Error(`EXIT_${code}`)
}) as never)

await expect(
program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alex']),
).rejects.toThrow('EXIT_1')

expect(errorSpy).toHaveBeenCalledWith(
'Multiple users match "Alex":\n 1 Alex <alex@example.com>\n\nUse numeric ID to specify.',
)

exitSpy.mockRestore()
errorSpy.mockRestore()
})
})
Loading