Skip to content

Commit b583393

Browse files
authored
Add tRPC error handling and updateSpeaker functionality with image reference support (#351)
1 parent 315e579 commit b583393

5 files changed

Lines changed: 161 additions & 12 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
vi.mock('@/lib/auth', () => ({
2+
getAuthSession: vi.fn().mockResolvedValue(null),
3+
}))
4+
5+
import { isClientError } from '@/server/trpc'
6+
7+
describe('tRPC error handler', () => {
8+
describe('isClientError', () => {
9+
it.each([
10+
'NOT_FOUND',
11+
'BAD_REQUEST',
12+
'UNAUTHORIZED',
13+
'FORBIDDEN',
14+
'PARSE_ERROR',
15+
])('should classify %s as a client error', (code) => {
16+
expect(isClientError(code)).toBe(true)
17+
})
18+
19+
it.each(['INTERNAL_SERVER_ERROR', 'TIMEOUT', 'TOO_MANY_REQUESTS'])(
20+
'should not classify %s as a client error',
21+
(code) => {
22+
expect(isClientError(code)).toBe(false)
23+
},
24+
)
25+
})
26+
})
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const { mockCommit, mockSet, mockPatch, mockFetch } = vi.hoisted(() => {
2+
const mockCommit = vi.fn().mockResolvedValue({})
3+
const mockSet = vi.fn()
4+
mockSet.mockReturnValue({ commit: mockCommit })
5+
const mockPatch = vi.fn().mockReturnValue({ set: mockSet })
6+
const mockFetch = vi.fn()
7+
return { mockCommit, mockSet, mockPatch, mockFetch }
8+
})
9+
10+
vi.mock('@/lib/sanity/client', () => ({
11+
clientReadUncached: {
12+
fetch: mockFetch,
13+
},
14+
clientWrite: {
15+
patch: mockPatch,
16+
},
17+
}))
18+
19+
import { updateSpeaker } from '@/lib/speaker/sanity'
20+
import type { Speaker } from '@/lib/speaker/types'
21+
22+
const baseSpeaker: Speaker = {
23+
_id: 'speaker-1',
24+
_rev: 'rev-1',
25+
_createdAt: '2025-01-01T00:00:00Z',
26+
_updatedAt: '2025-01-01T00:00:00Z',
27+
name: 'Test Speaker',
28+
title: 'Engineer',
29+
email: 'test@example.com',
30+
slug: 'test-speaker',
31+
flags: [],
32+
}
33+
34+
describe('updateSpeaker', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
mockFetch.mockResolvedValue(baseSpeaker)
38+
})
39+
40+
it('should update speaker without image', async () => {
41+
const { speaker, err } = await updateSpeaker('speaker-1', {
42+
name: 'Updated Name',
43+
bio: 'New bio',
44+
})
45+
46+
expect(err).toBeNull()
47+
expect(speaker).toEqual(baseSpeaker)
48+
expect(mockPatch).toHaveBeenCalledWith('speaker-1')
49+
expect(mockSet).toHaveBeenCalledWith({
50+
name: 'Updated Name',
51+
bio: 'New bio',
52+
})
53+
expect(mockCommit).toHaveBeenCalled()
54+
})
55+
56+
it('should convert image string to Sanity image reference', async () => {
57+
const { speaker, err } = await updateSpeaker('speaker-1', {
58+
name: 'Updated Name',
59+
image: 'image-abc123-500x500-png',
60+
})
61+
62+
expect(err).toBeNull()
63+
expect(speaker).toEqual(baseSpeaker)
64+
65+
// Single .set() call should include both fields and the image reference
66+
expect(mockSet).toHaveBeenCalledTimes(1)
67+
expect(mockSet).toHaveBeenCalledWith({
68+
name: 'Updated Name',
69+
image: {
70+
_type: 'image',
71+
asset: {
72+
_type: 'reference',
73+
_ref: 'image-abc123-500x500-png',
74+
},
75+
},
76+
})
77+
})
78+
79+
it('should not set image reference when image is undefined', async () => {
80+
await updateSpeaker('speaker-1', { name: 'No Image' })
81+
82+
// set should be called once (without image), not twice
83+
expect(mockSet).toHaveBeenCalledTimes(1)
84+
expect(mockSet).toHaveBeenCalledWith({ name: 'No Image' })
85+
})
86+
87+
it('should return error when patch fails', async () => {
88+
mockCommit.mockRejectedValueOnce(new Error('Sanity error'))
89+
90+
const { err } = await updateSpeaker('speaker-1', { name: 'Fail' })
91+
92+
expect(err).toBeInstanceOf(Error)
93+
expect(err!.message).toBe('Sanity error')
94+
})
95+
96+
it('should return error when getSpeaker fails after patch', async () => {
97+
mockFetch.mockRejectedValueOnce(new Error('Fetch failed'))
98+
99+
const { err } = await updateSpeaker('speaker-1', { name: 'Fail' })
100+
101+
expect(err).toBeInstanceOf(Error)
102+
expect(err!.message).toBe('Fetch failed')
103+
})
104+
})

