feat: Add comprehensive test suite and misc improvements
- 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>
This commit is contained in:
461
frontend/src/hooks/__tests__/useProfile.test.ts
Normal file
461
frontend/src/hooks/__tests__/useProfile.test.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user