test: reorganize hook tests

This commit is contained in:
2025-12-27 22:58:48 +00:00
parent 99d4411a41
commit 8012fe13ec
6 changed files with 324 additions and 157 deletions

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { User } from '@/lib/level-types'
import { useAuth } from '@/hooks/useAuth'
import { fetchSession } from '@/lib/auth/api/fetch-session'
import { login as loginRequest } from '@/lib/auth/api/login'
import { logout as logoutRequest } from '@/lib/auth/api/logout'
vi.mock('@/lib/auth/api/fetch-session', () => ({
fetchSession: vi.fn(),
}))
vi.mock('@/lib/auth/api/login', () => ({
login: vi.fn(),
}))
vi.mock('@/lib/auth/api/logout', () => ({
logout: vi.fn(),
}))
const mockFetchSession = vi.mocked(fetchSession)
const mockLogin = vi.mocked(loginRequest)
const mockLogout = vi.mocked(logoutRequest)
const createUser = (overrides?: Partial<User>): User => ({
id: 'user_1',
username: 'alice',
email: 'alice@example.com',
role: 'user',
createdAt: 1000,
tenantId: undefined,
profilePicture: undefined,
bio: undefined,
isInstanceOwner: false,
...overrides,
})
const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
}
const resetAuthStore = async () => {
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
await act(async () => {
await result.current.logout()
})
await waitForIdle(result)
unmount()
}
describe('useAuth role mapping', () => {
beforeEach(async () => {
mockFetchSession.mockReset()
mockLogin.mockReset()
mockLogout.mockReset()
mockFetchSession.mockResolvedValue(null)
mockLogout.mockResolvedValue(undefined)
await resetAuthStore()
})
it.each([
{ role: 'public', expectedLevel: 1 },
{ role: 'user', expectedLevel: 2 },
{ role: 'admin', expectedLevel: 4 },
{ role: 'supergod', expectedLevel: 6 },
{ role: 'unknown', expectedLevel: 0 },
])('applies level for role "$role"', async ({ role, expectedLevel }) => {
const { result, unmount } = renderHook(() => useAuth())
mockLogin.mockResolvedValue(createUser({ role }))
await waitForIdle(result)
await act(async () => {
await result.current.login('alice@example.com', 'password')
})
expect(result.current.user?.level).toBe(expectedLevel)
unmount()
})
it('maps refreshed session roles to levels', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockFetchSession.mockResolvedValue(createUser({ role: 'moderator' }))
await act(async () => {
await result.current.refresh()
})
await waitForIdle(result)
expect(result.current.user?.level).toBe(3)
unmount()
})
})

View File

