From ac3643f76d9ae304f5e471de52a5d49bcd127f0f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 8 Sep 2025 18:30:43 -0700 Subject: [PATCH] Refactor database services and add component tests - Remove deprecated CouchDB service files - Update database test configurations - Add test files for components and auth modules - Update user context and admin interface - Remove migration script for unified config - Fix User interface properties in tests (use status instead of isActive) --- App.tsx | 45 +- .../__tests__/example.component.test.tsx | 334 ++++++++++ components/admin/AdminInterface.tsx | 14 +- .../auth/__tests__/AvatarDropdown.test.tsx | 475 ++++++++++++++ contexts/UserContext.tsx | 8 +- scripts/migrate-to-unified-config.ts | 596 ------------------ services/couchdb.factory.ts | 15 - services/couchdb.production.ts | 396 ------------ services/couchdb.ts | 402 ------------ .../__tests__/DatabaseService.test.ts | 263 +++----- 10 files changed, 934 insertions(+), 1614 deletions(-) create mode 100644 components/__tests__/example.component.test.tsx create mode 100644 components/auth/__tests__/AvatarDropdown.test.tsx delete mode 100644 scripts/migrate-to-unified-config.ts delete mode 100644 services/couchdb.factory.ts delete mode 100644 services/couchdb.production.ts delete mode 100644 services/couchdb.ts diff --git a/App.tsx b/App.tsx index bbf689b..a24f77f 100644 --- a/App.tsx +++ b/App.tsx @@ -59,7 +59,7 @@ import { BarChartIcon, } from './components/icons/Icons'; import { useUser } from './contexts/UserContext'; -import { dbService } from './services/couchdb.factory'; +import { databaseService } from './services/database'; import { databaseSeeder } from './services/database.seeder'; const Header: React.FC<{ @@ -242,10 +242,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { const [medsData, remindersData, takenDosesData, settingsData] = await Promise.all([ - dbService.getMedications(user._id), - dbService.getCustomReminders(user._id), - dbService.getTakenDoses(user._id), - dbService.getSettings(user._id), + databaseService.getMedications(user._id), + databaseService.getCustomReminders(user._id), + databaseService.getTakenDoses(user._id), + databaseService.getUserSettings(user._id), ]); console.warn('Data fetched successfully:', { @@ -308,18 +308,18 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { }, [medications, customReminders, currentTime]); const handleAddMedication = async (med: Omit) => { - const newMed = await dbService.addMedication(user._id, med); + const newMed = await databaseService.createMedication(user._id, med); setMedications(prev => [...prev, newMed]); setAddModalOpen(false); }; const handleDeleteMedication = async (medToDelete: Medication) => { - await dbService.deleteMedication(user._id, medToDelete); + await databaseService.deleteMedication(medToDelete._id); setMedications(meds => meds.filter(med => med._id !== medToDelete._id)); }; const handleUpdateMedication = async (updatedMed: Medication) => { - const savedMed = await dbService.updateMedication(user._id, updatedMed); + const savedMed = await databaseService.updateMedication(updatedMed); setMedications(meds => meds.map(m => (m._id === savedMed._id ? savedMed : m)) ); @@ -329,16 +329,17 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { const handleAddReminder = async ( reminder: Omit ) => { - const newReminder = await dbService.addCustomReminder(user._id, reminder); + const newReminder = await databaseService.createCustomReminder( + user._id, + reminder + ); setCustomReminders(prev => [...prev, newReminder]); setAddReminderOpen(false); }; const handleUpdateReminder = async (updatedReminder: CustomReminder) => { - const savedReminder = await dbService.updateCustomReminder( - user._id, - updatedReminder - ); + const savedReminder = + await databaseService.updateCustomReminder(updatedReminder); setCustomReminders(reminders => reminders.map(r => (r._id === savedReminder._id ? savedReminder : r)) ); @@ -346,7 +347,7 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { }; const handleDeleteReminder = async (reminderToDelete: CustomReminder) => { - await dbService.deleteCustomReminder(user._id, reminderToDelete); + await databaseService.deleteCustomReminder(reminderToDelete._id); setCustomReminders(reminders => reminders.filter(r => r._id !== reminderToDelete._id) ); @@ -371,13 +372,13 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { } else { newDoses[doseId] = new Date().toISOString(); } - const updatedDoc = await dbService.updateTakenDoses(user._id, { + const updatedDoc = await databaseService.updateTakenDoses({ ...takenDosesDoc, doses: newDoses, }); setTakenDosesDoc(updatedDoc); }, - [takenDosesDoc, user._id] + [takenDosesDoc] ); const handleSnoozeDose = useCallback((doseId: string) => { @@ -656,10 +657,8 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { }, [medications, takenDoses, getDoseStatus]); const handleUpdateSettings = async (newSettings: UserSettings) => { - const updatedSettings = await dbService.updateSettings( - user._id, - newSettings - ); + const updatedSettings = + await databaseService.updateUserSettings(newSettings); setSettings(updatedSettings); }; @@ -669,10 +668,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { 'Are you sure you want to delete all your medication data? This action cannot be undone.' ) ) { - await dbService.deleteAllUserData(user._id); + await databaseService.deleteAllUserData(user._id); setMedications([]); setCustomReminders([]); - const updatedTakenDoses = await dbService.getTakenDoses(user._id); + const updatedTakenDoses = await databaseService.getTakenDoses(user._id); setTakenDosesDoc(updatedTakenDoses); setAccountModalOpen(false); } @@ -681,7 +680,7 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { const handleCompleteOnboarding = async () => { if (settings) { try { - const updatedSettings = await dbService.updateSettings(user._id, { + const updatedSettings = await databaseService.updateUserSettings({ ...settings, hasCompletedOnboarding: true, }); diff --git a/components/__tests__/example.component.test.tsx b/components/__tests__/example.component.test.tsx new file mode 100644 index 0000000..51c30b0 --- /dev/null +++ b/components/__tests__/example.component.test.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +// Mock component for demonstration purposes +// This would normally import an actual component +const MockButton: React.FC<{ + onClick: () => void; + children: React.ReactNode; + disabled?: boolean; +}> = ({ onClick, children, disabled = false }) => ( + +); + +const MockMedicationCard: React.FC<{ + medication: { + id: string; + name: string; + dosage: string; + frequency: string; + }; + onEdit: (id: string) => void; + onDelete: (id: string) => void; +}> = ({ medication, onEdit, onDelete }) => ( +
+

{medication.name}

+

Dosage: {medication.dosage}

+

Frequency: {medication.frequency}

+
+ onEdit(medication.id)}>Edit + onDelete(medication.id)}>Delete +
+
+); + +describe('Component Testing Examples', () => { + describe('MockButton Component', () => { + test('renders button with correct text', () => { + const mockClick = jest.fn(); + render(Click Me); + + const button = screen.getByTestId('mock-button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Click Me'); + }); + + test('calls onClick handler when clicked', () => { + const mockClick = jest.fn(); + render(Click Me); + + const button = screen.getByTestId('mock-button'); + fireEvent.click(button); + + expect(mockClick).toHaveBeenCalledTimes(1); + }); + + test('applies disabled state correctly', () => { + const mockClick = jest.fn(); + render( + + Disabled Button + + ); + + const button = screen.getByTestId('mock-button'); + expect(button).toBeDisabled(); + expect(button).toHaveClass('btn-disabled'); + + fireEvent.click(button); + expect(mockClick).not.toHaveBeenCalled(); + }); + + test('applies correct CSS classes', () => { + const mockClick = jest.fn(); + const { rerender } = render( + Normal Button + ); + + let button = screen.getByTestId('mock-button'); + expect(button).toHaveClass('btn', 'btn-primary'); + expect(button).not.toHaveClass('btn-disabled'); + + rerender( + + Disabled Button + + ); + + button = screen.getByTestId('mock-button'); + expect(button).toHaveClass('btn', 'btn-disabled'); + expect(button).not.toHaveClass('btn-primary'); + }); + }); + + describe('MockMedicationCard Component', () => { + const mockMedication = { + id: 'med-123', + name: 'Aspirin', + dosage: '100mg', + frequency: 'Daily', + }; + + const defaultProps = { + medication: mockMedication, + onEdit: jest.fn(), + onDelete: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders medication information correctly', () => { + render(); + + expect(screen.getByTestId('medication-card')).toBeInTheDocument(); + expect(screen.getByTestId('medication-name')).toHaveTextContent( + 'Aspirin' + ); + expect(screen.getByTestId('medication-dosage')).toHaveTextContent( + 'Dosage: 100mg' + ); + expect(screen.getByTestId('medication-frequency')).toHaveTextContent( + 'Frequency: Daily' + ); + }); + + test('renders action buttons', () => { + render(); + + const editButton = screen.getByText('Edit'); + const deleteButton = screen.getByText('Delete'); + + expect(editButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + }); + + test('calls onEdit with correct medication ID when edit button is clicked', () => { + const mockOnEdit = jest.fn(); + render(); + + const editButton = screen.getByText('Edit'); + fireEvent.click(editButton); + + expect(mockOnEdit).toHaveBeenCalledTimes(1); + expect(mockOnEdit).toHaveBeenCalledWith('med-123'); + }); + + test('calls onDelete with correct medication ID when delete button is clicked', () => { + const mockOnDelete = jest.fn(); + render(); + + const deleteButton = screen.getByText('Delete'); + fireEvent.click(deleteButton); + + expect(mockOnDelete).toHaveBeenCalledTimes(1); + expect(mockOnDelete).toHaveBeenCalledWith('med-123'); + }); + + test('handles different medication data', () => { + const differentMedication = { + id: 'med-456', + name: 'Vitamin D', + dosage: '1000 IU', + frequency: 'Weekly', + }; + + render( + + ); + + expect(screen.getByTestId('medication-name')).toHaveTextContent( + 'Vitamin D' + ); + expect(screen.getByTestId('medication-dosage')).toHaveTextContent( + 'Dosage: 1000 IU' + ); + expect(screen.getByTestId('medication-frequency')).toHaveTextContent( + 'Frequency: Weekly' + ); + }); + + test('maintains proper component structure', () => { + render(); + + const card = screen.getByTestId('medication-card'); + expect(card).toHaveClass('medication-card'); + + const actionsContainer = card.querySelector('.actions'); + expect(actionsContainer).toBeInTheDocument(); + + // Verify buttons are within the actions container + const buttons = actionsContainer?.querySelectorAll('button'); + expect(buttons).toHaveLength(2); + }); + }); + + describe('Component Integration Tests', () => { + test('multiple components work together', () => { + const medications = [ + { + id: 'med-1', + name: 'Aspirin', + dosage: '100mg', + frequency: 'Daily', + }, + { + id: 'med-2', + name: 'Vitamin C', + dosage: '500mg', + frequency: 'Twice daily', + }, + ]; + + const MockMedicationList: React.FC<{ + medications: typeof medications; + onEdit: (id: string) => void; + onDelete: (id: string) => void; + }> = ({ medications, onEdit, onDelete }) => ( +
+ {medications.map(med => ( + + ))} +
+ ); + + const mockOnEdit = jest.fn(); + const mockOnDelete = jest.fn(); + + render( + + ); + + // Verify all medications are rendered + expect(screen.getAllByTestId('medication-card')).toHaveLength(2); + expect(screen.getByText('Aspirin')).toBeInTheDocument(); + expect(screen.getByText('Vitamin C')).toBeInTheDocument(); + + // Test interaction with first medication + const editButtons = screen.getAllByText('Edit'); + fireEvent.click(editButtons[0]); + expect(mockOnEdit).toHaveBeenCalledWith('med-1'); + + // Test interaction with second medication + const deleteButtons = screen.getAllByText('Delete'); + fireEvent.click(deleteButtons[1]); + expect(mockOnDelete).toHaveBeenCalledWith('med-2'); + }); + }); + + describe('Accessibility Tests', () => { + test('buttons have proper accessibility attributes', () => { + const mockClick = jest.fn(); + render(Accessible Button); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAccessibleName('Accessible Button'); + }); + + test('medication card structure supports screen readers', () => { + render( + + ); + + // Check that important information is properly structured + const heading = screen.getByRole('heading', { level: 3 }); + expect(heading).toHaveTextContent('Test Medicine'); + + const editButton = screen.getByRole('button', { name: 'Edit' }); + const deleteButton = screen.getByRole('button', { name: 'Delete' }); + + expect(editButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + }); + }); + + describe('Error Handling', () => { + test('components handle missing props gracefully', () => { + // Test with minimal props + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { + // Mock implementation for testing + }); + + render( + + ); + + // Component should still render even with empty data + expect(screen.getByTestId('medication-card')).toBeInTheDocument(); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/components/admin/AdminInterface.tsx b/components/admin/AdminInterface.tsx index 4b4bdf9..6ed51c7 100644 --- a/components/admin/AdminInterface.tsx +++ b/components/admin/AdminInterface.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { User, UserRole } from '../../types'; import { AccountStatus } from '../../services/auth/auth.constants'; -import { dbService } from '../../services/couchdb.factory'; +import { databaseService } from '../../services/database'; import { useUser } from '../../contexts/UserContext'; interface AdminInterfaceProps { @@ -22,8 +22,8 @@ const AdminInterface: React.FC = ({ onClose }) => { const loadUsers = async () => { try { - const allUsers = await dbService.getAllUsers(); - setUsers(allUsers); + const users = await databaseService.getAllUsers(); + setUsers(users); } catch (error) { setError('Failed to load users'); console.error('Error loading users:', error); @@ -34,7 +34,7 @@ const AdminInterface: React.FC = ({ onClose }) => { const handleSuspendUser = async (userId: string) => { try { - await dbService.suspendUser(userId); + await databaseService.suspendUser(userId); await loadUsers(); } catch (error) { setError('Failed to suspend user'); @@ -44,7 +44,7 @@ const AdminInterface: React.FC = ({ onClose }) => { const handleActivateUser = async (userId: string) => { try { - await dbService.activateUser(userId); + await databaseService.activateUser(userId); await loadUsers(); } catch (error) { setError('Failed to activate user'); @@ -62,7 +62,7 @@ const AdminInterface: React.FC = ({ onClose }) => { } try { - await dbService.deleteUser(userId); + await databaseService.deleteUser(userId); await loadUsers(); } catch (error) { setError('Failed to delete user'); @@ -77,7 +77,7 @@ const AdminInterface: React.FC = ({ onClose }) => { } try { - await dbService.changeUserPassword(userId, newPassword); + await databaseService.changeUserPassword(userId, newPassword); setNewPassword(''); setSelectedUser(null); setError(''); diff --git a/components/auth/__tests__/AvatarDropdown.test.tsx b/components/auth/__tests__/AvatarDropdown.test.tsx new file mode 100644 index 0000000..c3bd59a --- /dev/null +++ b/components/auth/__tests__/AvatarDropdown.test.tsx @@ -0,0 +1,475 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AvatarDropdown from '../AvatarDropdown'; +import { User, UserRole } from '../../../types'; +import { AccountStatus } from '../../../services/auth/auth.constants'; + +// Mock user data +const mockRegularUser: User = { + _id: '1', + _rev: '1-abc123', + username: 'John Doe', + email: 'john@example.com', + role: UserRole.USER, + status: AccountStatus.ACTIVE, + createdAt: new Date(), +}; + +const mockAdminUser: User = { + _id: '2', + _rev: '1-def456', + username: 'Admin User', + email: 'admin@example.com', + role: UserRole.ADMIN, + status: AccountStatus.ACTIVE, + createdAt: new Date(), +}; + +const mockUserWithAvatar: User = { + ...mockRegularUser, + avatar: 'https://example.com/avatar.jpg', +}; + +const mockUserWithPassword: User = { + ...mockRegularUser, + password: 'hashed-password', +}; + +describe('AvatarDropdown', () => { + const mockOnLogout = jest.fn(); + const mockOnAdmin = jest.fn(); + const mockOnChangePassword = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + test('should render avatar button with user initials', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('J'); + }); + + test('should render avatar image when user has avatar', () => { + render( + + ); + + const avatar = screen.getByAltText('User avatar'); + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg'); + }); + + test('should render fallback character for empty username', () => { + const userWithEmptyName = { ...mockRegularUser, username: '' }; + render( + + ); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveTextContent('?'); + }); + + test('should not render dropdown menu initially', () => { + render(); + + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + }); + + describe('dropdown functionality', () => { + test('should open dropdown when avatar button is clicked', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + fireEvent.click(button); + + expect(screen.getByText('Signed in as')).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + test('should close dropdown when avatar button is clicked again', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + + // Open dropdown + fireEvent.click(button); + expect(screen.getByText('Signed in as')).toBeInTheDocument(); + + // Close dropdown + fireEvent.click(button); + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + + test('should close dropdown when clicking outside', async () => { + render( +
+ +
Outside element
+
+ ); + + const button = screen.getByRole('button', { name: /user menu/i }); + const outside = screen.getByTestId('outside'); + + // Open dropdown + fireEvent.click(button); + expect(screen.getByText('Signed in as')).toBeInTheDocument(); + + // Click outside + fireEvent.mouseDown(outside); + + await waitFor(() => { + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + }); + }); + + describe('user information display', () => { + test('should display username in dropdown', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + test('should display administrator badge for admin users', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.getByText('Administrator')).toBeInTheDocument(); + }); + + test('should not display administrator badge for regular users', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.queryByText('Administrator')).not.toBeInTheDocument(); + }); + + test('should truncate long usernames', () => { + const userWithLongName = { + ...mockRegularUser, + username: 'Very Long Username That Should Be Truncated', + }; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + const usernameElement = screen.getByText( + 'Very Long Username That Should Be Truncated' + ); + expect(usernameElement).toHaveClass('truncate'); + }); + }); + + describe('logout functionality', () => { + test('should call onLogout when logout button is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + fireEvent.click(screen.getByText('Logout')); + + expect(mockOnLogout).toHaveBeenCalledTimes(1); + }); + + test('should close dropdown after logout is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + fireEvent.click(screen.getByText('Logout')); + + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + + test('should always display logout button', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + }); + + describe('admin functionality', () => { + test('should display admin interface button for admin users when onAdmin provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.getByText('Admin Interface')).toBeInTheDocument(); + }); + + test('should not display admin interface button for regular users', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument(); + }); + + test('should not display admin interface button when onAdmin not provided', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument(); + }); + + test('should call onAdmin when admin interface button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + fireEvent.click(screen.getByText('Admin Interface')); + + expect(mockOnAdmin).toHaveBeenCalledTimes(1); + }); + + test('should close dropdown after admin interface is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + fireEvent.click(screen.getByText('Admin Interface')); + + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + }); + + describe('change password functionality', () => { + test('should display change password button for users with password when onChangePassword provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.getByText('Change Password')).toBeInTheDocument(); + }); + + test('should not display change password button for users without password', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.queryByText('Change Password')).not.toBeInTheDocument(); + }); + + test('should not display change password button when onChangePassword not provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + expect(screen.queryByText('Change Password')).not.toBeInTheDocument(); + }); + + test('should call onChangePassword when change password button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + fireEvent.click(screen.getByText('Change Password')); + + expect(mockOnChangePassword).toHaveBeenCalledTimes(1); + }); + + test('should close dropdown after change password is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + fireEvent.click(screen.getByText('Change Password')); + + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + }); + + describe('getInitials function', () => { + test('should return first character uppercase for regular names', () => { + const userWithLowercase = { ...mockRegularUser, username: 'john doe' }; + render( + + ); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveTextContent('J'); + }); + + test('should return question mark for empty string', () => { + const userWithEmptyName = { ...mockRegularUser, username: '' }; + render( + + ); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveTextContent('?'); + }); + + test('should handle single character names', () => { + const userWithSingleChar = { ...mockRegularUser, username: 'x' }; + render( + + ); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveTextContent('X'); + }); + + test('should handle special characters', () => { + const userWithSpecialChar = { ...mockRegularUser, username: '@john' }; + render( + + ); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveTextContent('@'); + }); + }); + + describe('accessibility', () => { + test('should have proper aria-label for avatar button', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveAttribute('aria-label', 'User menu'); + }); + + test('should be keyboard accessible', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + button.focus(); + expect(button).toHaveFocus(); + }); + + test('should have proper focus styles', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveClass('focus:outline-none', 'focus:ring-2'); + }); + }); + + describe('styling and theming', () => { + test('should apply dark mode classes', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + expect(button).toHaveClass('dark:bg-slate-700', 'dark:text-slate-300'); + + fireEvent.click(button); + const dropdown = screen.getByText('Signed in as').closest('div'); + expect(dropdown).toHaveClass( + 'dark:bg-slate-800', + 'dark:border-slate-700' + ); + }); + + test('should apply hover styles to menu items', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /user menu/i })); + const logoutButton = screen.getByText('Logout'); + expect(logoutButton).toHaveClass( + 'hover:bg-slate-100', + 'dark:hover:bg-slate-700' + ); + }); + }); + + describe('edge cases', () => { + test('should handle clicking outside when dropdown is closed', async () => { + render( +
+ +
Outside element
+
+ ); + + const outside = screen.getByTestId('outside'); + fireEvent.mouseDown(outside); + + // Should not throw any errors + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + + test('should handle rapid clicking', () => { + render(); + + const button = screen.getByRole('button', { name: /user menu/i }); + + // Rapid clicks + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button); + + // Should end up closed + expect(screen.queryByText('Signed in as')).not.toBeInTheDocument(); + }); + + test('should cleanup event listeners on unmount', () => { + const removeEventListenerSpy = jest.spyOn( + document, + 'removeEventListener' + ); + + const { unmount } = render( + + ); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mousedown', + expect.any(Function) + ); + + removeEventListenerSpy.mockRestore(); + }); + }); +}); diff --git a/contexts/UserContext.tsx b/contexts/UserContext.tsx index e6b5200..01e5bcd 100644 --- a/contexts/UserContext.tsx +++ b/contexts/UserContext.tsx @@ -6,7 +6,7 @@ import React, { ReactNode, } from 'react'; import { User } from '../types'; -import { dbService } from '../services/couchdb.factory'; +import { databaseService } from '../services/database'; import { authService } from '../services/auth/auth.service'; const SESSION_KEY = 'medication_app_session'; @@ -74,7 +74,7 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ // Update last login time const updatedUser = { ...result.user, lastLoginAt: new Date() }; - await dbService.updateUser(updatedUser); + await databaseService.updateUser(updatedUser); console.warn('Updated user with last login:', updatedUser); @@ -119,7 +119,7 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ // Update last login time const updatedUser = { ...result.user, lastLoginAt: new Date() }; - await dbService.updateUser(updatedUser); + await databaseService.updateUser(updatedUser); console.warn('Updated OAuth user with last login:', updatedUser); @@ -157,7 +157,7 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ const updateUser = async (updatedUser: User) => { try { - const savedUser = await dbService.updateUser(updatedUser); + const savedUser = await databaseService.updateUser(updatedUser); setUser(savedUser); } catch (error) { console.error('Failed to update user', error); diff --git a/scripts/migrate-to-unified-config.ts b/scripts/migrate-to-unified-config.ts deleted file mode 100644 index 08dd96a..0000000 --- a/scripts/migrate-to-unified-config.ts +++ /dev/null @@ -1,596 +0,0 @@ -#!/usr/bin/env bun - -/** - * Migration Script: Old Config System → Unified Config - * - * This script helps migrate from the existing .env and multiple config files - * to the new unified configuration system. - * - * Usage: - * bun scripts/migrate-to-unified-config.ts - * bun scripts/migrate-to-unified-config.ts --dry-run - * bun scripts/migrate-to-unified-config.ts --backup - */ - -import { - readFileSync, - writeFileSync, - existsSync, - mkdirSync, - copyFileSync, -} from 'fs'; -import { join } from 'path'; - -interface MigrationOptions { - dryRun?: boolean; - backup?: boolean; - verbose?: boolean; -} - -interface ParsedEnv { - [key: string]: string; -} - -class ConfigMigrator { - private options: MigrationOptions; - private projectRoot: string; - private backupDir: string; - - constructor(options: MigrationOptions = {}) { - this.options = options; - this.projectRoot = join(__dirname, '..'); - this.backupDir = join(this.projectRoot, '.config-backup'); - } - - /** - * Run the migration process - */ - async migrate(): Promise { - console.warn('šŸ”„ Starting migration to unified configuration system...'); - console.warn(`šŸ“ Project root: ${this.projectRoot}`); - - if (this.options.backup) { - await this.createBackup(); - } - - // Step 1: Read existing configuration - const existingConfig = await this.readExistingConfig(); - - // Step 2: Generate unified config values - const unifiedValues = this.mapToUnifiedConfig(existingConfig); - - // Step 3: Update .env file with unified structure - await this.updateMainEnvFile(unifiedValues); - - // Step 4: Generate new config files - await this.generateUnifiedConfigFiles(); - - // Step 5: Update package.json scripts if needed - await this.updatePackageJsonScripts(); - - console.warn('āœ… Migration completed successfully!'); - console.warn('\nšŸ“‹ Next steps:'); - console.warn('1. Review the updated .env file'); - console.warn('2. Run: make generate-config'); - console.warn('3. Test your application: make dev'); - console.warn('4. Deploy when ready: make deploy-prod-quick'); - - if (this.options.backup) { - console.warn(`5. Remove backup when satisfied: rm -rf ${this.backupDir}`); - } - } - - /** - * Create backup of existing configuration files - */ - private async createBackup(): Promise { - console.warn('\nšŸ’¾ Creating backup of existing configuration...'); - - if (!existsSync(this.backupDir)) { - mkdirSync(this.backupDir, { recursive: true }); - } - - const filesToBackup = [ - '.env', - '.env.production', - '.env.example', - 'config/app.config.ts', - 'k8s-kustomize/base/config.env', - 'vite.config.ts', - ]; - - for (const file of filesToBackup) { - const sourcePath = join(this.projectRoot, file); - if (existsSync(sourcePath)) { - const backupPath = join(this.backupDir, file.replace('/', '_')); - copyFileSync(sourcePath, backupPath); - console.warn(` šŸ“„ Backed up: ${file}`); - } - } - - console.warn(`āœ… Backup created in: ${this.backupDir}`); - } - - /** - * Read existing configuration from various sources - */ - private async readExistingConfig(): Promise { - console.warn('\nšŸ” Reading existing configuration...'); - - const config: ParsedEnv = {}; - - // Read .env files - const envFiles = ['.env', '.env.production', '.env.local']; - for (const envFile of envFiles) { - const envPath = join(this.projectRoot, envFile); - if (existsSync(envPath)) { - const envData = this.parseEnvFile(envPath); - Object.assign(config, envData); - console.warn( - ` šŸ“„ Read: ${envFile} (${Object.keys(envData).length} variables)` - ); - } - } - - // Read Kubernetes config.env - const k8sConfigPath = join( - this.projectRoot, - 'k8s-kustomize/base/config.env' - ); - if (existsSync(k8sConfigPath)) { - const k8sData = this.parseEnvFile(k8sConfigPath); - Object.assign(config, k8sData); - console.warn( - ` 🚢 Read: k8s config.env (${Object.keys(k8sData).length} variables)` - ); - } - - console.warn( - `āœ… Found ${Object.keys(config).length} configuration variables` - ); - return config; - } - - /** - * Parse environment file - */ - private parseEnvFile(filePath: string): ParsedEnv { - try { - const content = readFileSync(filePath, 'utf8'); - const env: ParsedEnv = {}; - - content.split('\n').forEach(line => { - line = line.trim(); - if (line && !line.startsWith('#')) { - const [key, ...valueParts] = line.split('='); - if (key && valueParts.length > 0) { - env[key.trim()] = valueParts - .join('=') - .trim() - .replace(/^["']|["']$/g, ''); - } - } - }); - - return env; - } catch (error) { - console.warn(` āš ļø Could not read ${filePath}: ${error}`); - return {}; - } - } - - /** - * Map existing config to unified config structure - */ - private mapToUnifiedConfig(existingConfig: ParsedEnv): ParsedEnv { - console.warn('\nšŸ”„ Mapping to unified configuration structure...'); - - const unified: ParsedEnv = {}; - - // Application - unified.APP_NAME = existingConfig.APP_NAME || 'RxMinder'; - unified.APP_VERSION = existingConfig.APP_VERSION || '1.0.0'; - unified.APP_BASE_URL = - existingConfig.APP_BASE_URL || - existingConfig.VITE_BASE_URL || - 'http://localhost:5173'; - unified.NODE_ENV = existingConfig.NODE_ENV || 'development'; - - // Database - unified.VITE_COUCHDB_URL = - existingConfig.VITE_COUCHDB_URL || - existingConfig.COUCHDB_URL || - 'http://localhost:5984'; - unified.VITE_COUCHDB_USER = - existingConfig.VITE_COUCHDB_USER || - existingConfig.COUCHDB_USER || - 'admin'; - unified.VITE_COUCHDB_PASSWORD = - existingConfig.VITE_COUCHDB_PASSWORD || - existingConfig.COUCHDB_PASSWORD || - 'changeme'; - - // Container & Registry - unified.CONTAINER_REGISTRY = - existingConfig.CONTAINER_REGISTRY || 'gitea-http.taildb3494.ts.net'; - unified.CONTAINER_REPOSITORY = - existingConfig.CONTAINER_REPOSITORY || - existingConfig.GITEA_REPOSITORY || - 'will/meds'; - unified.CONTAINER_TAG = this.getContainerTag(existingConfig); - - // Kubernetes - unified.INGRESS_HOST = - existingConfig.INGRESS_HOST || - existingConfig.APP_BASE_URL?.replace(/^https?:\/\//, '') || - 'rxminder.local'; - unified.STORAGE_CLASS = existingConfig.STORAGE_CLASS || 'longhorn'; - unified.STORAGE_SIZE = existingConfig.STORAGE_SIZE || '1Gi'; - - // Email - unified.VITE_MAILGUN_API_KEY = - existingConfig.VITE_MAILGUN_API_KEY || - existingConfig.MAILGUN_API_KEY || - ''; - unified.VITE_MAILGUN_DOMAIN = - existingConfig.VITE_MAILGUN_DOMAIN || existingConfig.MAILGUN_DOMAIN || ''; - unified.VITE_MAILGUN_FROM_NAME = - existingConfig.VITE_MAILGUN_FROM_NAME || - existingConfig.MAILGUN_FROM_NAME || - 'RxMinder'; - unified.VITE_MAILGUN_FROM_EMAIL = - existingConfig.VITE_MAILGUN_FROM_EMAIL || - existingConfig.MAILGUN_FROM_EMAIL || - ''; - - // OAuth - unified.VITE_GOOGLE_CLIENT_ID = - existingConfig.VITE_GOOGLE_CLIENT_ID || - existingConfig.GOOGLE_CLIENT_ID || - ''; - unified.VITE_GITHUB_CLIENT_ID = - existingConfig.VITE_GITHUB_CLIENT_ID || - existingConfig.GITHUB_CLIENT_ID || - ''; - - // Feature Flags - unified.ENABLE_EMAIL_VERIFICATION = this.toBooleanString( - existingConfig.ENABLE_EMAIL_VERIFICATION, - true - ); - unified.ENABLE_OAUTH = this.toBooleanString( - existingConfig.ENABLE_OAUTH, - true - ); - unified.ENABLE_ADMIN_INTERFACE = this.toBooleanString( - existingConfig.ENABLE_ADMIN_INTERFACE, - true - ); - unified.ENABLE_MONITORING = this.toBooleanString( - existingConfig.ENABLE_MONITORING, - false - ); - unified.DEBUG_MODE = this.toBooleanString( - existingConfig.DEBUG_MODE, - unified.NODE_ENV === 'development' - ); - - // Performance - unified.LOG_LEVEL = - existingConfig.LOG_LEVEL || - (unified.NODE_ENV === 'production' ? 'warn' : 'info'); - unified.CACHE_TTL = existingConfig.CACHE_TTL || '1800'; - - // Security - unified.JWT_SECRET = - existingConfig.JWT_SECRET || - 'your-super-secret-jwt-key-change-in-production'; - - console.warn( - `āœ… Mapped ${Object.keys(unified).length} unified configuration variables` - ); - return unified; - } - - /** - * Get container tag from existing config - */ - private getContainerTag(existingConfig: ParsedEnv): string { - if (existingConfig.CONTAINER_TAG) return existingConfig.CONTAINER_TAG; - - // Extract from DOCKER_IMAGE if present - if (existingConfig.DOCKER_IMAGE) { - const parts = existingConfig.DOCKER_IMAGE.split(':'); - if (parts.length > 1) return parts[parts.length - 1]; - } - - // Default based on environment - const env = existingConfig.NODE_ENV || 'development'; - return env === 'production' - ? 'v1.0.0' - : env === 'staging' - ? 'staging' - : 'latest'; - } - - /** - * Convert value to boolean string - */ - private toBooleanString( - value: string | undefined, - defaultValue: boolean - ): string { - if (value === undefined) return defaultValue.toString(); - return (value.toLowerCase() === 'true' || value === '1').toString(); - } - - /** - * Update main .env file with unified structure - */ - private async updateMainEnvFile(unifiedValues: ParsedEnv): Promise { - console.warn('\nšŸ“ Updating .env file with unified structure...'); - - const envPath = join(this.projectRoot, '.env'); - const timestamp = new Date().toISOString(); - - const content = `# Unified Application Configuration -# Migrated on: ${timestamp} -# -# This file now serves as the single source of truth for configuration. -# All other config files are generated from this unified configuration. - -# ============================================================================ -# APPLICATION CONFIGURATION -# ============================================================================ - -# Application Identity -APP_NAME=${unifiedValues.APP_NAME} -APP_VERSION=${unifiedValues.APP_VERSION} -APP_BASE_URL=${unifiedValues.APP_BASE_URL} -NODE_ENV=${unifiedValues.NODE_ENV} - -# ============================================================================ -# DATABASE CONFIGURATION -# ============================================================================ - -# CouchDB Configuration -VITE_COUCHDB_URL=${unifiedValues.VITE_COUCHDB_URL} -VITE_COUCHDB_USER=${unifiedValues.VITE_COUCHDB_USER} -VITE_COUCHDB_PASSWORD=${unifiedValues.VITE_COUCHDB_PASSWORD} - -# ============================================================================ -# CONTAINER & DEPLOYMENT CONFIGURATION -# ============================================================================ - -# Container Registry -CONTAINER_REGISTRY=${unifiedValues.CONTAINER_REGISTRY} -CONTAINER_REPOSITORY=${unifiedValues.CONTAINER_REPOSITORY} -CONTAINER_TAG=${unifiedValues.CONTAINER_TAG} - -# Kubernetes Configuration -INGRESS_HOST=${unifiedValues.INGRESS_HOST} -STORAGE_CLASS=${unifiedValues.STORAGE_CLASS} -STORAGE_SIZE=${unifiedValues.STORAGE_SIZE} - -# ============================================================================ -# EMAIL CONFIGURATION -# ============================================================================ - -# Mailgun Configuration -VITE_MAILGUN_API_KEY=${unifiedValues.VITE_MAILGUN_API_KEY} -VITE_MAILGUN_DOMAIN=${unifiedValues.VITE_MAILGUN_DOMAIN} -VITE_MAILGUN_FROM_NAME=${unifiedValues.VITE_MAILGUN_FROM_NAME} -VITE_MAILGUN_FROM_EMAIL=${unifiedValues.VITE_MAILGUN_FROM_EMAIL} - -# ============================================================================ -# OAUTH CONFIGURATION -# ============================================================================ - -# OAuth Provider Configuration -VITE_GOOGLE_CLIENT_ID=${unifiedValues.VITE_GOOGLE_CLIENT_ID} -VITE_GITHUB_CLIENT_ID=${unifiedValues.VITE_GITHUB_CLIENT_ID} - -# ============================================================================ -# FEATURE FLAGS -# ============================================================================ - -# Application Features -ENABLE_EMAIL_VERIFICATION=${unifiedValues.ENABLE_EMAIL_VERIFICATION} -ENABLE_OAUTH=${unifiedValues.ENABLE_OAUTH} -ENABLE_ADMIN_INTERFACE=${unifiedValues.ENABLE_ADMIN_INTERFACE} -ENABLE_MONITORING=${unifiedValues.ENABLE_MONITORING} -DEBUG_MODE=${unifiedValues.DEBUG_MODE} - -# ============================================================================ -# PERFORMANCE & SECURITY -# ============================================================================ - -# Logging and Performance -LOG_LEVEL=${unifiedValues.LOG_LEVEL} -CACHE_TTL=${unifiedValues.CACHE_TTL} - -# Security -JWT_SECRET=${unifiedValues.JWT_SECRET} - -# ============================================================================ -# ENVIRONMENT-SPECIFIC OVERRIDES -# ============================================================================ -# Add environment-specific variables below this line -# These will override the unified configuration for this environment - -`; - - if (this.options.dryRun) { - console.warn(' šŸ” [DRY RUN] Would update .env file'); - console.warn(` Path: ${envPath}`); - console.warn(` Size: ${content.length} bytes`); - } else { - writeFileSync(envPath, content, 'utf8'); - console.warn(' āœ… Updated .env file with unified structure'); - } - } - - /** - * Generate unified config files - */ - private async generateUnifiedConfigFiles(): Promise { - console.warn('\nšŸ› ļø Generating unified configuration files...'); - - if (this.options.dryRun) { - console.warn(' šŸ” [DRY RUN] Would generate unified config files'); - } else { - try { - // Use the new generator script - const { ConfigGenerator } = await import('./generate-unified-config'); - const generator = new ConfigGenerator({ generateAll: true }); - await generator.generate(); - console.warn(' āœ… Generated unified configuration files'); - } catch (_error) { - console.warn(' āš ļø Could not auto-generate config files'); - console.warn(' šŸ’” Run manually: make generate-config'); - } - } - } - - /** - * Update package.json scripts to use unified config - */ - private async updatePackageJsonScripts(): Promise { - console.warn('\nšŸ“¦ Checking package.json scripts...'); - - const packageJsonPath = join(this.projectRoot, 'package.json'); - if (!existsSync(packageJsonPath)) { - console.warn(' āš ļø package.json not found, skipping script updates'); - return; - } - - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - let updated = false; - - // Update scripts that might reference old config generation - const scriptUpdates = { - 'generate:config': 'bun scripts/generate-unified-config.ts --all', - 'config:dev': 'bun scripts/generate-unified-config.ts development', - 'config:prod': 'bun scripts/generate-unified-config.ts production', - 'config:staging': 'bun scripts/generate-unified-config.ts staging', - }; - - for (const [scriptName, command] of Object.entries(scriptUpdates)) { - if (!packageJson.scripts[scriptName]) { - packageJson.scripts[scriptName] = command; - updated = true; - } - } - - if (updated) { - if (this.options.dryRun) { - console.warn( - ' šŸ” [DRY RUN] Would add unified config scripts to package.json' - ); - } else { - writeFileSync( - packageJsonPath, - JSON.stringify(packageJson, null, 2), - 'utf8' - ); - console.warn(' āœ… Added unified config scripts to package.json'); - } - } else { - console.warn(' ā„¹ļø No package.json script updates needed'); - } - } catch (error) { - console.warn(` āš ļø Could not update package.json: ${error}`); - } - } -} - -/** - * CLI Interface - */ -async function main() { - const args = process.argv.slice(2); - const options: MigrationOptions = {}; - - // Parse command line arguments - for (const arg of args) { - switch (arg) { - case '--dry-run': - options.dryRun = true; - break; - case '--backup': - options.backup = true; - break; - case '--verbose': - case '-v': - options.verbose = true; - break; - case '--help': - case '-h': - showHelp(); - process.exit(0); - break; - default: - console.warn(`Unknown option: ${arg}`); - break; - } - } - - try { - const migrator = new ConfigMigrator(options); - await migrator.migrate(); - } catch (error) { - console.error('āŒ Migration failed:', error); - process.exit(1); - } -} - -/** - * Show help message - */ -function showHelp() { - console.warn(` -šŸ”„ Configuration Migration Tool - -Migrates from the old configuration system (multiple .env files, -separate Kubernetes configs) to the new unified configuration system. - -USAGE: - bun scripts/migrate-to-unified-config.ts [options] - -OPTIONS: - --dry-run Show what would be changed without making changes - --backup Create backup of existing configuration files - --verbose, -v Show detailed output - --help, -h Show this help message - -EXAMPLES: - bun scripts/migrate-to-unified-config.ts --backup - bun scripts/migrate-to-unified-config.ts --dry-run - bun scripts/migrate-to-unified-config.ts --backup --verbose - -WHAT IT DOES: - 1. Reads existing .env files and Kubernetes config - 2. Maps old config structure to unified configuration - 3. Updates .env file with unified structure - 4. Generates new configuration files for all environments - 5. Updates package.json scripts (if needed) - -BACKUP: - When --backup is used, existing config files are copied to: - .config-backup/ - -AFTER MIGRATION: - 1. Review the updated .env file - 2. Run: make generate-config - 3. Test: make dev - 4. Deploy: make deploy-prod-quick -`); -} - -// Run CLI if this file is executed directly -if (import.meta.main) { - main().catch(console.error); -} - -export { ConfigMigrator, type MigrationOptions }; diff --git a/services/couchdb.factory.ts b/services/couchdb.factory.ts deleted file mode 100644 index 5a5a06d..0000000 --- a/services/couchdb.factory.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Legacy compatibility layer for the new consolidated database service -// This file maintains backward compatibility while migrating to the new architecture - -import { databaseService } from './database'; - -// Re-export the consolidated service as dbService for existing code -export const dbService = databaseService; - -// Re-export the error class for backward compatibility -export { DatabaseError as CouchDBError } from './database'; - -// Legacy warning for developers -console.error( - 'āš ļø Using legacy couchdb.factory.ts - Consider migrating to services/database directly' -); diff --git a/services/couchdb.production.ts b/services/couchdb.production.ts deleted file mode 100644 index 05076f6..0000000 --- a/services/couchdb.production.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; -import { - User, - Medication, - UserSettings, - TakenDoses, - CustomReminder, - CouchDBDocument, - UserRole, -} from '../types'; -import { AccountStatus } from './auth/auth.constants'; -import { CouchDBError } from './couchdb'; - -// Production CouchDB Service that connects to a real CouchDB instance -export class CouchDBService { - private baseUrl: string; - private auth: string; - - constructor() { - // Get CouchDB configuration from environment - const couchdbUrl = process.env.VITE_COUCHDB_URL || 'http://localhost:5984'; - const couchdbUser = process.env.VITE_COUCHDB_USER || 'admin'; - const couchdbPassword = process.env.VITE_COUCHDB_PASSWORD || 'password'; - - this.baseUrl = couchdbUrl; - this.auth = btoa(`${couchdbUser}:${couchdbPassword}`); - - // Initialize databases - this.initializeDatabases(); - } - - private async initializeDatabases(): Promise { - const databases = [ - 'users', - 'medications', - 'settings', - 'taken_doses', - 'reminders', - ]; - - for (const dbName of databases) { - try { - await this.createDatabaseIfNotExists(dbName); - } catch (error) { - console.error(`Failed to initialize database ${dbName}:`, error); - } - } - } - - private async createDatabaseIfNotExists(dbName: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/${dbName}`, { - method: 'HEAD', - headers: { - Authorization: `Basic ${this.auth}`, - }, - }); - - if (response.status === 404) { - // Database doesn't exist, create it - const createResponse = await fetch(`${this.baseUrl}/${dbName}`, { - method: 'PUT', - headers: { - Authorization: `Basic ${this.auth}`, - 'Content-Type': 'application/json', - }, - }); - - if (!createResponse.ok) { - throw new Error(`Failed to create database ${dbName}`); - } - - console.warn(`āœ… Created CouchDB database: ${dbName}`); - } - } catch (error) { - console.error(`Error checking/creating database ${dbName}:`, error); - throw error; - } - } - - private async makeRequest( - method: string, - path: string, - body?: Record - ): Promise> { - const url = `${this.baseUrl}${path}`; - const options: RequestInit = { - method, - headers: { - Authorization: `Basic ${this.auth}`, - 'Content-Type': 'application/json', - }, - }; - - if (body) { - options.body = JSON.stringify(body); - } - - const response = await fetch(url, options); - - if (!response.ok) { - const errorText = await response.text(); - throw new CouchDBError(`CouchDB error: ${errorText}`, response.status); - } - - return response.json(); - } - - private async getDoc( - dbName: string, - id: string - ): Promise { - try { - const doc = await this.makeRequest('GET', `/${dbName}/${id}`); - return doc as T; - } catch (error) { - if (error instanceof CouchDBError && error.status === 404) { - return null; - } - throw error; - } - } - - private async putDoc( - dbName: string, - doc: Omit & { _rev?: string } - ): Promise { - const response = await this.makeRequest( - 'PUT', - `/${dbName}/${doc._id}`, - doc - ); - return { ...doc, _rev: response.rev } as T; - } - - private async query( - dbName: string, - selector: Record - ): Promise { - const response = await this.makeRequest('POST', `/${dbName}/_find`, { - selector, - limit: 1000, - }); - return response.docs as T[]; - } - - // User Management Methods - async findUserByUsername(username: string): Promise { - const users = await this.query('users', { username }); - return users[0] || null; - } - - async findUserByEmail(email: string): Promise { - const users = await this.query('users', { email }); - return users[0] || null; - } - - async createUser(username: string): Promise { - const existingUser = await this.findUserByUsername(username); - if (existingUser) { - throw new CouchDBError('User already exists', 409); - } - - const newUser: Omit = { _id: uuidv4(), username }; - return this.putDoc('users', newUser); - } - - async createUserWithPassword( - email: string, - password: string, - username?: string - ): Promise { - const existingUser = await this.findUserByEmail(email); - if (existingUser) { - throw new CouchDBError('User already exists', 409); - } - - const newUser: Omit = { - _id: uuidv4(), - username: username || email.split('@')[0], - email, - password, - emailVerified: false, - status: AccountStatus.PENDING, - role: UserRole.USER, - createdAt: new Date(), - lastLoginAt: new Date(), - }; - - return this.putDoc('users', newUser); - } - - async createUserFromOAuth(userData: { - email: string; - username: string; - avatar?: string; - }): Promise { - const existingUser = await this.findUserByEmail(userData.email); - if (existingUser) { - throw new CouchDBError('User already exists', 409); - } - - const newUser: Omit = { - _id: uuidv4(), - username: userData.username, - email: userData.email, - avatar: userData.avatar, - emailVerified: true, - status: AccountStatus.ACTIVE, - role: UserRole.USER, - createdAt: new Date(), - lastLoginAt: new Date(), - }; - - return this.putDoc('users', newUser); - } - - async getUserById(id: string): Promise { - return this.getDoc('users', id); - } - - async updateUser(user: User): Promise { - return this.putDoc('users', user); - } - - async deleteUser(id: string): Promise { - const user = await this.getDoc('users', id); - if (!user) { - throw new CouchDBError('User not found', 404); - } - - await this.makeRequest('DELETE', `/users/${id}?rev=${user._rev}`); - } - - // Medication Methods - async getMedications(userId: string): Promise { - return this.query('medications', { userId }); - } - - async createMedication( - medication: Omit - ): Promise { - const newMedication = { ...medication, _id: uuidv4() }; - return this.putDoc('medications', newMedication); - } - - async updateMedication(medication: Medication): Promise { - return this.putDoc('medications', medication); - } - - async deleteMedication(id: string): Promise { - const medication = await this.getDoc('medications', id); - if (!medication) { - throw new CouchDBError('Medication not found', 404); - } - - await this.makeRequest( - 'DELETE', - `/medications/${id}?rev=${medication._rev}` - ); - } - - // Settings Methods - async getSettings(userId: string): Promise { - const settings = await this.getDoc('settings', userId); - if (!settings) { - const defaultSettings: Omit = { - _id: userId, - notificationsEnabled: true, - hasCompletedOnboarding: false, - }; - return this.putDoc('settings', defaultSettings); - } - return settings; - } - - async updateSettings(settings: UserSettings): Promise { - return this.putDoc('settings', settings); - } - - // Taken Doses Methods - async getTakenDoses(userId: string): Promise { - const doses = await this.getDoc('taken_doses', userId); - if (!doses) { - const defaultDoses: Omit = { - _id: userId, - doses: {}, - }; - return this.putDoc('taken_doses', defaultDoses); - } - return doses; - } - - async updateTakenDoses(takenDoses: TakenDoses): Promise { - return this.putDoc('taken_doses', takenDoses); - } - - // Reminder Methods - async getReminders(userId: string): Promise { - return this.query('reminders', { userId }); - } - - async createReminder( - reminder: Omit - ): Promise { - const newReminder = { ...reminder, _id: uuidv4() }; - return this.putDoc('reminders', newReminder); - } - - async updateReminder(reminder: CustomReminder): Promise { - return this.putDoc('reminders', reminder); - } - - async deleteReminder(id: string): Promise { - const reminder = await this.getDoc('reminders', id); - if (!reminder) { - throw new CouchDBError('Reminder not found', 404); - } - - await this.makeRequest('DELETE', `/reminders/${id}?rev=${reminder._rev}`); - } - - // Admin Methods - async getAllUsers(): Promise { - return this.query('users', {}); - } - - async updateUserStatus(userId: string, status: AccountStatus): Promise { - const user = await this.getUserById(userId); - if (!user) { - throw new CouchDBError('User not found', 404); - } - - const updatedUser = { ...user, status }; - return this.updateUser(updatedUser); - } - - async changeUserPassword(userId: string, newPassword: string): Promise { - const user = await this.getUserById(userId); - if (!user) { - throw new CouchDBError('User not found', 404); - } - - const updatedUser = { ...user, password: newPassword }; - return this.updateUser(updatedUser); - } - - // Cleanup Methods - async deleteAllUserData(userId: string): Promise { - // Delete user medications, settings, doses, and reminders - const [medications, reminders] = await Promise.all([ - this.getMedications(userId), - this.getReminders(userId), - ]); - - // Delete all user data - const deletePromises = [ - ...medications.map(med => this.deleteMedication(med._id)), - ...reminders.map(rem => this.deleteReminder(rem._id)), - ]; - - // Delete settings and taken doses - try { - const settings = await this.getDoc('settings', userId); - if (settings) { - deletePromises.push( - this.makeRequest( - 'DELETE', - `/settings/${userId}?rev=${settings._rev}` - ).then(() => undefined) - ); - } - } catch (_error) { - // Settings might not exist - } - - try { - const takenDoses = await this.getDoc('taken_doses', userId); - if (takenDoses) { - deletePromises.push( - this.makeRequest( - 'DELETE', - `/taken_doses/${userId}?rev=${takenDoses._rev}` - ).then(() => undefined) - ); - } - } catch (_error) { - // Taken doses might not exist - } - - await Promise.all(deletePromises); - - // Finally delete the user - await this.deleteUser(userId); - } -} diff --git a/services/couchdb.ts b/services/couchdb.ts deleted file mode 100644 index da404b6..0000000 --- a/services/couchdb.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; -import { - User, - Medication, - UserSettings, - TakenDoses, - CustomReminder, - CouchDBDocument, - UserRole, -} from '../types'; -import { AccountStatus } from './auth/auth.constants'; - -// This is a mock CouchDB service that uses localStorage for persistence. -// It mimics the async nature of a real database API and includes robust error handling and conflict resolution. - -const latency = () => - new Promise(res => setTimeout(res, Math.random() * 200 + 50)); - -export class CouchDBError extends Error { - status: number; - constructor(message: string, status: number) { - super(message); - this.name = 'CouchDBError'; - this.status = status; - } -} - -class CouchDBService { - private async getDb(dbName: string): Promise { - await latency(); - const db = localStorage.getItem(dbName); - return db ? JSON.parse(db) : []; - } - - private async saveDb(dbName: string, data: T[]): Promise { - await latency(); - localStorage.setItem(dbName, JSON.stringify(data)); - } - - private async getDoc( - dbName: string, - id: string - ): Promise { - const allDocs = await this.getDb(dbName); - return allDocs.find(doc => doc._id === id) || null; - } - - private async query( - dbName: string, - predicate: (doc: T) => boolean - ): Promise { - const allDocs = await this.getDb(dbName); - return allDocs.filter(predicate); - } - - private async putDoc( - dbName: string, - doc: Omit & { _rev?: string } - ): Promise { - const allDocs = await this.getDb(dbName); - const docIndex = allDocs.findIndex(d => d._id === doc._id); - - if (docIndex > -1) { - // Update - const existingDoc = allDocs[docIndex]; - if (existingDoc._rev !== doc._rev) { - throw new CouchDBError('Document update conflict', 409); - } - const newRev = parseInt(existingDoc._rev.split('-')[0], 10) + 1; - const updatedDoc = { - ...doc, - _rev: `${newRev}-${Math.random().toString(36).substr(2, 9)}`, - } as T; - allDocs[docIndex] = updatedDoc; - await this.saveDb(dbName, allDocs); - return updatedDoc; - } else { - // Create - const newDoc = { - ...doc, - _rev: `1-${Math.random().toString(36).substr(2, 9)}`, - } as T; - allDocs.push(newDoc); - await this.saveDb(dbName, allDocs); - return newDoc; - } - } - - private async deleteDoc( - dbName: string, - doc: T - ): Promise { - let docs = await this.getDb(dbName); - const docIndex = docs.findIndex(d => d._id === doc._id); - if (docIndex > -1) { - if (docs[docIndex]._rev !== doc._rev) { - throw new CouchDBError('Document update conflict', 409); - } - docs = docs.filter(m => m._id !== doc._id); - await this.saveDb(dbName, docs); - } else { - throw new CouchDBError('Document not found', 404); - } - } - - // Generic update function with conflict resolution - private async updateDocWithConflictResolution( - dbName: string, - doc: T, - mergeFn?: (latest: T, incoming: T) => T - ): Promise { - try { - return await this.putDoc(dbName, doc); - } catch (error) { - if (error instanceof CouchDBError && error.status === 409) { - console.warn( - `Conflict detected for doc ${doc._id}. Attempting to resolve.` - ); - const latestDoc = await this.getDoc(dbName, doc._id); - if (latestDoc) { - // Default merge: incoming changes overwrite latest - const defaultMerge = { ...latestDoc, ...doc, _rev: latestDoc._rev }; - const mergedDoc = mergeFn ? mergeFn(latestDoc, doc) : defaultMerge; - // Retry the update with the latest revision and merged data - return this.putDoc(dbName, mergedDoc); - } - } - // Re-throw if it's not a resolvable conflict or fetching the latest doc fails - throw error; - } - } - - // --- User Management --- - async findUserByUsername(username: string): Promise { - const users = await this.query( - 'users', - u => u.username.toLowerCase() === username.toLowerCase() - ); - return users[0] || null; - } - - async findUserByEmail(email: string): Promise { - const users = await this.query( - 'users', - u => u.email?.toLowerCase() === email.toLowerCase() - ); - return users[0] || null; - } - - async createUser(username: string): Promise { - if (await this.findUserByUsername(username)) { - throw new CouchDBError('User already exists', 409); - } - const newUser: Omit = { _id: uuidv4(), username }; - return this.putDoc('users', newUser); - } - - async createUserWithPassword( - email: string, - password: string, - username?: string - ): Promise { - // Check if user already exists by email - const existingUser = await this.findUserByEmail(email); - if (existingUser) { - throw new CouchDBError('User already exists', 409); - } - - const newUser: Omit = { - _id: uuidv4(), - username: username || email.split('@')[0], // Default username from email - email, - password, // In production, this should be hashed with bcrypt - emailVerified: false, // Require email verification for password accounts - status: AccountStatus.PENDING, - role: UserRole.USER, // Default role is USER - createdAt: new Date(), - lastLoginAt: new Date(), - }; - return this.putDoc('users', newUser); - } - - async createUserFromOAuth(userData: { - email: string; - username: string; - avatar?: string; - }): Promise { - // Check if user already exists by email - const existingUser = await this.findUserByEmail(userData.email); - if (existingUser) { - throw new CouchDBError('User already exists', 409); - } - - const newUser: Omit = { - _id: uuidv4(), - username: userData.username, - email: userData.email, - avatar: userData.avatar, - emailVerified: true, // OAuth users have verified emails - status: AccountStatus.ACTIVE, - role: UserRole.USER, // Default role is USER - createdAt: new Date(), - lastLoginAt: new Date(), - }; - return this.putDoc('users', newUser); - } - - async updateUser(user: User): Promise { - return this.updateDocWithConflictResolution('users', user); - } - - // --- Admin User Management --- - async getAllUsers(): Promise { - return this.getDb('users'); - } - - async getUserById(userId: string): Promise { - return this.getDoc('users', userId); - } - - async suspendUser(userId: string): Promise { - const user = await this.getUserById(userId); - if (!user) { - throw new CouchDBError('User not found', 404); - } - - const updatedUser = { ...user, status: AccountStatus.SUSPENDED }; - return this.updateUser(updatedUser); - } - - async activateUser(userId: string): Promise { - const user = await this.getUserById(userId); - if (!user) { - throw new CouchDBError('User not found', 404); - } - - const updatedUser = { ...user, status: AccountStatus.ACTIVE }; - return this.updateUser(updatedUser); - } - - async deleteUser(userId: string): Promise { - const user = await this.getUserById(userId); - if (!user) { - throw new CouchDBError('User not found', 404); - } - - // Delete user data - await this.deleteDoc('users', user); - - // Delete user's associated data - const userMeds = this.getUserDbName('meds', userId); - const userSettings = this.getUserDbName('settings', userId); - const userTaken = this.getUserDbName('taken', userId); - const userReminders = this.getUserDbName('reminders', userId); - - localStorage.removeItem(userMeds); - localStorage.removeItem(userSettings); - localStorage.removeItem(userTaken); - localStorage.removeItem(userReminders); - } - - async changeUserPassword(userId: string, newPassword: string): Promise { - const user = await this.getUserById(userId); - if (!user) { - throw new CouchDBError('User not found', 404); - } - - // In production, hash the password with bcrypt - const updatedUser = { ...user, password: newPassword }; - return this.updateUser(updatedUser); - } - - // --- User Data Management --- - private getUserDbName = ( - type: 'meds' | 'settings' | 'taken' | 'reminders', - userId: string - ) => `${type}_${userId}`; - - async getMedications(userId: string): Promise { - return this.getDb(this.getUserDbName('meds', userId)); - } - - async addMedication( - userId: string, - med: Omit - ): Promise { - const newMed = { ...med, _id: uuidv4() }; - return this.putDoc(this.getUserDbName('meds', userId), newMed); - } - - async updateMedication(userId: string, med: Medication): Promise { - return this.updateDocWithConflictResolution( - this.getUserDbName('meds', userId), - med - ); - } - - async deleteMedication(userId: string, med: Medication): Promise { - return this.deleteDoc(this.getUserDbName('meds', userId), med); - } - - async getCustomReminders(userId: string): Promise { - return this.getDb(this.getUserDbName('reminders', userId)); - } - - async addCustomReminder( - userId: string, - reminder: Omit - ): Promise { - const newReminder = { ...reminder, _id: uuidv4() }; - return this.putDoc( - this.getUserDbName('reminders', userId), - newReminder - ); - } - - async updateCustomReminder( - userId: string, - reminder: CustomReminder - ): Promise { - return this.updateDocWithConflictResolution( - this.getUserDbName('reminders', userId), - reminder - ); - } - - async deleteCustomReminder( - userId: string, - reminder: CustomReminder - ): Promise { - return this.deleteDoc( - this.getUserDbName('reminders', userId), - reminder - ); - } - - async getSettings(userId: string): Promise { - const dbName = this.getUserDbName('settings', userId); - let settings = await this.getDoc(dbName, userId); - if (!settings) { - settings = await this.putDoc(dbName, { - _id: userId, - notificationsEnabled: true, - hasCompletedOnboarding: false, - }); - } - return settings; - } - - async updateSettings( - userId: string, - settings: UserSettings - ): Promise { - return this.updateDocWithConflictResolution( - this.getUserDbName('settings', userId), - settings - ); - } - - async getTakenDoses(userId: string): Promise { - const dbName = this.getUserDbName('taken', userId); - let takenDoses = await this.getDoc(dbName, userId); - if (!takenDoses) { - takenDoses = await this.putDoc(dbName, { - _id: userId, - doses: {}, - }); - } - return takenDoses; - } - - async updateTakenDoses( - userId: string, - takenDoses: TakenDoses - ): Promise { - // Custom merge logic for taken doses to avoid overwriting recent updates - const mergeFn = (latest: TakenDoses, incoming: TakenDoses): TakenDoses => { - return { - ...latest, // Use latest doc as the base - ...incoming, // Apply incoming changes - doses: { ...latest.doses, ...incoming.doses }, // Specifically merge the doses object - _rev: latest._rev, // IMPORTANT: Use the latest revision for the update attempt - }; - }; - return this.updateDocWithConflictResolution( - this.getUserDbName('taken', userId), - takenDoses, - mergeFn - ); - } - - async deleteAllUserData(userId: string): Promise { - await latency(); - localStorage.removeItem(this.getUserDbName('meds', userId)); - localStorage.removeItem(this.getUserDbName('settings', userId)); - localStorage.removeItem(this.getUserDbName('taken', userId)); - localStorage.removeItem(this.getUserDbName('reminders', userId)); - } -} - -export { CouchDBService }; -export const dbService = new CouchDBService(); diff --git a/services/database/__tests__/DatabaseService.test.ts b/services/database/__tests__/DatabaseService.test.ts index d89eb27..730a1ad 100644 --- a/services/database/__tests__/DatabaseService.test.ts +++ b/services/database/__tests__/DatabaseService.test.ts @@ -1,5 +1,3 @@ -import { AccountStatus } from '../../auth/auth.constants'; - // Mock the environment utilities jest.mock('../../../utils/env', () => ({ getEnvVar: jest.fn(), @@ -51,6 +49,9 @@ jest.mock('../ProductionDatabaseStrategy', () => ({ // Import after mocks are set up import { DatabaseService } from '../DatabaseService'; +import { testUtils } from '../../../tests/setup'; + +const { createMockUser } = testUtils; describe('DatabaseService', () => { let mockGetEnvVar: jest.MockedFunction; @@ -301,26 +302,6 @@ describe('DatabaseService', () => { expect(result).toBe(medication); }); - test('should delegate updateMedication to strategy (legacy signature)', async () => { - const medication = { - _id: 'med1', - _rev: 'rev1', - name: 'Updated Aspirin', - dosage: '200mg', - frequency: 'Daily' as any, - startTime: '08:00', - notes: '', - }; - mockStrategyMethods.updateMedication.mockResolvedValue(medication); - - const result = await service.updateMedication('user1', medication); - - expect(mockStrategyMethods.updateMedication).toHaveBeenCalledWith( - medication - ); - expect(result).toBe(medication); - }); - test('should delegate getMedications to strategy', async () => { const medications = [{ _id: 'med1', _rev: 'rev1', name: 'Aspirin' }]; mockStrategyMethods.getMedications.mockResolvedValue(medications); @@ -331,22 +312,12 @@ describe('DatabaseService', () => { expect(result).toBe(medications); }); - test('should delegate deleteMedication to strategy (new signature)', async () => { - mockStrategyMethods.deleteMedication.mockResolvedValue(true); + test('should delegate deleteMedication to strategy', async () => { + mockStrategyMethods.deleteMedication.mockResolvedValue(undefined); - const result = await service.deleteMedication('med1'); + await service.deleteMedication('med1'); expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith('med1'); - expect(result).toBe(true); - }); - - test('should delegate deleteMedication to strategy (legacy signature)', async () => { - mockStrategyMethods.deleteMedication.mockResolvedValue(true); - - const result = await service.deleteMedication('user1', { _id: 'med1' }); - - expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith('med1'); - expect(result).toBe(true); }); }); @@ -382,162 +353,112 @@ describe('DatabaseService', () => { service = new DatabaseService(); }); - test('should support legacy getSettings method', async () => { - const settings = { _id: 'settings1', theme: 'dark' }; - mockStrategyMethods.getUserSettings.mockResolvedValue(settings); + describe('user management operations', () => { + test('should support suspendUser method', async () => { + const user = createMockUser(); + const suspendedUser = { ...user, status: 'SUSPENDED' as any }; + mockStrategyMethods.getUserById.mockResolvedValue(user); + mockStrategyMethods.updateUser.mockResolvedValue(suspendedUser); - const result = await service.getSettings('user1'); + const result = await service.suspendUser('user1'); - expect(mockStrategyMethods.getUserSettings).toHaveBeenCalledWith('user1'); - expect(result).toBe(settings); - }); - - test('should support legacy addMedication method', async () => { - const medicationInput = { - name: 'Aspirin', - dosage: '100mg', - frequency: 'Daily' as any, - startTime: '08:00', - notes: '', - }; - const medication = { _id: 'med1', _rev: 'rev1', ...medicationInput }; - mockStrategyMethods.createMedication.mockResolvedValue(medication); - - const result = await service.addMedication('user1', medicationInput); - - expect(mockStrategyMethods.createMedication).toHaveBeenCalledWith( - 'user1', - medicationInput - ); - expect(result).toBe(medication); - }); - - test('should support legacy updateSettings method', async () => { - const currentSettings = { - _id: 'settings1', - _rev: 'rev1', - notificationsEnabled: true, - hasCompletedOnboarding: false, - }; - const updatedSettings = { - _id: 'settings1', - _rev: 'rev2', - notificationsEnabled: false, - hasCompletedOnboarding: false, - }; - mockStrategyMethods.getUserSettings.mockResolvedValue(currentSettings); - mockStrategyMethods.updateUserSettings.mockResolvedValue(updatedSettings); - - const result = await service.updateSettings('user1', { - notificationsEnabled: false, + expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1'); + expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({ + ...user, + status: 'SUSPENDED', + }); + expect(result).toBe(suspendedUser); }); - expect(mockStrategyMethods.getUserSettings).toHaveBeenCalledWith('user1'); - expect(mockStrategyMethods.updateUserSettings).toHaveBeenCalledWith({ - _id: 'settings1', - _rev: 'rev1', - notificationsEnabled: false, - hasCompletedOnboarding: false, + test('should support activateUser method', async () => { + const user = { ...createMockUser(), status: 'SUSPENDED' as any }; + const activatedUser = { ...user, status: 'ACTIVE' as any }; + mockStrategyMethods.getUserById.mockResolvedValue(user); + mockStrategyMethods.updateUser.mockResolvedValue(activatedUser); + + const result = await service.activateUser('user1'); + + expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1'); + expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({ + ...user, + status: 'ACTIVE', + }); + expect(result).toBe(activatedUser); }); - expect(result).toBe(updatedSettings); - }); - test('should support suspendUser method', async () => { - const user = { _id: 'user1', _rev: 'rev1', status: AccountStatus.ACTIVE }; - const suspendedUser = { ...user, status: AccountStatus.SUSPENDED }; - mockStrategyMethods.getUserById.mockResolvedValue(user); - mockStrategyMethods.updateUser.mockResolvedValue(suspendedUser); + test('should support changeUserPassword method', async () => { + const user = createMockUser(); + const updatedUser = { ...user, password: 'newPassword' }; + mockStrategyMethods.getUserById.mockResolvedValue(user); + mockStrategyMethods.updateUser.mockResolvedValue(updatedUser); - const result = await service.suspendUser('user1'); + const result = await service.changeUserPassword('user1', 'newPassword'); - expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1'); - expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({ - ...user, - status: AccountStatus.SUSPENDED, + expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1'); + expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({ + ...user, + password: 'newPassword', + }); + expect(result).toBe(updatedUser); }); - expect(result).toBe(suspendedUser); - }); - test('should support activateUser method', async () => { - const user = { - _id: 'user1', - _rev: 'rev1', - status: AccountStatus.SUSPENDED, - }; - const activeUser = { ...user, status: AccountStatus.ACTIVE }; - mockStrategyMethods.getUserById.mockResolvedValue(user); - mockStrategyMethods.updateUser.mockResolvedValue(activeUser); + test('should support deleteAllUserData method', async () => { + const medications = [ + { _id: 'med1', _rev: 'rev1', name: 'Aspirin' }, + { _id: 'med2', _rev: 'rev2', name: 'Vitamin' }, + ]; + const reminders = [{ _id: 'rem1', _rev: 'rev1', name: 'Doctor Visit' }]; - const result = await service.activateUser('user1'); + mockStrategyMethods.getMedications.mockResolvedValue(medications); + mockStrategyMethods.getCustomReminders.mockResolvedValue(reminders); + mockStrategyMethods.deleteMedication.mockResolvedValue(true); + mockStrategyMethods.deleteCustomReminder.mockResolvedValue(true); + mockStrategyMethods.deleteUser.mockResolvedValue(true); - expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({ - ...user, - status: AccountStatus.ACTIVE, + const result = await service.deleteAllUserData('user1'); + + expect(mockStrategyMethods.getMedications).toHaveBeenCalledWith( + 'user1' + ); + expect(mockStrategyMethods.getCustomReminders).toHaveBeenCalledWith( + 'user1' + ); + expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith( + 'med1' + ); + expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith( + 'med2' + ); + expect(mockStrategyMethods.deleteCustomReminder).toHaveBeenCalledWith( + 'rem1' + ); + expect(mockStrategyMethods.deleteUser).toHaveBeenCalledWith('user1'); + expect(result).toBe(true); }); - expect(result).toBe(activeUser); - }); - test('should support changeUserPassword method', async () => { - const user = { _id: 'user1', _rev: 'rev1', password: 'oldpass' }; - const updatedUser = { ...user, password: 'newpass' }; - mockStrategyMethods.getUserById.mockResolvedValue(user); - mockStrategyMethods.updateUser.mockResolvedValue(updatedUser); + test('should throw error when user not found in suspendUser', async () => { + mockStrategyMethods.getUserById.mockResolvedValue(null); - const result = await service.changeUserPassword('user1', 'newpass'); - - expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({ - ...user, - password: 'newpass', + await expect(service.suspendUser('user1')).rejects.toThrow( + 'User not found' + ); }); - expect(result).toBe(updatedUser); - }); - test('should support deleteAllUserData method', async () => { - const medications = [{ _id: 'med1', _rev: 'rev1' }]; - const reminders = [{ _id: 'rem1', _rev: 'rev1' }]; + test('should throw error when user not found in activateUser', async () => { + mockStrategyMethods.getUserById.mockResolvedValue(null); - mockStrategyMethods.getMedications.mockResolvedValue(medications); - mockStrategyMethods.getCustomReminders.mockResolvedValue(reminders); - mockStrategyMethods.deleteMedication.mockResolvedValue(true); - mockStrategyMethods.deleteCustomReminder.mockResolvedValue(true); - mockStrategyMethods.deleteUser.mockResolvedValue(true); + await expect(service.activateUser('user1')).rejects.toThrow( + 'User not found' + ); + }); - const result = await service.deleteAllUserData('user1'); + test('should throw error when user not found in changeUserPassword', async () => { + mockStrategyMethods.getUserById.mockResolvedValue(null); - expect(mockStrategyMethods.getMedications).toHaveBeenCalledWith('user1'); - expect(mockStrategyMethods.getCustomReminders).toHaveBeenCalledWith( - 'user1' - ); - expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith('med1'); - expect(mockStrategyMethods.deleteCustomReminder).toHaveBeenCalledWith( - 'rem1' - ); - expect(mockStrategyMethods.deleteUser).toHaveBeenCalledWith('user1'); - expect(result).toBe(true); - }); - - test('should throw error when user not found in suspendUser', async () => { - mockStrategyMethods.getUserById.mockResolvedValue(null); - - await expect(service.suspendUser('user1')).rejects.toThrow( - 'User not found' - ); - }); - - test('should throw error when user not found in activateUser', async () => { - mockStrategyMethods.getUserById.mockResolvedValue(null); - - await expect(service.activateUser('user1')).rejects.toThrow( - 'User not found' - ); - }); - - test('should throw error when user not found in changeUserPassword', async () => { - mockStrategyMethods.getUserById.mockResolvedValue(null); - - await expect( - service.changeUserPassword('user1', 'newpass') - ).rejects.toThrow('User not found'); + await expect( + service.changeUserPassword('user1', 'newPassword') + ).rejects.toThrow('User not found'); + }); }); }); });