src/app/api/trpc/[trpc]/route.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
22
import { appRouter } from '@/server/_app'
3-
import { createTRPCContext } from '@/server/trpc'
3+
import { createTRPCContext, isClientError } from '@/server/trpc'
44
import { NextRequest } from 'next/server'
55

66
const handler = (req: NextRequest) =>
@@ -9,12 +9,10 @@ const handler = (req: NextRequest) =>
99
req,
1010
router: appRouter,
1111
createContext: () => createTRPCContext({ req }),
12-
onError:
13-
process.env.NODE_ENV === 'development'
14-
? ({ path, error }) => {
15-
console.error(`❌ tRPC failed on ${path ?? '<no-path>'}:`, error)
16-
}
17-
: undefined,
12+
onError: ({ path, error }) => {
13+
if (isClientError(error.code)) return
14+
console.error(`tRPC failed on ${path ?? '<no-path>'}:`, error)
15+
},
1816
})
1917

2018
export { handler as GET, handler as POST }

src/lib/speaker/sanity.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,19 +255,28 @@ export async function getPublicSpeaker(
255255
}
256256

257257
export async function updateSpeaker(
258-
spekaerId: string,
258+
speakerId: string,
259259
speaker: Partial<SpeakerInput>,
260260
): Promise<{ speaker: Speaker; err: Error | null }> {
261261
let err = null
262262
let updatedSpeaker: Speaker = {} as Speaker
263263

264264
try {
265-
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- image is excluded from patch
266-
const { image: _image, ...speakerWithoutImage } = speaker
267-
await clientWrite.patch(spekaerId).set(speakerWithoutImage).commit()
265+
const { image, ...speakerWithoutImage } = speaker
266+
267+
const patchData: Record<string, unknown> = { ...speakerWithoutImage }
268+
269+
if (typeof image === 'string' && image) {
270+
patchData.image = {
271+
_type: 'image',
272+
asset: { _type: 'reference', _ref: image },
273+
}
274+
}
275+
276+
await clientWrite.patch(speakerId).set(patchData).commit()
268277

269278
const { speaker: fetchedSpeaker, err: fetchErr } =
270-
await getSpeaker(spekaerId)
279+
await getSpeaker(speakerId)
271280
if (fetchErr) {
272281
throw fetchErr
273282
}

src/server/trpc.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,15 @@ export const publicProcedure = t.procedure
6565
export const protectedProcedure = t.procedure.use(requireAuth)
6666
export const adminProcedure = t.procedure.use(requireAuth).use(requireAdmin)
6767
export const router = t.router
68+
69+
const CLIENT_ERROR_CODES = new Set([
70+
'NOT_FOUND',
71+
'BAD_REQUEST',
72+
'UNAUTHORIZED',
73+
'FORBIDDEN',
74+
'PARSE_ERROR',
75+
])
76+
77+
export function isClientError(code: string): boolean {
78+
return CLIENT_ERROR_CODES.has(code)
79+
}

0 commit comments

Comments
 (0)