@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
text/typescript
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
})
})
})