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
1013vi . 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'
2028import type { Speaker } from '@/lib/speaker/types'
29+ import type { Account , User } from 'next-auth'
2130
2231const 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+ } )
0 commit comments