- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import React from 'react';
|
|
|
|
// Mock profile API
|
|
vi.mock('../../api/profile', () => ({
|
|
getProfile: vi.fn(),
|
|
updateProfile: vi.fn(),
|
|
uploadAvatar: vi.fn(),
|
|
deleteAvatar: vi.fn(),
|
|
sendVerificationEmail: vi.fn(),
|
|
verifyEmail: vi.fn(),
|
|
requestEmailChange: vi.fn(),
|
|
confirmEmailChange: vi.fn(),
|
|
changePassword: vi.fn(),
|
|
setupTOTP: vi.fn(),
|
|
verifyTOTP: vi.fn(),
|
|
disableTOTP: vi.fn(),
|
|
getRecoveryCodes: vi.fn(),
|
|
regenerateRecoveryCodes: vi.fn(),
|
|
sendPhoneVerification: vi.fn(),
|
|
verifyPhoneCode: vi.fn(),
|
|
getSessions: vi.fn(),
|
|
revokeSession: vi.fn(),
|
|
revokeOtherSessions: vi.fn(),
|
|
getLoginHistory: vi.fn(),
|
|
getUserEmails: vi.fn(),
|
|
addUserEmail: vi.fn(),
|
|
deleteUserEmail: vi.fn(),
|
|
sendUserEmailVerification: vi.fn(),
|
|
verifyUserEmail: vi.fn(),
|
|
setPrimaryEmail: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
useProfile,
|
|
useUpdateProfile,
|
|
useUploadAvatar,
|
|
useDeleteAvatar,
|
|
useSendVerificationEmail,
|
|
useVerifyEmail,
|
|
useRequestEmailChange,
|
|
useConfirmEmailChange,
|
|
useChangePassword,
|
|
useSetupTOTP,
|
|
useVerifyTOTP,
|
|
useDisableTOTP,
|
|
useRegenerateRecoveryCodes,
|
|
useSendPhoneVerification,
|
|
useVerifyPhoneCode,
|
|
useSessions,
|
|
useRevokeSession,
|
|
useRevokeOtherSessions,
|
|
useLoginHistory,
|
|
useUserEmails,
|
|
useAddUserEmail,
|
|
useDeleteUserEmail,
|
|
useSendUserEmailVerification,
|
|
useVerifyUserEmail,
|
|
useSetPrimaryEmail,
|
|
} from '../useProfile';
|
|
import * as profileApi from '../../api/profile';
|
|
|
|
// Create wrapper
|
|
const createWrapper = () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
};
|
|
};
|
|
|
|
describe('useProfile hooks', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('useProfile', () => {
|
|
it('fetches user profile', async () => {
|
|
const mockProfile = { id: 1, name: 'Test User', email: 'test@example.com' };
|
|
vi.mocked(profileApi.getProfile).mockResolvedValue(mockProfile as any);
|
|
|
|
const { result } = renderHook(() => useProfile(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockProfile);
|
|
});
|
|
});
|
|
|
|
describe('useUpdateProfile', () => {
|
|
it('updates profile', async () => {
|
|
const mockUpdated = { id: 1, name: 'Updated' };
|
|
vi.mocked(profileApi.updateProfile).mockResolvedValue(mockUpdated as any);
|
|
|
|
const { result } = renderHook(() => useUpdateProfile(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ name: 'Updated' });
|
|
});
|
|
|
|
expect(profileApi.updateProfile).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useUploadAvatar', () => {
|
|
it('uploads avatar', async () => {
|
|
vi.mocked(profileApi.uploadAvatar).mockResolvedValue({ avatar_url: 'url' });
|
|
|
|
const { result } = renderHook(() => useUploadAvatar(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
const file = new File(['test'], 'avatar.jpg');
|
|
await act(async () => {
|
|
await result.current.mutateAsync(file);
|
|
});
|
|
|
|
expect(profileApi.uploadAvatar).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useDeleteAvatar', () => {
|
|
it('deletes avatar', async () => {
|
|
vi.mocked(profileApi.deleteAvatar).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useDeleteAvatar(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(profileApi.deleteAvatar).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('email hooks', () => {
|
|
it('sends verification email', async () => {
|
|
vi.mocked(profileApi.sendVerificationEmail).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useSendVerificationEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(profileApi.sendVerificationEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it('verifies email', async () => {
|
|
vi.mocked(profileApi.verifyEmail).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useVerifyEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('token');
|
|
});
|
|
|
|
expect(profileApi.verifyEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it('requests email change', async () => {
|
|
vi.mocked(profileApi.requestEmailChange).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useRequestEmailChange(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('new@example.com');
|
|
});
|
|
|
|
expect(profileApi.requestEmailChange).toHaveBeenCalled();
|
|
});
|
|
|
|
it('confirms email change', async () => {
|
|
vi.mocked(profileApi.confirmEmailChange).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useConfirmEmailChange(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('token');
|
|
});
|
|
|
|
expect(profileApi.confirmEmailChange).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useChangePassword', () => {
|
|
it('changes password', async () => {
|
|
vi.mocked(profileApi.changePassword).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useChangePassword(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
currentPassword: 'old',
|
|
newPassword: 'new',
|
|
});
|
|
});
|
|
|
|
expect(profileApi.changePassword).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('2FA hooks', () => {
|
|
it('sets up TOTP', async () => {
|
|
const mockSetup = { secret: 'ABC', qr_code: 'qr', provisioning_uri: 'uri' };
|
|
vi.mocked(profileApi.setupTOTP).mockResolvedValue(mockSetup);
|
|
|
|
const { result } = renderHook(() => useSetupTOTP(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
const data = await result.current.mutateAsync();
|
|
expect(data).toEqual(mockSetup);
|
|
});
|
|
});
|
|
|
|
it('verifies TOTP', async () => {
|
|
vi.mocked(profileApi.verifyTOTP).mockResolvedValue({ success: true, recovery_codes: [] });
|
|
|
|
const { result } = renderHook(() => useVerifyTOTP(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('123456');
|
|
});
|
|
|
|
expect(profileApi.verifyTOTP).toHaveBeenCalled();
|
|
});
|
|
|
|
it('disables TOTP', async () => {
|
|
vi.mocked(profileApi.disableTOTP).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useDisableTOTP(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('123456');
|
|
});
|
|
|
|
expect(profileApi.disableTOTP).toHaveBeenCalled();
|
|
});
|
|
|
|
it('regenerates recovery codes', async () => {
|
|
vi.mocked(profileApi.regenerateRecoveryCodes).mockResolvedValue(['code1', 'code2']);
|
|
|
|
const { result } = renderHook(() => useRegenerateRecoveryCodes(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
const codes = await result.current.mutateAsync();
|
|
expect(codes).toEqual(['code1', 'code2']);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('phone hooks', () => {
|
|
it('sends phone verification', async () => {
|
|
vi.mocked(profileApi.sendPhoneVerification).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useSendPhoneVerification(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('555-1234');
|
|
});
|
|
|
|
expect(profileApi.sendPhoneVerification).toHaveBeenCalled();
|
|
});
|
|
|
|
it('verifies phone code', async () => {
|
|
vi.mocked(profileApi.verifyPhoneCode).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useVerifyPhoneCode(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('123456');
|
|
});
|
|
|
|
expect(profileApi.verifyPhoneCode).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('session hooks', () => {
|
|
it('fetches sessions', async () => {
|
|
const mockSessions = [{ id: '1', device_info: 'Chrome' }];
|
|
vi.mocked(profileApi.getSessions).mockResolvedValue(mockSessions as any);
|
|
|
|
const { result } = renderHook(() => useSessions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockSessions);
|
|
});
|
|
|
|
it('revokes session', async () => {
|
|
vi.mocked(profileApi.revokeSession).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useRevokeSession(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('session-id');
|
|
});
|
|
|
|
expect(profileApi.revokeSession).toHaveBeenCalled();
|
|
});
|
|
|
|
it('revokes other sessions', async () => {
|
|
vi.mocked(profileApi.revokeOtherSessions).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useRevokeOtherSessions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(profileApi.revokeOtherSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
it('fetches login history', async () => {
|
|
const mockHistory = [{ id: '1', success: true }];
|
|
vi.mocked(profileApi.getLoginHistory).mockResolvedValue(mockHistory as any);
|
|
|
|
const { result } = renderHook(() => useLoginHistory(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockHistory);
|
|
});
|
|
});
|
|
|
|
describe('multiple email hooks', () => {
|
|
it('fetches user emails', async () => {
|
|
const mockEmails = [{ id: 1, email: 'test@example.com' }];
|
|
vi.mocked(profileApi.getUserEmails).mockResolvedValue(mockEmails as any);
|
|
|
|
const { result } = renderHook(() => useUserEmails(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockEmails);
|
|
});
|
|
|
|
it('adds user email', async () => {
|
|
vi.mocked(profileApi.addUserEmail).mockResolvedValue({ id: 2, email: 'new@example.com' } as any);
|
|
|
|
const { result } = renderHook(() => useAddUserEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('new@example.com');
|
|
});
|
|
|
|
expect(profileApi.addUserEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it('deletes user email', async () => {
|
|
vi.mocked(profileApi.deleteUserEmail).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useDeleteUserEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(2);
|
|
});
|
|
|
|
expect(profileApi.deleteUserEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends user email verification', async () => {
|
|
vi.mocked(profileApi.sendUserEmailVerification).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useSendUserEmailVerification(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(2);
|
|
});
|
|
|
|
expect(profileApi.sendUserEmailVerification).toHaveBeenCalled();
|
|
});
|
|
|
|
it('verifies user email', async () => {
|
|
vi.mocked(profileApi.verifyUserEmail).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useVerifyUserEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ emailId: 2, token: 'token' });
|
|
});
|
|
|
|
expect(profileApi.verifyUserEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it('sets primary email', async () => {
|
|
vi.mocked(profileApi.setPrimaryEmail).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useSetPrimaryEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(2);
|
|
});
|
|
|
|
expect(profileApi.setPrimaryEmail).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|