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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user