UNPKG

@shopify/shop-minis-react

Version:

React component library for Shopify Shop Minis with Tailwind CSS v4 support (source-only, requires TypeScript)

341 lines (269 loc) 10.8 kB
import {renderHook, act} from '@testing-library/react' import {describe, expect, it, vi, beforeEach} from 'vitest' import {useHandleAction} from '../../internal/useHandleAction' import {useShopActions} from '../../internal/useShopActions' import {useGenerateUserToken} from './useGenerateUserToken' // Mock the internal hooks vi.mock('../../internal/useShopActions', () => ({ useShopActions: vi.fn(), })) vi.mock('../../internal/useHandleAction', () => ({ useHandleAction: vi.fn((action: any) => action), })) describe('useGenerateUserToken', () => { const mockToken = 'test-token-123' const mockUserState = 'VERIFIED' // Helper to create a future timestamp const getFutureTimestamp = (hoursFromNow: number) => { const date = new Date() date.setHours(date.getHours() + hoursFromNow) return date.toISOString() } // Helper to create an expired timestamp const getExpiredTimestamp = () => { const date = new Date() date.setHours(date.getHours() - 1) return date.toISOString() } const createMockResponse = (overrides = {}) => ({ data: { token: mockToken, expiresAt: getFutureTimestamp(24), // 24 hours from now userState: mockUserState, ...overrides, }, userErrors: [], }) let mockGenerateUserToken: ReturnType<typeof vi.fn> beforeEach(() => { vi.clearAllMocks() mockGenerateUserToken = vi.fn() ;(useShopActions as ReturnType<typeof vi.fn>).mockReturnValue({ generateUserToken: mockGenerateUserToken, }) ;(useHandleAction as ReturnType<typeof vi.fn>).mockImplementation( (action: any) => action ) }) describe('Token Generation', () => { it('should generate a new token on first call', async () => { mockGenerateUserToken.mockResolvedValue(createMockResponse()) const {result} = renderHook(() => useGenerateUserToken()) let tokenResponse: any await act(async () => { tokenResponse = await result.current.generateUserToken() }) expect(tokenResponse.data).toEqual({ token: mockToken, expiresAt: expect.any(String), userState: mockUserState, }) expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) }) it('should throw error when token generation fails', async () => { const error = new Error('Network error') mockGenerateUserToken.mockRejectedValue(error) const {result} = renderHook(() => useGenerateUserToken()) await expect(result.current.generateUserToken()).rejects.toThrow( 'Network error' ) expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) }) it('should return response even when token is incomplete', async () => { const incompleteResponse = { data: { token: null, expiresAt: null, userState: null, }, userErrors: [ {code: 'INVALID_TOKEN', message: 'Failed to generate token'}, ], } mockGenerateUserToken.mockResolvedValue(incompleteResponse) const {result} = renderHook(() => useGenerateUserToken()) const response = await result.current.generateUserToken() expect(response).toEqual(incompleteResponse) expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) }) }) describe('Token Caching', () => { it('should return cached token on subsequent calls', async () => { mockGenerateUserToken.mockResolvedValue(createMockResponse()) const {result} = renderHook(() => useGenerateUserToken()) // First call - should hit the API let firstResponse: any await act(async () => { firstResponse = await result.current.generateUserToken() }) // Second call - should return cached token let secondResponse: any await act(async () => { secondResponse = await result.current.generateUserToken() }) // Both should be the same reference (cached) expect(firstResponse).toBe(secondResponse) expect(secondResponse.data.token).toBe(mockToken) expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) // Only called once }) it('should request new token when cached token is expired', async () => { mockGenerateUserToken .mockResolvedValueOnce( createMockResponse({ token: 'expired-token', expiresAt: getExpiredTimestamp(), }) ) .mockResolvedValueOnce( createMockResponse({ token: 'new-token', expiresAt: getFutureTimestamp(24), }) ) const {result} = renderHook(() => useGenerateUserToken()) // First call - gets expired token await act(async () => { await result.current.generateUserToken() }) // Second call - should request new token since first is expired let secondResponse: any await act(async () => { secondResponse = await result.current.generateUserToken() }) expect(secondResponse.data.token).toBe('new-token') expect(mockGenerateUserToken).toHaveBeenCalledTimes(2) }) it('should request new token when cached token is within 5-minute buffer', async () => { // Token expires in 4 minutes (within buffer) mockGenerateUserToken .mockResolvedValueOnce( createMockResponse({ token: 'almost-expired-token', expiresAt: getFutureTimestamp(0.066), // ~4 minutes }) ) .mockResolvedValueOnce( createMockResponse({ token: 'fresh-token', expiresAt: getFutureTimestamp(24), }) ) const {result} = renderHook(() => useGenerateUserToken()) // First call await act(async () => { await result.current.generateUserToken() }) // Second call - should get new token due to buffer let secondResponse: any await act(async () => { secondResponse = await result.current.generateUserToken() }) expect(secondResponse.data.token).toBe('fresh-token') expect(mockGenerateUserToken).toHaveBeenCalledTimes(2) }) it('should use cached token when outside 5-minute buffer', async () => { // Token expires in 1 hour (well outside the 5-minute buffer) const mockResponse = createMockResponse({ token: 'valid-token', expiresAt: getFutureTimestamp(1), // 1 hour from now }) mockGenerateUserToken.mockResolvedValue(mockResponse) const {result} = renderHook(() => useGenerateUserToken()) // First call const firstResponse = await result.current.generateUserToken() // Verify the response has the expected structure expect(firstResponse.data.token).toBe('valid-token') expect(firstResponse.data.expiresAt).toBeDefined() // Second call - should use cached token const secondResponse = await result.current.generateUserToken() // Both responses should be the same cached object expect(firstResponse).toBe(secondResponse) expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) }) }) describe('Race Condition Prevention', () => { it('should handle concurrent requests by returning same promise', async () => { // Create a delayed promise to simulate an API call const mockResponse = createMockResponse() mockGenerateUserToken.mockImplementation( () => new Promise(resolve => { setTimeout(() => resolve(mockResponse), 10) }) ) const {result} = renderHook(() => useGenerateUserToken()) // Make two concurrent calls const promise1 = result.current.generateUserToken() const promise2 = result.current.generateUserToken() // Only one API call should be made expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) // Wait for both promises const [response1, response2] = await Promise.all([promise1, promise2]) // Both should return the same response expect(response1).toEqual(mockResponse) expect(response2).toEqual(mockResponse) expect(response1).toBe(response2) }) it('should handle multiple rapid sequential calls correctly', async () => { mockGenerateUserToken.mockResolvedValue(createMockResponse()) const {result} = renderHook(() => useGenerateUserToken()) // Make multiple rapid calls const promises = Array.from({length: 5}, () => result.current.generateUserToken() ) // All should resolve to the same token const responses = await Promise.all(promises) responses.forEach(response => { expect(response.data.token).toBe(mockToken) // All should be the same object reference expect(response).toBe(responses[0]) }) // Should only have made one API call expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) }) }) describe('Error Handling', () => { it('should clear cache on error and retry', async () => { mockGenerateUserToken .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce(createMockResponse()) const {result} = renderHook(() => useGenerateUserToken()) // First call - should fail await expect(result.current.generateUserToken()).rejects.toThrow( 'Network error' ) // Second call - should succeed with new request let response: any await act(async () => { response = await result.current.generateUserToken() }) expect(response.data.token).toBe(mockToken) expect(mockGenerateUserToken).toHaveBeenCalledTimes(2) }) it('should clear pending request on error', async () => { let rejectPromise: any const promise = new Promise((_resolve, reject) => { rejectPromise = reject }) mockGenerateUserToken.mockReturnValue(promise) const {result} = renderHook(() => useGenerateUserToken()) // Make concurrent calls that will fail const promise1 = result.current.generateUserToken() const promise2 = result.current.generateUserToken() // Reject the promise rejectPromise(new Error('Network error')) // Both should fail with same error await expect(promise1).rejects.toThrow('Network error') await expect(promise2).rejects.toThrow('Network error') // Reset mock for next call mockGenerateUserToken.mockResolvedValue(createMockResponse()) // Next call should make a fresh request let response: any await act(async () => { response = await result.current.generateUserToken() }) expect(response.data.token).toBe(mockToken) expect(mockGenerateUserToken).toHaveBeenCalledTimes(2) // First failed call + retry }) }) })