@@ -8,14 +8,20 @@ import {createStoreState} from '../store/createStoreState'
8
8
import { AuthStateType } from './authStateType'
9
9
import { type AuthState , authStore } from './authStore'
10
10
// Import only the public function
11
- import { refreshStampedToken } from './refreshStampedToken'
11
+ import {
12
+ getLastRefreshTime ,
13
+ getNextRefreshDelay ,
14
+ refreshStampedToken ,
15
+ setLastRefreshTime ,
16
+ } from './refreshStampedToken'
12
17
13
18
// Type definitions for Web Locks (can be kept if needed for context)
14
19
// ... (Lock, LockOptions, LockGrantedCallback types)
15
20
16
21
describe ( 'refreshStampedToken' , ( ) => {
17
22
let mockStorage : Storage
18
23
let originalNavigator : typeof navigator // Restored
24
+ let originalDocument : Document
19
25
let subscriptions : Subscription [ ]
20
26
// mockLocksRequest removed
21
27
@@ -25,6 +31,19 @@ describe('refreshStampedToken', () => {
25
31
vi . useFakeTimers ( )
26
32
27
33
originalNavigator = global . navigator // Restore original navigator setup
34
+
35
+ // Mock document for visibility API
36
+ originalDocument = global . document
37
+ Object . defineProperty ( global , 'document' , {
38
+ value : {
39
+ visibilityState : 'visible' ,
40
+ addEventListener : vi . fn ( ) ,
41
+ removeEventListener : vi . fn ( ) ,
42
+ } ,
43
+ writable : true ,
44
+ configurable : true ,
45
+ } )
46
+
28
47
mockStorage = {
29
48
getItem : vi . fn ( ) ,
30
49
setItem : vi . fn ( ) ,
@@ -76,6 +95,11 @@ describe('refreshStampedToken', () => {
76
95
value : originalNavigator ,
77
96
writable : true ,
78
97
} )
98
+ // Restore original document
99
+ Object . defineProperty ( global , 'document' , {
100
+ value : originalDocument ,
101
+ writable : true ,
102
+ } )
79
103
// Restore real timers
80
104
try {
81
105
await vi . runAllTimersAsync ( ) // Attempt to flush cleanly
@@ -122,6 +146,49 @@ describe('refreshStampedToken', () => {
122
146
const locksRequest = navigator . locks . request as ReturnType < typeof vi . fn >
123
147
expect ( locksRequest ) . not . toHaveBeenCalled ( )
124
148
} )
149
+
150
+ it ( 'does not refresh when tab is not visible' , async ( ) => {
151
+ // Set visibility to hidden
152
+ Object . defineProperty ( global , 'document' , {
153
+ value : {
154
+ visibilityState : 'hidden' ,
155
+ addEventListener : vi . fn ( ) ,
156
+ removeEventListener : vi . fn ( ) ,
157
+ } ,
158
+ writable : true ,
159
+ configurable : true ,
160
+ } )
161
+
162
+ const mockClient = {
163
+ observable : { request : vi . fn ( ( ) => of ( { token : 'sk-refreshed-token-st123' } ) ) } ,
164
+ }
165
+ const mockClientFactory = vi . fn ( ) . mockReturnValue ( mockClient )
166
+ const instance = createSanityInstance ( {
167
+ projectId : 'p' ,
168
+ dataset : 'd' ,
169
+ auth : { clientFactory : mockClientFactory , storageArea : mockStorage } ,
170
+ } )
171
+ const initialState = authStore . getInitialState ( instance )
172
+ initialState . authState = {
173
+ type : AuthStateType . LOGGED_IN ,
174
+ token : 'sk-initial-token-st123' ,
175
+ currentUser : null ,
176
+ }
177
+ initialState . dashboardContext = { mode : 'test' }
178
+ const state = createStoreState ( initialState )
179
+
180
+ const subscription = refreshStampedToken ( { state, instance} )
181
+ subscriptions . push ( subscription )
182
+
183
+ await vi . advanceTimersToNextTimerAsync ( )
184
+
185
+ // Verify that no refresh occurred
186
+ expect ( mockClient . observable . request ) . not . toHaveBeenCalled ( )
187
+ const finalAuthState = state . get ( ) . authState
188
+ if ( finalAuthState . type === AuthStateType . LOGGED_IN ) {
189
+ expect ( finalAuthState . token ) . toBe ( 'sk-initial-token-st123' )
190
+ }
191
+ } )
125
192
} )
126
193
127
194
describe ( 'non-dashboard context' , ( ) => {
@@ -150,11 +217,19 @@ describe('refreshStampedToken', () => {
150
217
subscriptions . push ( subscription ! )
151
218
} ) . not . toThrow ( )
152
219
153
- // Avoid advancing timers here, as it would trigger the infinite loop
154
- // await vi.advanceTimersByTimeAsync(0)
155
-
156
- // No assertions about navigator.locks.request call
157
- // No assertions about final token state (due to infinite loop in source)
220
+ // DO NOT advance timers or yield here - focus on immediate observable logic
221
+ // We cannot reliably test that failingLocksRequest is called due to async/timer issues,
222
+ // but we *can* test the consequence of it resolving to false.
223
+
224
+ // VERIFY THE OUTCOME:
225
+ // Check client request was NOT made (because filter(hasLock => hasLock) receives false)
226
+ expect ( mockClient . observable . request ) . not . toHaveBeenCalled ( )
227
+ // Check state remains unchanged
228
+ const finalAuthState = state . get ( ) . authState
229
+ expect ( finalAuthState . type ) . toBe ( AuthStateType . LOGGED_IN )
230
+ if ( finalAuthState . type === AuthStateType . LOGGED_IN ) {
231
+ expect ( finalAuthState . token ) . toBe ( 'sk-initial-token-st123' )
232
+ }
158
233
} )
159
234
160
235
it ( 'skips refresh if lock request returns false' , async ( ) => {
@@ -209,6 +284,61 @@ describe('refreshStampedToken', () => {
209
284
} )
210
285
} )
211
286
287
+ describe ( 'unsupported environments' , ( ) => {
288
+ it ( 'falls back to immediate refresh if Web Locks API is not supported' , async ( ) => {
289
+ // Temporarily remove navigator.locks for this test
290
+ const originalLocks = navigator . locks
291
+ Object . defineProperty ( global . navigator , 'locks' , {
292
+ value : undefined ,
293
+ writable : true ,
294
+ } )
295
+
296
+ try {
297
+ const mockClient = {
298
+ observable : { request : vi . fn ( ( ) => of ( { token : 'sk-refreshed-immediately-st123' } ) ) } ,
299
+ }
300
+ const mockClientFactory = vi . fn ( ) . mockReturnValue ( mockClient )
301
+ const instance = createSanityInstance ( {
302
+ projectId : 'p' ,
303
+ dataset : 'd' ,
304
+ auth : { clientFactory : mockClientFactory , storageArea : mockStorage } ,
305
+ } )
306
+ const initialState = authStore . getInitialState ( instance )
307
+ initialState . authState = {
308
+ type : AuthStateType . LOGGED_IN ,
309
+ token : 'sk-initial-token-st123' ,
310
+ currentUser : null ,
311
+ }
312
+ const state = createStoreState ( initialState )
313
+
314
+ const subscription = refreshStampedToken ( { state, instance} )
315
+ subscriptions . push ( subscription )
316
+
317
+ // Advance timers to allow the async `performRefresh` to execute
318
+ await vi . advanceTimersToNextTimerAsync ( )
319
+
320
+ // Verify the refresh was performed and state was updated
321
+ expect ( mockClient . observable . request ) . toHaveBeenCalled ( )
322
+ const finalAuthState = state . get ( ) . authState
323
+ expect ( finalAuthState . type ) . toBe ( AuthStateType . LOGGED_IN )
324
+ if ( finalAuthState . type === AuthStateType . LOGGED_IN ) {
325
+ expect ( finalAuthState . token ) . toBe ( 'sk-refreshed-immediately-st123' )
326
+ }
327
+ // Verify token was set in storage
328
+ expect ( mockStorage . setItem ) . toHaveBeenCalledWith (
329
+ initialState . options . storageKey ,
330
+ JSON . stringify ( { token : 'sk-refreshed-immediately-st123' } ) ,
331
+ )
332
+ } finally {
333
+ // Restore navigator.locks
334
+ Object . defineProperty ( global . navigator , 'locks' , {
335
+ value : originalLocks ,
336
+ writable : true ,
337
+ } )
338
+ }
339
+ } )
340
+ } )
341
+
212
342
// Restore other tests to their simpler form
213
343
it ( 'sets an error state when token refresh fails' , async ( ) => {
214
344
const error = new Error ( 'Refresh failed' )
@@ -298,3 +428,92 @@ describe('refreshStampedToken', () => {
298
428
}
299
429
} )
300
430
} )
431
+
432
+ describe ( 'time-based refresh helpers' , ( ) => {
433
+ let mockStorage : Storage
434
+ const storageKey = 'my-test-key'
435
+
436
+ beforeEach ( ( ) => {
437
+ mockStorage = {
438
+ getItem : vi . fn ( ) ,
439
+ setItem : vi . fn ( ) ,
440
+ removeItem : vi . fn ( ) ,
441
+ clear : vi . fn ( ) ,
442
+ key : vi . fn ( ) ,
443
+ length : 0 ,
444
+ }
445
+ vi . useFakeTimers ( )
446
+ } )
447
+
448
+ afterEach ( ( ) => {
449
+ vi . useRealTimers ( )
450
+ } )
451
+
452
+ describe ( 'getLastRefreshTime' , ( ) => {
453
+ it ( 'returns 0 if storage is undefined' , ( ) => {
454
+ expect ( getLastRefreshTime ( undefined , storageKey ) ) . toBe ( 0 )
455
+ } )
456
+
457
+ it ( 'returns 0 if item is not in storage' , ( ) => {
458
+ vi . spyOn ( mockStorage , 'getItem' ) . mockReturnValue ( null )
459
+ expect ( getLastRefreshTime ( mockStorage , storageKey ) ) . toBe ( 0 )
460
+ expect ( mockStorage . getItem ) . toHaveBeenCalledWith ( `${ storageKey } _last_refresh` )
461
+ } )
462
+
463
+ it ( 'returns the parsed timestamp from storage' , ( ) => {
464
+ const now = Date . now ( )
465
+ vi . spyOn ( mockStorage , 'getItem' ) . mockReturnValue ( now . toString ( ) )
466
+ expect ( getLastRefreshTime ( mockStorage , storageKey ) ) . toBe ( now )
467
+ } )
468
+
469
+ it ( 'returns 0 if stored data is malformed' , ( ) => {
470
+ vi . spyOn ( mockStorage , 'getItem' ) . mockReturnValue ( 'not a number' )
471
+ expect ( getLastRefreshTime ( mockStorage , storageKey ) ) . toBe ( 0 )
472
+ } )
473
+
474
+ it ( 'returns 0 on storage access error' , ( ) => {
475
+ vi . spyOn ( mockStorage , 'getItem' ) . mockImplementation ( ( ) => {
476
+ throw new Error ( 'Storage access failed' )
477
+ } )
478
+ expect ( getLastRefreshTime ( mockStorage , storageKey ) ) . toBe ( 0 )
479
+ } )
480
+ } )
481
+
482
+ describe ( 'setLastRefreshTime' , ( ) => {
483
+ it ( 'sets the current timestamp in storage' , ( ) => {
484
+ const now = Date . now ( )
485
+ setLastRefreshTime ( mockStorage , storageKey )
486
+ expect ( mockStorage . setItem ) . toHaveBeenCalledWith ( `${ storageKey } _last_refresh` , now . toString ( ) )
487
+ } )
488
+
489
+ it ( 'does not throw on storage access error' , ( ) => {
490
+ vi . spyOn ( mockStorage , 'setItem' ) . mockImplementation ( ( ) => {
491
+ throw new Error ( 'Storage access failed' )
492
+ } )
493
+ expect ( ( ) => setLastRefreshTime ( mockStorage , storageKey ) ) . not . toThrow ( )
494
+ } )
495
+ } )
496
+
497
+ describe ( 'getNextRefreshDelay' , ( ) => {
498
+ const REFRESH_INTERVAL = 12 * 60 * 60 * 1000
499
+
500
+ it ( 'returns 0 if last refresh time is not available' , ( ) => {
501
+ vi . spyOn ( mockStorage , 'getItem' ) . mockReturnValue ( null )
502
+ expect ( getNextRefreshDelay ( mockStorage , storageKey ) ) . toBe ( 0 )
503
+ } )
504
+
505
+ it ( 'returns the remaining time until the next refresh' , ( ) => {
506
+ const lastRefresh = Date . now ( ) - 10000 // 10 seconds ago
507
+ vi . spyOn ( mockStorage , 'getItem' ) . mockReturnValue ( lastRefresh . toString ( ) )
508
+
509
+ const delay = getNextRefreshDelay ( mockStorage , storageKey )
510
+ expect ( delay ) . toBeCloseTo ( REFRESH_INTERVAL - 10000 , - 2 )
511
+ } )
512
+
513
+ it ( 'returns 0 if the refresh interval has passed' , ( ) => {
514
+ const lastRefresh = Date . now ( ) - REFRESH_INTERVAL - 5000 // 5 seconds past due
515
+ vi . spyOn ( mockStorage , 'getItem' ) . mockReturnValue ( lastRefresh . toString ( ) )
516
+ expect ( getNextRefreshDelay ( mockStorage , storageKey ) ) . toBe ( 0 )
517
+ } )
518
+ } )
519
+ } )
0 commit comments