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)
This commit is contained in:
William Valentin
2025-09-08 18:30:43 -07:00
parent 31e08d730d
commit ac3643f76d
10 changed files with 934 additions and 1614 deletions
+22 -23
View File
@@ -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<Medication, '_id' | '_rev'>) => {
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<CustomReminder, '_id' | '_rev'>
) => {
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,
});
@@ -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 }) => (
<button
onClick={onClick}
disabled={disabled}
data-testid='mock-button'
className={`btn ${disabled ? 'btn-disabled' : 'btn-primary'}`}
>
{children}
</button>
);
const MockMedicationCard: React.FC<{
medication: {
id: string;
name: string;
dosage: string;
frequency: string;
};
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}> = ({ medication, onEdit, onDelete }) => (
<div data-testid='medication-card' className='medication-card'>
<h3 data-testid='medication-name'>{medication.name}</h3>
<p data-testid='medication-dosage'>Dosage: {medication.dosage}</p>
<p data-testid='medication-frequency'>Frequency: {medication.frequency}</p>
<div className='actions'>
<MockButton onClick={() => onEdit(medication.id)}>Edit</MockButton>
<MockButton onClick={() => onDelete(medication.id)}>Delete</MockButton>
</div>
</div>
);
describe('Component Testing Examples', () => {
describe('MockButton Component', () => {
test('renders button with correct text', () => {
const mockClick = jest.fn();
render(<MockButton onClick={mockClick}>Click Me</MockButton>);
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(<MockButton onClick={mockClick}>Click Me</MockButton>);
const button = screen.getByTestId('mock-button');
fireEvent.click(button);
expect(mockClick).toHaveBeenCalledTimes(1);
});
test('applies disabled state correctly', () => {
const mockClick = jest.fn();
render(
<MockButton onClick={mockClick} disabled>
Disabled Button
</MockButton>
);
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(
<MockButton onClick={mockClick}>Normal Button</MockButton>
);
let button = screen.getByTestId('mock-button');
expect(button).toHaveClass('btn', 'btn-primary');
expect(button).not.toHaveClass('btn-disabled');
rerender(
<MockButton onClick={mockClick} disabled>
Disabled Button
</MockButton>
);
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(<MockMedicationCard {...defaultProps} />);
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(<MockMedicationCard {...defaultProps} />);
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(<MockMedicationCard {...defaultProps} onEdit={mockOnEdit} />);
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(<MockMedicationCard {...defaultProps} onDelete={mockOnDelete} />);
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(
<MockMedicationCard
{...defaultProps}
medication={differentMedication}
/>
);
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(<MockMedicationCard {...defaultProps} />);
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 }) => (
<div data-testid='medication-list'>
{medications.map(med => (
<MockMedicationCard
key={med.id}
medication={med}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
const mockOnEdit = jest.fn();
const mockOnDelete = jest.fn();
render(
<MockMedicationList
medications={medications}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>
);
// 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(<MockButton onClick={mockClick}>Accessible Button</MockButton>);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAccessibleName('Accessible Button');
});
test('medication card structure supports screen readers', () => {
render(
<MockMedicationCard
{...{
medication: {
id: 'med-123',
name: 'Test Medicine',
dosage: '50mg',
frequency: 'As needed',
},
onEdit: jest.fn(),
onDelete: jest.fn(),
}}
/>
);
// 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(
<MockMedicationCard
medication={{
id: '',
name: '',
dosage: '',
frequency: '',
}}
onEdit={jest.fn()}
onDelete={jest.fn()}
/>
);
// Component should still render even with empty data
expect(screen.getByTestId('medication-card')).toBeInTheDocument();
consoleSpy.mockRestore();
});
});
});
+7 -7
View File
@@ -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<AdminInterfaceProps> = ({ 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<AdminInterfaceProps> = ({ 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<AdminInterfaceProps> = ({ 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<AdminInterfaceProps> = ({ 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<AdminInterfaceProps> = ({ onClose }) => {
}
try {
await dbService.changeUserPassword(userId, newPassword);
await databaseService.changeUserPassword(userId, newPassword);
setNewPassword('');
setSelectedUser(null);
setError('');
@@ -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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(
<AvatarDropdown user={mockUserWithAvatar} onLogout={mockOnLogout} />
);
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(
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('?');
});
test('should not render dropdown menu initially', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
});
describe('dropdown functionality', () => {
test('should open dropdown when avatar button is clicked', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(
<div>
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
<div data-testid='outside'>Outside element</div>
</div>
);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
test('should display administrator badge for admin users', () => {
render(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Administrator')).toBeInTheDocument();
});
test('should not display administrator badge for regular users', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(
<AvatarDropdown user={userWithLongName} onLogout={mockOnLogout} />
);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
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(
<AvatarDropdown
user={mockRegularUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
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(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
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(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
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(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
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(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
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(
<AvatarDropdown
user={mockRegularUser}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
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(
<AvatarDropdown user={mockUserWithPassword} onLogout={mockOnLogout} />
);
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(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
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(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
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(
<AvatarDropdown user={userWithLowercase} onLogout={mockOnLogout} />
);
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(
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('?');
});
test('should handle single character names', () => {
const userWithSingleChar = { ...mockRegularUser, username: 'x' };
render(
<AvatarDropdown user={userWithSingleChar} onLogout={mockOnLogout} />
);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('X');
});
test('should handle special characters', () => {
const userWithSpecialChar = { ...mockRegularUser, username: '@john' };
render(
<AvatarDropdown user={userWithSpecialChar} onLogout={mockOnLogout} />
);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('@');
});
});
describe('accessibility', () => {
test('should have proper aria-label for avatar button', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveAttribute('aria-label', 'User menu');
});
test('should be keyboard accessible', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
button.focus();
expect(button).toHaveFocus();
});
test('should have proper focus styles', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(
<div>
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
<div data-testid='outside'>Outside element</div>
</div>
);
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(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
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(
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
);
removeEventListenerSpy.mockRestore();
});
});
});
+4 -4
View File
@@ -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);
-596
View File
@@ -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<void> {
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<void> {
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<ParsedEnv> {
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<void> {
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<void> {
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<void> {
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 };
-15
View File
@@ -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'
);
-396
View File
@@ -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<void> {
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<void> {
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<string, unknown>
): Promise<Record<string, unknown>> {
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<T extends CouchDBDocument>(
dbName: string,
id: string
): Promise<T | null> {
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<T extends CouchDBDocument>(
dbName: string,
doc: Omit<T, '_rev'> & { _rev?: string }
): Promise<T> {
const response = await this.makeRequest(
'PUT',
`/${dbName}/${doc._id}`,
doc
);
return { ...doc, _rev: response.rev } as T;
}
private async query<T>(
dbName: string,
selector: Record<string, unknown>
): Promise<T[]> {
const response = await this.makeRequest('POST', `/${dbName}/_find`, {
selector,
limit: 1000,
});
return response.docs as T[];
}
// User Management Methods
async findUserByUsername(username: string): Promise<User | null> {
const users = await this.query<User>('users', { username });
return users[0] || null;
}
async findUserByEmail(email: string): Promise<User | null> {
const users = await this.query<User>('users', { email });
return users[0] || null;
}
async createUser(username: string): Promise<User> {
const existingUser = await this.findUserByUsername(username);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = { _id: uuidv4(), username };
return this.putDoc<User>('users', newUser);
}
async createUserWithPassword(
email: string,
password: string,
username?: string
): Promise<User> {
const existingUser = await this.findUserByEmail(email);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = {
_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<User>('users', newUser);
}
async createUserFromOAuth(userData: {
email: string;
username: string;
avatar?: string;
}): Promise<User> {
const existingUser = await this.findUserByEmail(userData.email);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = {
_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<User>('users', newUser);
}
async getUserById(id: string): Promise<User | null> {
return this.getDoc<User>('users', id);
}
async updateUser(user: User): Promise<User> {
return this.putDoc<User>('users', user);
}
async deleteUser(id: string): Promise<void> {
const user = await this.getDoc<User>('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<Medication[]> {
return this.query<Medication>('medications', { userId });
}
async createMedication(
medication: Omit<Medication, '_id' | '_rev'>
): Promise<Medication> {
const newMedication = { ...medication, _id: uuidv4() };
return this.putDoc<Medication>('medications', newMedication);
}
async updateMedication(medication: Medication): Promise<Medication> {
return this.putDoc<Medication>('medications', medication);
}
async deleteMedication(id: string): Promise<void> {
const medication = await this.getDoc<Medication>('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<UserSettings> {
const settings = await this.getDoc<UserSettings>('settings', userId);
if (!settings) {
const defaultSettings: Omit<UserSettings, '_rev'> = {
_id: userId,
notificationsEnabled: true,
hasCompletedOnboarding: false,
};
return this.putDoc<UserSettings>('settings', defaultSettings);
}
return settings;
}
async updateSettings(settings: UserSettings): Promise<UserSettings> {
return this.putDoc<UserSettings>('settings', settings);
}
// Taken Doses Methods
async getTakenDoses(userId: string): Promise<TakenDoses> {
const doses = await this.getDoc<TakenDoses>('taken_doses', userId);
if (!doses) {
const defaultDoses: Omit<TakenDoses, '_rev'> = {
_id: userId,
doses: {},
};
return this.putDoc<TakenDoses>('taken_doses', defaultDoses);
}
return doses;
}
async updateTakenDoses(takenDoses: TakenDoses): Promise<TakenDoses> {
return this.putDoc<TakenDoses>('taken_doses', takenDoses);
}
// Reminder Methods
async getReminders(userId: string): Promise<CustomReminder[]> {
return this.query<CustomReminder>('reminders', { userId });
}
async createReminder(
reminder: Omit<CustomReminder, '_id' | '_rev'>
): Promise<CustomReminder> {
const newReminder = { ...reminder, _id: uuidv4() };
return this.putDoc<CustomReminder>('reminders', newReminder);
}
async updateReminder(reminder: CustomReminder): Promise<CustomReminder> {
return this.putDoc<CustomReminder>('reminders', reminder);
}
async deleteReminder(id: string): Promise<void> {
const reminder = await this.getDoc<CustomReminder>('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<User[]> {
return this.query<User>('users', {});
}
async updateUserStatus(userId: string, status: AccountStatus): Promise<User> {
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<User> {
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<void> {
// 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);
}
}
-402
View File
@@ -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<T>(dbName: string): Promise<T[]> {
await latency();
const db = localStorage.getItem(dbName);
return db ? JSON.parse(db) : [];
}
private async saveDb<T>(dbName: string, data: T[]): Promise<void> {
await latency();
localStorage.setItem(dbName, JSON.stringify(data));
}
private async getDoc<T extends CouchDBDocument>(
dbName: string,
id: string
): Promise<T | null> {
const allDocs = await this.getDb<T>(dbName);
return allDocs.find(doc => doc._id === id) || null;
}
private async query<T>(
dbName: string,
predicate: (doc: T) => boolean
): Promise<T[]> {
const allDocs = await this.getDb<T>(dbName);
return allDocs.filter(predicate);
}
private async putDoc<T extends CouchDBDocument>(
dbName: string,
doc: Omit<T, '_rev'> & { _rev?: string }
): Promise<T> {
const allDocs = await this.getDb<T>(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<T extends CouchDBDocument>(
dbName: string,
doc: T
): Promise<void> {
let docs = await this.getDb<T>(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<T extends CouchDBDocument>(
dbName: string,
doc: T,
mergeFn?: (latest: T, incoming: T) => T
): Promise<T> {
try {
return await this.putDoc<T>(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<T>(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<T>(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<User | null> {
const users = await this.query<User>(
'users',
u => u.username.toLowerCase() === username.toLowerCase()
);
return users[0] || null;
}
async findUserByEmail(email: string): Promise<User | null> {
const users = await this.query<User>(
'users',
u => u.email?.toLowerCase() === email.toLowerCase()
);
return users[0] || null;
}
async createUser(username: string): Promise<User> {
if (await this.findUserByUsername(username)) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = { _id: uuidv4(), username };
return this.putDoc<User>('users', newUser);
}
async createUserWithPassword(
email: string,
password: string,
username?: string
): Promise<User> {
// 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<User, '_rev'> = {
_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<User>('users', newUser);
}
async createUserFromOAuth(userData: {
email: string;
username: string;
avatar?: string;
}): Promise<User> {
// 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<User, '_rev'> = {
_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<User>('users', newUser);
}
async updateUser(user: User): Promise<User> {
return this.updateDocWithConflictResolution<User>('users', user);
}
// --- Admin User Management ---
async getAllUsers(): Promise<User[]> {
return this.getDb<User>('users');
}
async getUserById(userId: string): Promise<User | null> {
return this.getDoc<User>('users', userId);
}
async suspendUser(userId: string): Promise<User> {
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<User> {
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<void> {
const user = await this.getUserById(userId);
if (!user) {
throw new CouchDBError('User not found', 404);
}
// Delete user data
await this.deleteDoc<User>('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<User> {
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<Medication[]> {
return this.getDb<Medication>(this.getUserDbName('meds', userId));
}
async addMedication(
userId: string,
med: Omit<Medication, '_id' | '_rev'>
): Promise<Medication> {
const newMed = { ...med, _id: uuidv4() };
return this.putDoc<Medication>(this.getUserDbName('meds', userId), newMed);
}
async updateMedication(userId: string, med: Medication): Promise<Medication> {
return this.updateDocWithConflictResolution<Medication>(
this.getUserDbName('meds', userId),
med
);
}
async deleteMedication(userId: string, med: Medication): Promise<void> {
return this.deleteDoc<Medication>(this.getUserDbName('meds', userId), med);
}
async getCustomReminders(userId: string): Promise<CustomReminder[]> {
return this.getDb<CustomReminder>(this.getUserDbName('reminders', userId));
}
async addCustomReminder(
userId: string,
reminder: Omit<CustomReminder, '_id' | '_rev'>
): Promise<CustomReminder> {
const newReminder = { ...reminder, _id: uuidv4() };
return this.putDoc<CustomReminder>(
this.getUserDbName('reminders', userId),
newReminder
);
}
async updateCustomReminder(
userId: string,
reminder: CustomReminder
): Promise<CustomReminder> {
return this.updateDocWithConflictResolution<CustomReminder>(
this.getUserDbName('reminders', userId),
reminder
);
}
async deleteCustomReminder(
userId: string,
reminder: CustomReminder
): Promise<void> {
return this.deleteDoc<CustomReminder>(
this.getUserDbName('reminders', userId),
reminder
);
}
async getSettings(userId: string): Promise<UserSettings> {
const dbName = this.getUserDbName('settings', userId);
let settings = await this.getDoc<UserSettings>(dbName, userId);
if (!settings) {
settings = await this.putDoc<UserSettings>(dbName, {
_id: userId,
notificationsEnabled: true,
hasCompletedOnboarding: false,
});
}
return settings;
}
async updateSettings(
userId: string,
settings: UserSettings
): Promise<UserSettings> {
return this.updateDocWithConflictResolution<UserSettings>(
this.getUserDbName('settings', userId),
settings
);
}
async getTakenDoses(userId: string): Promise<TakenDoses> {
const dbName = this.getUserDbName('taken', userId);
let takenDoses = await this.getDoc<TakenDoses>(dbName, userId);
if (!takenDoses) {
takenDoses = await this.putDoc<TakenDoses>(dbName, {
_id: userId,
doses: {},
});
}
return takenDoses;
}
async updateTakenDoses(
userId: string,
takenDoses: TakenDoses
): Promise<TakenDoses> {
// 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<TakenDoses>(
this.getUserDbName('taken', userId),
takenDoses,
mergeFn
);
}
async deleteAllUserData(userId: string): Promise<void> {
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();
@@ -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<any>;
@@ -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,69 +353,10 @@ describe('DatabaseService', () => {
service = new DatabaseService();
});
test('should support legacy getSettings method', async () => {
const settings = { _id: 'settings1', theme: 'dark' };
mockStrategyMethods.getUserSettings.mockResolvedValue(settings);
const result = await service.getSettings('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.getUserSettings).toHaveBeenCalledWith('user1');
expect(mockStrategyMethods.updateUserSettings).toHaveBeenCalledWith({
_id: 'settings1',
_rev: 'rev1',
notificationsEnabled: false,
hasCompletedOnboarding: false,
});
expect(result).toBe(updatedSettings);
});
describe('user management operations', () => {
test('should support suspendUser method', async () => {
const user = { _id: 'user1', _rev: 'rev1', status: AccountStatus.ACTIVE };
const suspendedUser = { ...user, status: AccountStatus.SUSPENDED };
const user = createMockUser();
const suspendedUser = { ...user, status: 'SUSPENDED' as any };
mockStrategyMethods.getUserById.mockResolvedValue(user);
mockStrategyMethods.updateUser.mockResolvedValue(suspendedUser);
@@ -453,48 +365,49 @@ describe('DatabaseService', () => {
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
...user,
status: AccountStatus.SUSPENDED,
status: 'SUSPENDED',
});
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 };
const user = { ...createMockUser(), status: 'SUSPENDED' as any };
const activatedUser = { ...user, status: 'ACTIVE' as any };
mockStrategyMethods.getUserById.mockResolvedValue(user);
mockStrategyMethods.updateUser.mockResolvedValue(activeUser);
mockStrategyMethods.updateUser.mockResolvedValue(activatedUser);
const result = await service.activateUser('user1');
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
...user,
status: AccountStatus.ACTIVE,
status: 'ACTIVE',
});
expect(result).toBe(activeUser);
expect(result).toBe(activatedUser);
});
test('should support changeUserPassword method', async () => {
const user = { _id: 'user1', _rev: 'rev1', password: 'oldpass' };
const updatedUser = { ...user, password: 'newpass' };
const user = createMockUser();
const updatedUser = { ...user, password: 'newPassword' };
mockStrategyMethods.getUserById.mockResolvedValue(user);
mockStrategyMethods.updateUser.mockResolvedValue(updatedUser);
const result = await service.changeUserPassword('user1', 'newpass');
const result = await service.changeUserPassword('user1', 'newPassword');
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
...user,
password: 'newpass',
password: 'newPassword',
});
expect(result).toBe(updatedUser);
});
test('should support deleteAllUserData method', async () => {
const medications = [{ _id: 'med1', _rev: 'rev1' }];
const reminders = [{ _id: 'rem1', _rev: 'rev1' }];
const medications = [
{ _id: 'med1', _rev: 'rev1', name: 'Aspirin' },
{ _id: 'med2', _rev: 'rev2', name: 'Vitamin' },
];
const reminders = [{ _id: 'rem1', _rev: 'rev1', name: 'Doctor Visit' }];
mockStrategyMethods.getMedications.mockResolvedValue(medications);
mockStrategyMethods.getCustomReminders.mockResolvedValue(reminders);
@@ -504,11 +417,18 @@ describe('DatabaseService', () => {
const result = await service.deleteAllUserData('user1');
expect(mockStrategyMethods.getMedications).toHaveBeenCalledWith('user1');
expect(mockStrategyMethods.getMedications).toHaveBeenCalledWith(
'user1'
);
expect(mockStrategyMethods.getCustomReminders).toHaveBeenCalledWith(
'user1'
);
expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith('med1');
expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith(
'med1'
);
expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith(
'med2'
);
expect(mockStrategyMethods.deleteCustomReminder).toHaveBeenCalledWith(
'rem1'
);
@@ -536,8 +456,9 @@ describe('DatabaseService', () => {
mockStrategyMethods.getUserById.mockResolvedValue(null);
await expect(
service.changeUserPassword('user1', 'newpass')
service.changeUserPassword('user1', 'newPassword')
).rejects.toThrow('User not found');
});
});
});
});