Skip to content

Commit 7f5d124

Browse files
committed
Update updateSpeaker function to validate image format before patching
1 parent ab241cd commit 7f5d124

2 files changed

Lines changed: 136 additions & 13 deletions

File tree

__tests__/lib/speaker/sanity.test.ts

Lines changed: 135 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
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-
})
1+
const { mockCommit, mockSet, mockPatch, mockFetch, mockCreate } = vi.hoisted(
2+
() => {
3+
const mockCommit = vi.fn().mockResolvedValue({})
4+
const mockSet = vi.fn()
5+
mockSet.mockReturnValue({ commit: mockCommit })
6+
const mockPatch = vi.fn().mockReturnValue({ set: mockSet })
7+
const mockFetch = vi.fn()
8+
const mockCreate = vi.fn()
9+
return { mockCommit, mockSet, mockPatch, mockFetch, mockCreate }
10+
},
11+
)
912

1013
vi.mock('@/lib/sanity/client', () => ({
1114
clientReadUncached: {
1215
fetch: mockFetch,
1316
},
1417
clientWrite: {
1518
patch: mockPatch,
19+
create: mockCreate,
1620
},
1721
}))
1822

19-
import { updateSpeaker } from '@/lib/speaker/sanity'
23+
vi.mock('uuid', () => ({
24+
v4: () => 'mock-uuid-1234',
25+
}))
26+
27+
import { updateSpeaker, getOrCreateSpeaker } from '@/lib/speaker/sanity'
2028
import type { Speaker } from '@/lib/speaker/types'
29+
import type { Account, User } from 'next-auth'
2130

