@@ -76,18 +76,26 @@ function setupAuthConfig(options = {}) {
7676
7777/**
7878 * Create mock for Google Play Games token exchange
79- * @param {string } accessToken - Access token to return
79+ * @param {string|Function } accessTokenOrResolver - Access token to return, or function(code, body) => accessToken
8080 * @param {Function } onCall - Optional callback when mock is called
8181 * @returns {Object } Mock response object
8282 */
83- function mockGpgamesTokenExchange ( accessToken = MOCK_ACCESS_TOKEN , onCall = null ) {
83+ function mockGpgamesTokenExchange ( accessTokenOrResolver = MOCK_ACCESS_TOKEN , onCall = null ) {
8484 return {
8585 url : GOOGLE_TOKEN_URL ,
8686 method : 'POST' ,
8787 response : {
8888 ok : true ,
8989 json : ( options ) => {
9090 if ( onCall ) onCall ( options ) ;
91+ const body = JSON . parse ( options . body ) ;
92+ const code = body . code ;
93+ let accessToken ;
94+ if ( typeof accessTokenOrResolver === 'function' ) {
95+ accessToken = accessTokenOrResolver ( code , body ) ;
96+ } else {
97+ accessToken = accessTokenOrResolver ;
98+ }
9199 return Promise . resolve ( { access_token : accessToken } ) ;
92100 } ,
93101 } ,
@@ -116,18 +124,27 @@ function mockGpgamesPlayerInfo(userId = MOCK_USER_ID, onCall = null) {
116124
117125/**
118126 * Create mock for Instagram token exchange
119- * @param {string } accessToken - Access token to return
127+ * @param {string|Function } accessTokenOrResolver - Access token to return, or function(code, body) => accessToken
120128 * @param {Function } onCall - Optional callback when mock is called
121129 * @returns {Object } Mock response object
122130 */
123- function mockInstagramTokenExchange ( accessToken = 'ig_token_1' , onCall = null ) {
131+ function mockInstagramTokenExchange ( accessTokenOrResolver = 'ig_token_1' , onCall = null ) {
124132 return {
125133 url : IG_TOKEN_URL ,
126134 method : 'POST' ,
127135 response : {
128136 ok : true ,
129137 json : ( options ) => {
130138 if ( onCall ) onCall ( options ) ;
139+ // Instagram uses URLSearchParams, not JSON
140+ const body = new URLSearchParams ( options . body ) ;
141+ const code = body . get ( 'code' ) ;
142+ let accessToken ;
143+ if ( typeof accessTokenOrResolver === 'function' ) {
144+ accessToken = accessTokenOrResolver ( code , body ) ;
145+ } else {
146+ accessToken = accessTokenOrResolver ;
147+ }
131148 return Promise . resolve ( { access_token : accessToken } ) ;
132149 } ,
133150 } ,
@@ -136,20 +153,34 @@ function mockInstagramTokenExchange(accessToken = 'ig_token_1', onCall = null) {
136153
137154/**
138155 * Create mock for Instagram user info
139- * @param {string } accessToken - Access token used in URL
156+ * @param {string|Function } accessTokenOrResolver - Access token or function(accessToken) => userId for dynamic responses
140157 * @param {string } userId - User ID to return (default: 'I1')
141158 * @param {Function } onCall - Optional callback when mock is called
142- * @returns {Object } Mock response object
159+ * @returns {Object } Mock response object with dynamic URL matching
143160 */
144- function mockInstagramUserInfo ( accessToken = 'ig_token_1' , userId = 'I1' , onCall = null ) {
161+ function mockInstagramUserInfo ( accessTokenOrResolver = 'ig_token_1' , userId = 'I1' , onCall = null ) {
162+ // For dynamic access tokens, use a function that matches any IG_ME_URL pattern
163+ const urlPattern = typeof accessTokenOrResolver === 'function'
164+ ? ( url ) => url && url . startsWith ( 'https://graph.instagram.com/me?fields=id&access_token=' )
165+ : IG_ME_URL ( accessTokenOrResolver ) ;
166+
145167 return {
146- url : IG_ME_URL ( accessToken ) ,
168+ url : urlPattern ,
147169 method : 'GET' ,
148170 response : {
149171 ok : true ,
150172 json : ( options ) => {
151173 if ( onCall ) onCall ( options ) ;
152- return Promise . resolve ( { id : userId } ) ;
174+ // Extract accessToken from URL if resolver is a function
175+ let resolvedUserId = userId ;
176+ if ( typeof accessTokenOrResolver === 'function' && options && options . url ) {
177+ const urlMatch = options . url . match ( / a c c e s s _ t o k e n = ( [ ^ & ] + ) / ) ;
178+ if ( urlMatch ) {
179+ const token = urlMatch [ 1 ] ;
180+ resolvedUserId = accessTokenOrResolver ( token ) || userId ;
181+ }
182+ }
183+ return Promise . resolve ( { id : resolvedUserId } ) ;
153184 } ,
154185 } ,
155186 } ;
@@ -159,7 +190,7 @@ function mockInstagramUserInfo(accessToken = 'ig_token_1', userId = 'I1', onCall
159190 * Create complete mock for gpgames login flow
160191 * @param {Object } options - Configuration options
161192 * @param {string } options.userId - User ID (default: MOCK_USER_ID)
162- * @param {string } options.accessToken - Access token (default: MOCK_ACCESS_TOKEN)
193+ * @param {string|Function } options.accessToken - Access token or function(code, body) => accessToken for dynamic responses
163194 * @param {Function } options.onTokenExchange - Callback for token exchange
164195 * @param {Function } options.onPlayerInfo - Callback for player info
165196 * @returns {Array } Array of mock responses
@@ -182,7 +213,7 @@ function mockGpgamesLogin(options = {}) {
182213 * Create complete mock for instagram login flow
183214 * @param {Object } options - Configuration options
184215 * @param {string } options.userId - User ID (default: 'I1')
185- * @param {string } options.accessToken - Access token (default: 'ig_token_1')
216+ * @param {string|Function } options.accessToken - Access token or function(code, body) => accessToken for dynamic responses
186217 * @param {Function } options.onTokenExchange - Callback for token exchange
187218 * @param {Function } options.onUserInfo - Callback for user info
188219 * @returns {Array } Array of mock responses
@@ -195,9 +226,13 @@ function mockInstagramLogin(options = {}) {
195226 onUserInfo = null ,
196227 } = options ;
197228
229+ // If accessToken is a function, we need to handle dynamic URL generation for IG_ME_URL
230+ // For now, use first token or default for URL construction
231+ const urlToken = typeof accessToken === 'function' ? 'ig_token_1' : accessToken ;
232+
198233 return [
199234 mockInstagramTokenExchange ( accessToken , onTokenExchange ) ,
200- mockInstagramUserInfo ( accessToken , userId , onUserInfo ) ,
235+ mockInstagramUserInfo ( urlToken , userId , onUserInfo ) ,
201236 ] ;
202237}
203238
@@ -236,6 +271,32 @@ async function createUserWithGpgames(options = {}) {
236271 } ) ;
237272}
238273
274+ /**
275+ * Create user with gpgames authData and return session token
276+ * @param {Object } options - Configuration options
277+ * @param {string } options.userId - User ID (default: MOCK_USER_ID)
278+ * @param {string } options.code - Auth code (default: 'C1')
279+ * @param {boolean } options.fetch - Whether to fetch user after creation (default: true)
280+ * @returns {Promise<{user: Parse.User, sessionToken: string}> } User and session token
281+ */
282+ async function createUserWithGpgamesAndSession ( options = { } ) {
283+ const { userId = MOCK_USER_ID , code = 'C1' , fetch = true } = options ;
284+
285+ mockFetch ( mockGpgamesLogin ( { userId } ) ) ;
286+
287+ const user = await Parse . User . logInWith ( 'gpgames' , {
288+ authData : { id : userId , code } ,
289+ } ) ;
290+
291+ const sessionToken = user . getSessionToken ( ) ;
292+
293+ if ( fetch ) {
294+ await user . fetch ( { sessionToken } ) ;
295+ }
296+
297+ return { user, sessionToken } ;
298+ }
299+
239300/**
240301 * Create user with password auth
241302 * @param {Object } options - Configuration options
@@ -249,6 +310,152 @@ async function createUserWithPassword(options = {}) {
249310 return await Parse . User . signUp ( username , password ) ;
250311}
251312
313+ /**
314+ * Create user with password auth and return session token
315+ * @param {Object } options - Configuration options
316+ * @param {string } options.username - Username (default: TEST_USERNAME)
317+ * @param {string } options.password - Password (default: TEST_PASSWORD)
318+ * @param {boolean } options.fetch - Whether to fetch user after creation (default: true)
319+ * @returns {Promise<{user: Parse.User, sessionToken: string}> } User and session token
320+ */
321+ async function createUserWithPasswordAndSession ( options = { } ) {
322+ const { username = TEST_USERNAME , password = TEST_PASSWORD , fetch = true } = options ;
323+
324+ const user = await Parse . User . signUp ( username , password ) ;
325+ const sessionToken = user . getSessionToken ( ) ;
326+
327+ if ( fetch ) {
328+ await user . fetch ( { sessionToken } ) ;
329+ }
330+
331+ return { user, sessionToken } ;
332+ }
333+
334+ // ============================================
335+ // AuthData Assertion Helpers
336+ // ============================================
337+
338+ /**
339+ * Assert that authData contains expected providers
340+ * @param {Parse.User } user - User object
341+ * @param {Object } expectedProviders - Object with provider names as keys and expected data as values
342+ * @param {Object } options - Options
343+ * @param {boolean } options.useMasterKey - Whether to fetch with master key (default: false)
344+ * @param {string } options.sessionToken - Session token for fetch (default: undefined)
345+ * @returns {Promise<Object> } The authData object
346+ */
347+ async function assertAuthDataProviders ( user , expectedProviders , options = { } ) {
348+ const { useMasterKey = false , sessionToken } = options ;
349+
350+ if ( useMasterKey ) {
351+ await user . fetch ( { useMasterKey : true } ) ;
352+ } else if ( sessionToken ) {
353+ await user . fetch ( { sessionToken } ) ;
354+ }
355+
356+ const authData = user . get ( 'authData' ) ;
357+ expect ( authData ) . toBeDefined ( ) ;
358+
359+ for ( const [ provider , expectedData ] of Object . entries ( expectedProviders ) ) {
360+ if ( expectedData === null ) {
361+ expect ( authData [ provider ] ) . toBeUndefined ( ) ;
362+ } else if ( typeof expectedData === 'object' ) {
363+ expect ( authData [ provider ] ) . toBeDefined ( ) ;
364+ for ( const [ key , value ] of Object . entries ( expectedData ) ) {
365+ expect ( authData [ provider ] [ key ] ) . toBe ( value ) ;
366+ }
367+ } else {
368+ expect ( authData [ provider ] ) . toBe ( expectedData ) ;
369+ }
370+ }
371+
372+ return authData ;
373+ }
374+
375+ /**
376+ * Update user authData with session token
377+ * @param {Parse.User } user - User object
378+ * @param {Object } authDataUpdate - AuthData to update
379+ * @param {string } sessionToken - Session token
380+ * @param {boolean } fetchAfter - Whether to fetch user after update (default: true)
381+ * @returns {Promise<Parse.User> } Updated user
382+ */
383+ async function updateUserAuthData ( user , authDataUpdate , sessionToken , fetchAfter = true ) {
384+ await user . save ( { authData : authDataUpdate } , { sessionToken } ) ;
385+ if ( fetchAfter ) {
386+ await user . fetch ( { sessionToken } ) ;
387+ }
388+ return user ;
389+ }
390+
391+ /**
392+ * Setup mocks for gpgames and instagram with dynamic responses
393+ * @param {Object } options - Configuration options
394+ * @param {string|Function } options.gpgamesResolver - Access token or function (code) => accessToken for gpgames
395+ * @param {string|Function } options.instagramResolver - Access token or function (code) => accessToken for instagram
396+ * @param {Function } options.onGpgamesTokenExchange - Callback for gpgames token exchange
397+ * @param {Function } options.onInstagramTokenExchange - Callback for instagram token exchange
398+ * @returns {Array } Array of mock responses
399+ */
400+ function setupGpgamesAndInstagramMocks ( options = { } ) {
401+ const {
402+ gpgamesResolver = MOCK_ACCESS_TOKEN ,
403+ instagramResolver = 'ig_token_1' ,
404+ onGpgamesTokenExchange = null ,
405+ onInstagramTokenExchange = null ,
406+ } = options ;
407+
408+ return [
409+ ...mockGpgamesLogin ( {
410+ accessToken : typeof gpgamesResolver === 'function'
411+ ? gpgamesResolver
412+ : ( ) => gpgamesResolver ,
413+ onTokenExchange : onGpgamesTokenExchange ,
414+ } ) ,
415+ ...mockInstagramLogin ( {
416+ accessToken : typeof instagramResolver === 'function'
417+ ? instagramResolver
418+ : ( ) => instagramResolver ,
419+ onTokenExchange : onInstagramTokenExchange ,
420+ } ) ,
421+ ] ;
422+ }
423+
424+ /**
425+ * Create validation tracker for a provider
426+ * @param {string } provider - Provider name ('gpgames' or 'instagram')
427+ * @param {Object } options - Configuration options
428+ * @param {Function } options.onValidation - Callback when validation is called
429+ * @returns {Object } Mock setup with validation tracker
430+ */
431+ function createValidationTracker ( provider , options = { } ) {
432+ const { onValidation } = options ;
433+ let validated = false ;
434+
435+ const tracker = {
436+ get validated ( ) { return validated ; } ,
437+ reset ( ) { validated = false ; } ,
438+ } ;
439+
440+ if ( provider === 'gpgames' ) {
441+ mockFetch ( mockGpgamesLogin ( {
442+ onTokenExchange : ( ) => {
443+ validated = true ;
444+ if ( onValidation ) onValidation ( ) ;
445+ } ,
446+ } ) ) ;
447+ } else if ( provider === 'instagram' ) {
448+ mockFetch ( mockInstagramLogin ( {
449+ onTokenExchange : ( ) => {
450+ validated = true ;
451+ if ( onValidation ) onValidation ( ) ;
452+ } ,
453+ } ) ) ;
454+ }
455+
456+ return tracker ;
457+ }
458+
252459// ============================================
253460// Exports
254461// ============================================
@@ -282,6 +489,14 @@ module.exports = {
282489
283490 // User Creation
284491 createUserWithGpgames,
492+ createUserWithGpgamesAndSession,
285493 createUserWithPassword,
494+ createUserWithPasswordAndSession,
495+
496+ // AuthData Assertion Helpers
497+ assertAuthDataProviders,
498+ updateUserAuthData,
499+ setupGpgamesAndInstagramMocks,
500+ createValidationTracker,
286501} ;
287502
0 commit comments