@@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { User } from '@/lib/level-types'
import { useAuth } from '@/hooks/useAuth'
import { fetchSession } from '@/lib/auth/api/fetch-session'
import { login as loginRequest } from '@/lib/auth/api/login'
import { register as registerRequest } from '@/lib/auth/api/register'
import { logout as logoutRequest } from '@/lib/auth/api/logout'
vi.mock('@/lib/auth/api/fetch-session', () => ({
fetchSession: vi.fn(),
@@ -18,12 +23,6 @@ vi.mock('@/lib/auth/api/logout', () => ({
logout: vi.fn(),
}))
import { useAuth } from '@/hooks/useAuth'
import { fetchSession } from '@/lib/auth/api/fetch-session'
import { login as loginRequest } from '@/lib/auth/api/login'
import { register as registerRequest } from '@/lib/auth/api/register'
import { logout as logoutRequest } from '@/lib/auth/api/logout'
const mockFetchSession = vi.mocked(fetchSession)
const mockLogin = vi.mocked(loginRequest)
const mockRegister = vi.mocked(registerRequest)
@@ -48,7 +47,17 @@ const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
})
}
describe('useAuth', () => {
const resetAuthStore = async () => {
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
await act(async () => {
await result.current.logout()
})
await waitForIdle(result)
unmount()
}
describe('useAuth session flows', () => {
beforeEach(async () => {
mockFetchSession.mockReset()
mockLogin.mockReset()
@@ -57,16 +66,10 @@ describe('useAuth', () => {
mockFetchSession.mockResolvedValue(null)
mockLogout.mockResolvedValue(undefined)
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
await act(async () => {
await result.current.logout()
})
await waitForIdle(result)
unmount()
await resetAuthStore()
})
it('should start unauthenticated after session check', async () => {
it('starts unauthenticated after session check', async () => {
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
@@ -77,28 +80,20 @@ describe('useAuth', () => {
unmount()
})
it.each([
{ email: 'alice@example.com', expectedName: 'alice' },
{ email: 'bob.smith@corp.io', expectedName: 'bob.smith' },
])('should authenticate $email', async ({ email, expectedName }) => {
it('authenticates on login', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockLogin.mockResolvedValue(createUser({
id: 'user_1',
username: expectedName,
email,
}))
mockLogin.mockResolvedValue(createUser())
await waitForIdle(result)
await act(async () => {
await result.current.login(email, 'password')
await result.current.login('alice@example.com', 'password')
})
expect(result.current.user).toMatchObject({
id: 'user_1',
email,
name: expectedName,
username: expectedName,
email: 'alice@example.com',
username: 'alice',
level: 2,
})
expect(result.current.isAuthenticated).toBe(true)
@@ -106,7 +101,7 @@ describe('useAuth', () => {
unmount()
})
it('should clear user on logout', async () => {
it('clears user on logout', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockLogin.mockResolvedValue(createUser())
@@ -126,14 +121,16 @@ describe('useAuth', () => {
unmount()
})
it('should register and authenticate', async () => {
it('registers and authenticates', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockRegister.mockResolvedValue(createUser({
id: 'user_2',
username: 'newbie',
email: 'newbie@example.com',
}))
mockRegister.mockResolvedValue(
createUser({
id: 'user_2',
username: 'newbie',
email: 'newbie@example.com',
})
)
await waitForIdle(result)
await act(async () => {
@@ -143,7 +140,6 @@ describe('useAuth', () => {
expect(result.current.user).toMatchObject({
id: 'user_2',
email: 'newbie@example.com',
name: 'newbie',
username: 'newbie',
level: 2,
})
@@ -152,7 +148,7 @@ describe('useAuth', () => {
unmount()
})
it('should sync state across hooks', async () => {
it('syncs state across hooks', async () => {
const first = renderHook(() => useAuth())
const second = renderHook(() => useAuth())

View File

@@ -1,54 +1,34 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useKV } from '@/hooks/useKV'
import { useKV } from '@/hooks/data/useKV'
describe('useKV', () => {
const STORAGE_PREFIX = 'mb_kv:'
let store: Record<string, string>
const STORAGE_PREFIX = 'mb_kv:'
let store: Record<string, string>
const setupLocalStorage = (): void => {
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach(k => delete store[k])
}),
length: 0,
key: vi.fn(() => null),
})
}
describe('useKV storage', () => {
beforeEach(() => {
// Mock localStorage
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { Object.keys(store).forEach(k => delete store[k]) }),
length: 0,
key: vi.fn(() => null),
})
setupLocalStorage()
})
it.each([
{ key: 'user_name', defaultValue: 'John', description: 'string value' },
{ key: 'user_count', defaultValue: 0, description: 'number value' },
{ key: 'is_active', defaultValue: true, description: 'boolean value' },
{ key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
])('should initialize hook with $description', ({ key, defaultValue }) => {
const { result } = renderHook(() => useKV(key, defaultValue))
const [value] = result.current
expect(value).toBe(defaultValue)
})
it('should initialize with undefined when no default value provided', () => {
const { result } = renderHook(() => useKV('empty_key'))
const [value] = result.current
expect(value).toBeUndefined()
})
it('should load value from localStorage when available', async () => {
localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
const { result } = renderHook(() => useKV('stored_key', 'default'))
await waitFor(() => {
expect(result.current[0]).toBe('stored')
})
})
it('should migrate legacy localStorage entries to namespaced keys', () => {
it('migrates legacy localStorage entries to namespaced keys', () => {
localStorage.setItem('legacy_key', JSON.stringify('legacy'))
const { result } = renderHook(() => useKV('legacy_key', 'default'))
@@ -58,7 +38,7 @@ describe('useKV', () => {
expect(localStorage.getItem('legacy_key')).toBeNull()
})
it('should update value when using updater function', async () => {
it('updates value when using updater function', async () => {
const { result } = renderHook(() => useKV('counter', 0))
const [, updateValue] = result.current
@@ -71,9 +51,8 @@ describe('useKV', () => {
expect(newValue).toBe(1)
})
it('should update value when providing direct value', async () => {
it('updates value when providing direct value', async () => {
const { result } = renderHook(() => useKV('name', 'John'))
const [, updateValue] = result.current
await act(async () => {
@@ -84,7 +63,7 @@ describe('useKV', () => {
expect(newValue).toBe('Jane')
})
it('should handle complex object updates', async () => {
it('handles complex object updates', async () => {
const initialObject = { id: 1, name: 'John', email: 'john@example.com' }
const { result } = renderHook(() => useKV('user', initialObject))
@@ -101,7 +80,7 @@ describe('useKV', () => {
expect(newValue).toEqual({ id: 1, name: 'Jane', email: 'john@example.com' })
})
it('should handle array updates', async () => {
it('handles array updates', async () => {
const initialArray = [1, 2, 3]
const { result } = renderHook(() => useKV('items', initialArray))
@@ -115,7 +94,7 @@ describe('useKV', () => {
expect(newValue).toEqual([1, 2, 3, 4])
})
it('should maintain separate state for different keys', async () => {
it('maintains separate state for different keys', () => {
const { result: result1 } = renderHook(() => useKV('key1', 'value1'))
const { result: result2 } = renderHook(() => useKV('key2', 'value2'))
@@ -126,7 +105,7 @@ describe('useKV', () => {
expect(value2).toBe('value2')
})
it('should persist updates across multiple hooks with same key', async () => {
it('persists updates across multiple hooks with same key', async () => {
const { result: firstHook } = renderHook(() => useKV('shared_key', 'initial'))
const [, updateValue] = firstHook.current
@@ -134,14 +113,13 @@ describe('useKV', () => {
await updateValue('updated')
})
// Create a new hook with the same key
const { result: secondHook } = renderHook(() => useKV('shared_key', 'initial'))
const [value] = secondHook.current
expect(value).toBe('updated')
})
it('should sync updates across mounted hooks with same key', async () => {
it('syncs updates across mounted hooks with same key', async () => {
const { result: firstHook } = renderHook(() => useKV('sync_key', 'initial'))
const { result: secondHook } = renderHook(() => useKV('sync_key', 'initial'))
@@ -154,19 +132,7 @@ describe('useKV', () => {
})
})
it.each([
{ initialValue: null, key: 'falsy_key_null', description: 'null value' },
{ initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
{ initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
{ initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
])('should handle falsy $description correctly', ({ initialValue, key }) => {
const { result } = renderHook(() => useKV(key, initialValue))
const [value] = result.current
expect(value).toBe(initialValue)
})
it('should handle rapid updates correctly', async () => {
it('handles rapid updates correctly', async () => {
const { result } = renderHook(() => useKV('rapid_key', 0))
const [, updateValue] = result.current
@@ -183,7 +149,7 @@ describe('useKV', () => {
expect(finalValue).toBeGreaterThanOrEqual(1)
})
it('should persist updates to localStorage', async () => {
it('persists updates to localStorage', async () => {
const { result } = renderHook(() => useKV('persist_key', 'initial'))
const [, updateValue] = result.current

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useKV } from '@/hooks/data/useKV'
const STORAGE_PREFIX = 'mb_kv:'
let store: Record<string, string>
const setupLocalStorage = (): void => {
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach(k => delete store[k])
}),
length: 0,
key: vi.fn(() => null),
})
}
describe('useKV validation', () => {
beforeEach(() => {
setupLocalStorage()
})
it.each([
{ key: 'user_name', defaultValue: 'John', description: 'string value' },
{ key: 'user_count', defaultValue: 0, description: 'number value' },
{ key: 'is_active', defaultValue: true, description: 'boolean value' },
{ key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
])('initializes with $description', ({ key, defaultValue }) => {
const { result } = renderHook(() => useKV(key, defaultValue))
const [value] = result.current
expect(value).toBe(defaultValue)
})
it('initializes with undefined when no default value provided', () => {
const { result } = renderHook(() => useKV('empty_key'))
const [value] = result.current
expect(value).toBeUndefined()
})
it.each([
{ initialValue: null, key: 'falsy_key_null', description: 'null value' },
{ initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
{ initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
{ initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
])('handles $description correctly', ({ initialValue, key }) => {
const { result } = renderHook(() => useKV(key, initialValue))
const [value] = result.current
expect(value).toBe(initialValue)
})
it('loads value from localStorage when available', () => {
localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
const { result } = renderHook(() => useKV('stored_key', 'default'))
const [value] = result.current
expect(value).toBe('stored')
})
})

View File

@@ -0,0 +1,74 @@
/**
* Auto-refresh error-handling tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAutoRefresh } from '../useAutoRefresh'
describe('useAutoRefresh error handling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('stops refresh when disabled after being enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoRefresh({
intervalMs: 5000,
onRefresh,
enabled: true,
})
)
act(() => {
vi.advanceTimersByTime(2500)
})
act(() => {
result.current.setEnabled(false)
})
act(() => {
vi.advanceTimersByTime(10000)
})
expect(onRefresh).not.toHaveBeenCalled()
})
it('continues scheduling refreshes after onRefresh errors', () => {
const erroringRefresh = vi.fn().mockImplementation(() =>
Promise.reject(new Error('refresh failed')).catch(() => {})
)
const { result } = renderHook(() =>
useAutoRefresh({
intervalMs: 2000,
onRefresh: erroringRefresh,
enabled: true,
})
)
expect(result.current.secondsUntilNextRefresh).toBe(2)
act(() => {
vi.advanceTimersByTime(2000)
})
expect(erroringRefresh).toHaveBeenCalledTimes(1)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current.secondsUntilNextRefresh).toBe(1)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current.secondsUntilNextRefresh).toBe(2)
expect(erroringRefresh).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,13 +1,11 @@
/**
* Tests for useAutoRefresh hook - Auto-refresh polling management
* Following parameterized test pattern per project conventions
* Auto-refresh polling tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useAutoRefresh } from './useAutoRefresh'
import { useAutoRefresh } from '../useAutoRefresh'
describe('useAutoRefresh', () => {
describe('useAutoRefresh polling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
@@ -21,7 +19,7 @@ describe('useAutoRefresh', () => {
{ enabled: false, expectAutoRefreshing: false },
{ enabled: true, expectAutoRefreshing: true },
{ enabled: undefined, expectAutoRefreshing: false },
])('should initialize with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
])('initializes with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -39,7 +37,7 @@ describe('useAutoRefresh', () => {
{ intervalMs: 30000, expectedSeconds: 30 },
{ intervalMs: 60000, expectedSeconds: 60 },
{ intervalMs: 5000, expectedSeconds: 5 },
])('should set secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
])('sets secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -55,7 +53,7 @@ describe('useAutoRefresh', () => {
})
describe('toggleAutoRefresh', () => {
it('should toggle from disabled to enabled', () => {
it('toggles from disabled to enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -75,7 +73,7 @@ describe('useAutoRefresh', () => {
expect(result.current.isAutoRefreshing).toBe(true)
})
it('should toggle from enabled to disabled', () => {
it('toggles from enabled to disabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -102,7 +100,7 @@ describe('useAutoRefresh', () => {
{ initial: true, setTo: false, expected: false },
{ initial: false, setTo: false, expected: false },
{ initial: true, setTo: true, expected: true },
])('should set from $initial to $setTo', ({ initial, setTo, expected }) => {
])('sets from $initial to $setTo', ({ initial, setTo, expected }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -122,7 +120,7 @@ describe('useAutoRefresh', () => {
})
describe('refresh timing', () => {
it('should call onRefresh after intervalMs when enabled', async () => {
it('calls onRefresh after intervalMs when enabled', async () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -133,23 +131,20 @@ describe('useAutoRefresh', () => {
})
)
// Not called initially
expect(onRefresh).not.toHaveBeenCalled()
// Advance to just before interval
act(() => {
vi.advanceTimersByTime(4999)
})
expect(onRefresh).not.toHaveBeenCalled()
// Advance past interval
act(() => {
vi.advanceTimersByTime(1)
})
expect(onRefresh).toHaveBeenCalledTimes(1)
})
it('should not call onRefresh when disabled', () => {
it('does not call onRefresh when disabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -167,7 +162,7 @@ describe('useAutoRefresh', () => {
expect(onRefresh).not.toHaveBeenCalled()
})
it('should call onRefresh multiple times at interval', () => {
it('calls onRefresh multiple times at interval', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -179,7 +174,7 @@ describe('useAutoRefresh', () => {
)
act(() => {
vi.advanceTimersByTime(15000) // 3 intervals
vi.advanceTimersByTime(15000)
})
expect(onRefresh).toHaveBeenCalledTimes(3)
@@ -187,7 +182,7 @@ describe('useAutoRefresh', () => {
})
describe('countdown', () => {
it('should decrement countdown every second when enabled', () => {
it('decrements countdown every second when enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -211,7 +206,7 @@ describe('useAutoRefresh', () => {
expect(result.current.secondsUntilNextRefresh).toBe(3)
})
it('should reset countdown after reaching zero', () => {
it('resets countdown after reaching zero', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -224,45 +219,11 @@ describe('useAutoRefresh', () => {
expect(result.current.secondsUntilNextRefresh).toBe(3)
// Advance 3 seconds to reach zero
act(() => {
vi.advanceTimersByTime(3000)
})
// Should reset to initial value
expect(result.current.secondsUntilNextRefresh).toBe(3)
})
})
describe('cleanup', () => {
it('should stop refresh when disabled after being enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoRefresh({
intervalMs: 5000,
onRefresh,
enabled: true,
})
)
// Advance half interval
act(() => {
vi.advanceTimersByTime(2500)
})
// Disable
act(() => {
result.current.setEnabled(false)
})
// Advance another full interval
act(() => {
vi.advanceTimersByTime(10000)
})
// Should not have been called (disabled before first interval completed)
expect(onRefresh).not.toHaveBeenCalled()
})
})
})