2231
const baseSpeaker: Speaker = {
2332
_id: 'speaker-1',
@@ -53,7 +62,7 @@ describe('updateSpeaker', () => {
5362
expect(mockCommit).toHaveBeenCalled()
5463
})
5564

56-
it('should convert image string to Sanity image reference', async () => {
65+
it('should convert image asset ID to Sanity image reference', async () => {
5766
const { speaker, err } = await updateSpeaker('speaker-1', {
5867
name: 'Updated Name',
5968
image: 'image-abc123-500x500-png',
@@ -62,7 +71,6 @@ describe('updateSpeaker', () => {
6271
expect(err).toBeNull()
6372
expect(speaker).toEqual(baseSpeaker)
6473

65-
// Single .set() call should include both fields and the image reference
6674
expect(mockSet).toHaveBeenCalledTimes(1)
6775
expect(mockSet).toHaveBeenCalledWith({
6876
name: 'Updated Name',
@@ -76,10 +84,39 @@ describe('updateSpeaker', () => {
7684
})
7785
})
7886

87+
describe('image field regression: CDN URLs and non-asset strings must be ignored', () => {
88+
it.each([
89+
[
90+
'Sanity CDN URL',
91+
'https://cdn.sanity.io/images/mvzwvw14/production/620c5070-4925x4925.jpg',
92+
],
93+
[
94+
'GitHub avatar URL',
95+
'https://avatars.githubusercontent.com/u/12345?v=4',
96+
],
97+
[
98+
'LinkedIn profile image URL',
99+
'https://media.licdn.com/dms/image/v2/abc/profile-photo.jpg',
100+
],
101+
['generic HTTPS URL', 'https://example.com/photo.jpg'],
102+
['empty string', ''],
103+
['random non-asset string', 'not-a-valid-asset-id'],
104+
])('should ignore image when it is a %s', async (_, imageValue) => {
105+
const { speaker, err } = await updateSpeaker('speaker-1', {
106+
name: 'Updated Name',
107+
image: imageValue,
108+
})
109+
110+
expect(err).toBeNull()
111+
expect(speaker).toEqual(baseSpeaker)
112+
expect(mockSet).toHaveBeenCalledTimes(1)
113+
expect(mockSet).toHaveBeenCalledWith({ name: 'Updated Name' })
114+
})
115+
})
116+
79117
it('should not set image reference when image is undefined', async () => {
80118
await updateSpeaker('speaker-1', { name: 'No Image' })
81119

82-
// set should be called once (without image), not twice
83120
expect(mockSet).toHaveBeenCalledTimes(1)
84121
expect(mockSet).toHaveBeenCalledWith({ name: 'No Image' })
85122
})
@@ -102,3 +139,89 @@ describe('updateSpeaker', () => {
102139
expect(err!.message).toBe('Fetch failed')
103140
})
104141
})
142+
143+
describe('getOrCreateSpeaker', () => {
144+
const mockUser: User = {
145+
email: 'jane@example.com',
146+
name: 'Jane Doe',
147+
image: 'https://avatars.githubusercontent.com/u/99999?v=4',
148+
}
149+
150+
const mockAccount: Account = {
151+
provider: 'github',
152+
providerAccountId: '99999',
153+
type: 'oauth',
154+
}
155+
156+
beforeEach(() => {
157+
vi.clearAllMocks()
158+
})
159+
160+
it('should create new speaker with imageURL from OAuth, not image field', async () => {
161+
// No existing speaker found by provider or email
162+
mockFetch.mockResolvedValue(null)
163+
mockCreate.mockResolvedValue({
164+
_id: 'mock-uuid-1234',
165+
_type: 'speaker',
166+
name: 'Jane Doe',
167+
email: 'jane@example.com',
168+
imageURL: 'https://avatars.githubusercontent.com/u/99999?v=4',
169+
providers: ['github:99999'],
170+
})
171+
172+
const { speaker, err } = await getOrCreateSpeaker(mockUser, mockAccount)
173+
174+
expect(err).toBeNull()
175+
expect(speaker).toBeDefined()
176+
177+
// Verify create was called with imageURL (OAuth URL), NOT with image field
178+
expect(mockCreate).toHaveBeenCalledTimes(1)
179+
const createArg = mockCreate.mock.calls[0][0]
180+
expect(createArg.imageURL).toBe(
181+
'https://avatars.githubusercontent.com/u/99999?v=4',
182+
)
183+
expect(createArg.image).toBeUndefined()
184+
})
185+
186+
it('should return existing speaker found by provider without creating', async () => {
187+
const existingSpeaker = { ...baseSpeaker, providers: ['github:99999'] }
188+
mockFetch.mockResolvedValue(existingSpeaker)
189+
190+
const { speaker, err } = await getOrCreateSpeaker(mockUser, mockAccount)
191+
192+
expect(err).toBeNull()
193+
expect(speaker._id).toBe('speaker-1')
194+
expect(mockCreate).not.toHaveBeenCalled()
195+
})
196+
197+
it('should return error when user email is missing', async () => {
198+
const { err } = await getOrCreateSpeaker(
199+
{ email: '', name: 'No Email' },
200+
mockAccount,
201+
)
202+
203+
expect(err).toBeInstanceOf(Error)
204+
expect(err!.message).toBe('Missing user email or name')
205+
expect(mockCreate).not.toHaveBeenCalled()
206+
})
207+
208+
it('should set imageURL to empty string when OAuth has no image', async () => {
209+
mockFetch.mockResolvedValue(null)
210+
mockCreate.mockResolvedValue({
211+
_id: 'mock-uuid-1234',
212+
_type: 'speaker',
213+
name: 'No Avatar',
214+
email: 'no-avatar@example.com',
215+
})
216+
217+
await getOrCreateSpeaker(
218+
{ email: 'no-avatar@example.com', name: 'No Avatar', image: undefined },
219+
mockAccount,
220+
)
221+
222+
expect(mockCreate).toHaveBeenCalledTimes(1)
223+
const createArg = mockCreate.mock.calls[0][0]
224+
expect(createArg.imageURL).toBe('')
225+
expect(createArg.image).toBeUndefined()
226+
})
227+
})

src/lib/speaker/sanity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export async function updateSpeaker(
266266

267267
const patchData: Record<string, unknown> = { ...speakerWithoutImage }
268268

269-
if (typeof image === 'string' && image) {
269+
if (typeof image === 'string' && image.startsWith('image-')) {
270270
patchData.image = {
271271
_type: 'image',
272272
asset: { _type: 'reference', _ref: image },

0 commit comments

Comments
 (0)