Skip to content

Commit a6e7fd6

Browse files
fix: handle batch response errors in thread view (#91)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 02bff25 commit a6e7fd6

5 files changed

Lines changed: 94 additions & 19 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"dist"
4848
],
4949
"dependencies": {
50-
"@doist/twist-sdk": "2.1.0",
50+
"@doist/twist-sdk": "2.1.1",
5151
"@pnpm/tabtab": "0.5.4",
5252
"chalk": "5.6.2",
5353
"commander": "14.0.3",

src/__tests__/thread.test.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ const apiMocks = vi.hoisted(() => ({
55
getTwistClient: vi.fn(),
66
}))
77

8-
vi.mock('../lib/api.js', () => apiMocks)
8+
vi.mock('../lib/api.js', async (importOriginal) => ({
9+
...(await importOriginal<typeof import('../lib/api.js')>()),
10+
getTwistClient: apiMocks.getTwistClient,
11+
}))
912

1013
vi.mock('../lib/public-channels.js', () => ({
1114
assertChannelIsPublic: vi.fn(),
@@ -76,7 +79,10 @@ function createClient({
7679
if (options?.batch) return { kind: 'comments' }
7780
return Promise.resolve(comments)
7881
}),
79-
getComment: vi.fn(),
82+
getComment: vi.fn((_id: number, options?: { batch?: boolean }) => {
83+
if (options?.batch) return { kind: 'comment', id: _id }
84+
return Promise.resolve(undefined)
85+
}),
8086
},
8187
channels: {
8288
getChannel: vi.fn((_id: number, options?: { batch?: boolean }) => {
@@ -97,11 +103,17 @@ function createClient({
97103
},
98104
batch: vi.fn(async (...requests: Array<{ kind: string; id?: number; userId?: number }>) =>
99105
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 }
106+
if (request.kind === 'thread') return { code: 200, data: thread }
107+
if (request.kind === 'comments') return { code: 200, data: comments }
108+
if (request.kind === 'comment')
109+
return {
110+
code: 200,
111+
data: comments.find((c) => c.id === request.id) ?? comments[0],
112+
}
113+
if (request.kind === 'channel') return { code: 200, data: channel }
103114
if (request.kind === 'user' && request.userId) {
104115
return {
116+
code: 200,
105117
data: users[request.userId] ?? {
106118
id: request.userId,
107119
name: `user:${request.userId}`,
@@ -303,3 +315,49 @@ describe('thread view --unread', () => {
303315
consoleSpy.mockRestore()
304316
})
305317
})
318+
319+
describe('thread view with failed batch response', () => {
320+
beforeEach(() => {
321+
vi.resetAllMocks()
322+
})
323+
324+
it('throws a clear error when comment batch response fails', async () => {
325+
const client = createClient({
326+
users: { 1: { id: 1, name: 'Alice' } },
327+
})
328+
// Override batch to return a 404 for the comment
329+
client.batch.mockResolvedValueOnce([
330+
{ code: 200, data: createThread(500) },
331+
{ code: 404, data: null as never },
332+
])
333+
apiMocks.getTwistClient.mockResolvedValue(client)
334+
335+
const program = createProgram()
336+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
337+
338+
await expect(
339+
program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--comment', '99999']),
340+
).rejects.toThrow('Failed to fetch comment 99999.')
341+
342+
consoleSpy.mockRestore()
343+
})
344+
345+
it('throws a clear error when thread batch response fails', async () => {
346+
const client = createClient()
347+
// Override batch to return a 404 for the thread
348+
client.batch.mockResolvedValueOnce([
349+
{ code: 404, data: null as never },
350+
{ code: 200, data: [] },
351+
])
352+
apiMocks.getTwistClient.mockResolvedValue(client)
353+
354+
const program = createProgram()
355+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
356+
357+
await expect(program.parseAsync(['node', 'tw', 'thread', 'view', '500'])).rejects.toThrow(
358+
'Failed to fetch thread.',
359+
)
360+
361+
consoleSpy.mockRestore()
362+
})
363+
})

src/commands/thread/view.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { TwistApi } from '@doist/twist-sdk'
22
import chalk from 'chalk'
3-
import { getTwistClient } from '../../lib/api.js'
3+
import { assertBatchData, getTwistClient } from '../../lib/api.js'
44
import { formatRelativeDate } from '../../lib/dates.js'
55
import { renderMarkdown } from '../../lib/markdown.js'
66
import type { PaginatedViewOptions } from '../../lib/options.js'
@@ -26,8 +26,8 @@ async function viewSingleComment(
2626
client.comments.getComment(commentId, { batch: true }),
2727
)
2828

29-
const thread = threadResponse.data
30-
const comment = commentResponse.data
29+
const thread = assertBatchData(threadResponse, 'thread')
30+
const comment = assertBatchData(commentResponse, `comment ${commentId}`)
3131

3232
const userIds = new Set([thread.creator, comment.creator])
3333
const userCalls = [...userIds].map((id) =>
@@ -41,8 +41,10 @@ async function viewSingleComment(
4141
...userCalls,
4242
)
4343

44-
const channel = channelResponse.data
45-
const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name]))
44+
const channel = assertBatchData(channelResponse, 'channel')
45+
const userMap = new Map(
46+
userResponses.filter((r) => r.data != null).map((r) => [r.data.id, r.data.name]),
47+
)
4648

4749
if (options.json) {
4850
const output = {
@@ -102,8 +104,8 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
102104
),
103105
)
104106

105-
const thread = threadResponse.data
106-
const comments = commentsResponse.data
107+
const thread = assertBatchData(threadResponse, 'thread')
108+
const comments = assertBatchData(commentsResponse, 'comments')
107109

108110
await assertChannelIsPublic(thread.channelId, thread.workspaceId)
109111

@@ -149,8 +151,10 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
149151
...userCalls,
150152
)
151153

152-
const channel = channelResponse.data
153-
const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name]))
154+
const channel = assertBatchData(channelResponse, 'channel')
155+
const userMap = new Map(
156+
userResponses.filter((r) => r.data != null).map((r) => [r.data.id, r.data.name]),
157+
)
154158

155159
if (options.json) {
156160
const output = {

src/lib/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,17 @@ export function clearUserCache(): void {
261261
sessionUserCache = null
262262
}
263263

264+
/**
265+
* Validates a batch response and returns the data, throwing on errors.
266+
* Also handles the case where the SDK fails to validate the response schema
267+
* (e.g. when the batch API wraps entities in a key like `{comment: {...}}`).
268+
* In that case, the raw transformed data is returned — check for expected fields.
269+
*/
270+
export function assertBatchData<T>(response: { code: number; data: T }, label: string): T {
271+
if (response.code >= 400 || response.data == null) {
272+
throw new Error(`Failed to fetch ${label}.`)
273+
}
274+
return response.data
275+
}
276+
264277
export type { Workspace, WorkspaceUser, User }

0 commit comments

Comments
 (0)