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:
@@ -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,162 +353,112 @@ describe('DatabaseService', () => {
|
||||
service = new DatabaseService();
|
||||
});
|
||||
|
||||
test('should support legacy getSettings method', async () => {
|
||||
const settings = { _id: 'settings1', theme: 'dark' };
|
||||
mockStrategyMethods.getUserSettings.mockResolvedValue(settings);
|
||||
describe('user management operations', () => {
|
||||
test('should support suspendUser method', async () => {
|
||||
const user = createMockUser();
|
||||
const suspendedUser = { ...user, status: 'SUSPENDED' as any };
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(user);
|
||||
mockStrategyMethods.updateUser.mockResolvedValue(suspendedUser);
|
||||
|
||||
const result = await service.getSettings('user1');
|
||||
const result = await service.suspendUser('user1');
|
||||
|
||||
expect(mockStrategyMethods.getUserSettings).toHaveBeenCalledWith('user1');
|
||||
expect(result).toBe(settings);
|
||||
});
|
||||
|
||||
test('should support legacy addMedication method', async () => {
|
||||
const medicationInput = {
|
||||
name: 'Aspirin',
|
||||
dosage: '100mg',
|
||||
frequency: 'Daily' as any,
|
||||
startTime: '08:00',
|
||||
notes: '',
|
||||
};
|
||||
const medication = { _id: 'med1', _rev: 'rev1', ...medicationInput };
|
||||
mockStrategyMethods.createMedication.mockResolvedValue(medication);
|
||||
|
||||
const result = await service.addMedication('user1', medicationInput);
|
||||
|
||||
expect(mockStrategyMethods.createMedication).toHaveBeenCalledWith(
|
||||
'user1',
|
||||
medicationInput
|
||||
);
|
||||
expect(result).toBe(medication);
|
||||
});
|
||||
|
||||
test('should support legacy updateSettings method', async () => {
|
||||
const currentSettings = {
|
||||
_id: 'settings1',
|
||||
_rev: 'rev1',
|
||||
notificationsEnabled: true,
|
||||
hasCompletedOnboarding: false,
|
||||
};
|
||||
const updatedSettings = {
|
||||
_id: 'settings1',
|
||||
_rev: 'rev2',
|
||||
notificationsEnabled: false,
|
||||
hasCompletedOnboarding: false,
|
||||
};
|
||||
mockStrategyMethods.getUserSettings.mockResolvedValue(currentSettings);
|
||||
mockStrategyMethods.updateUserSettings.mockResolvedValue(updatedSettings);
|
||||
|
||||
const result = await service.updateSettings('user1', {
|
||||
notificationsEnabled: false,
|
||||
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
|
||||
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
|
||||
...user,
|
||||
status: 'SUSPENDED',
|
||||
});
|
||||
expect(result).toBe(suspendedUser);
|
||||
});
|
||||
|
||||
expect(mockStrategyMethods.getUserSettings).toHaveBeenCalledWith('user1');
|
||||
expect(mockStrategyMethods.updateUserSettings).toHaveBeenCalledWith({
|
||||
_id: 'settings1',
|
||||
_rev: 'rev1',
|
||||
notificationsEnabled: false,
|
||||
hasCompletedOnboarding: false,
|
||||
test('should support activateUser method', async () => {
|
||||
const user = { ...createMockUser(), status: 'SUSPENDED' as any };
|
||||
const activatedUser = { ...user, status: 'ACTIVE' as any };
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(user);
|
||||
mockStrategyMethods.updateUser.mockResolvedValue(activatedUser);
|
||||
|
||||
const result = await service.activateUser('user1');
|
||||
|
||||
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
|
||||
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
|
||||
...user,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
expect(result).toBe(activatedUser);
|
||||
});
|
||||
expect(result).toBe(updatedSettings);
|
||||
});
|
||||
|
||||
test('should support suspendUser method', async () => {
|
||||
const user = { _id: 'user1', _rev: 'rev1', status: AccountStatus.ACTIVE };
|
||||
const suspendedUser = { ...user, status: AccountStatus.SUSPENDED };
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(user);
|
||||
mockStrategyMethods.updateUser.mockResolvedValue(suspendedUser);
|
||||
test('should support changeUserPassword method', async () => {
|
||||
const user = createMockUser();
|
||||
const updatedUser = { ...user, password: 'newPassword' };
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(user);
|
||||
mockStrategyMethods.updateUser.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.suspendUser('user1');
|
||||
const result = await service.changeUserPassword('user1', 'newPassword');
|
||||
|
||||
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
|
||||
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
|
||||
...user,
|
||||
status: AccountStatus.SUSPENDED,
|
||||
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
|
||||
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
|
||||
...user,
|
||||
password: 'newPassword',
|
||||
});
|
||||
expect(result).toBe(updatedUser);
|
||||
});
|
||||
expect(result).toBe(suspendedUser);
|
||||
});
|
||||
|
||||
test('should support activateUser method', async () => {
|
||||
const user = {
|
||||
_id: 'user1',
|
||||
_rev: 'rev1',
|
||||
status: AccountStatus.SUSPENDED,
|
||||
};
|
||||
const activeUser = { ...user, status: AccountStatus.ACTIVE };
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(user);
|
||||
mockStrategyMethods.updateUser.mockResolvedValue(activeUser);
|
||||
test('should support deleteAllUserData method', async () => {
|
||||
const medications = [
|
||||
{ _id: 'med1', _rev: 'rev1', name: 'Aspirin' },
|
||||
{ _id: 'med2', _rev: 'rev2', name: 'Vitamin' },
|
||||
];
|
||||
const reminders = [{ _id: 'rem1', _rev: 'rev1', name: 'Doctor Visit' }];
|
||||
|
||||
const result = await service.activateUser('user1');
|
||||
mockStrategyMethods.getMedications.mockResolvedValue(medications);
|
||||
mockStrategyMethods.getCustomReminders.mockResolvedValue(reminders);
|
||||
mockStrategyMethods.deleteMedication.mockResolvedValue(true);
|
||||
mockStrategyMethods.deleteCustomReminder.mockResolvedValue(true);
|
||||
mockStrategyMethods.deleteUser.mockResolvedValue(true);
|
||||
|
||||
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
|
||||
...user,
|
||||
status: AccountStatus.ACTIVE,
|
||||
const result = await service.deleteAllUserData('user1');
|
||||
|
||||
expect(mockStrategyMethods.getMedications).toHaveBeenCalledWith(
|
||||
'user1'
|
||||
);
|
||||
expect(mockStrategyMethods.getCustomReminders).toHaveBeenCalledWith(
|
||||
'user1'
|
||||
);
|
||||
expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith(
|
||||
'med1'
|
||||
);
|
||||
expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith(
|
||||
'med2'
|
||||
);
|
||||
expect(mockStrategyMethods.deleteCustomReminder).toHaveBeenCalledWith(
|
||||
'rem1'
|
||||
);
|
||||
expect(mockStrategyMethods.deleteUser).toHaveBeenCalledWith('user1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
expect(result).toBe(activeUser);
|
||||
});
|
||||
|
||||
test('should support changeUserPassword method', async () => {
|
||||
const user = { _id: 'user1', _rev: 'rev1', password: 'oldpass' };
|
||||
const updatedUser = { ...user, password: 'newpass' };
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(user);
|
||||
mockStrategyMethods.updateUser.mockResolvedValue(updatedUser);
|
||||
test('should throw error when user not found in suspendUser', async () => {
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(null);
|
||||
|
||||
const result = await service.changeUserPassword('user1', 'newpass');
|
||||
|
||||
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
|
||||
...user,
|
||||
password: 'newpass',
|
||||
await expect(service.suspendUser('user1')).rejects.toThrow(
|
||||
'User not found'
|
||||
);
|
||||
});
|
||||
expect(result).toBe(updatedUser);
|
||||
});
|
||||
|
||||
test('should support deleteAllUserData method', async () => {
|
||||
const medications = [{ _id: 'med1', _rev: 'rev1' }];
|
||||
const reminders = [{ _id: 'rem1', _rev: 'rev1' }];
|
||||
test('should throw error when user not found in activateUser', async () => {
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(null);
|
||||
|
||||
mockStrategyMethods.getMedications.mockResolvedValue(medications);
|
||||
mockStrategyMethods.getCustomReminders.mockResolvedValue(reminders);
|
||||
mockStrategyMethods.deleteMedication.mockResolvedValue(true);
|
||||
mockStrategyMethods.deleteCustomReminder.mockResolvedValue(true);
|
||||
mockStrategyMethods.deleteUser.mockResolvedValue(true);
|
||||
await expect(service.activateUser('user1')).rejects.toThrow(
|
||||
'User not found'
|
||||
);
|
||||
});
|
||||
|
||||
const result = await service.deleteAllUserData('user1');
|
||||
test('should throw error when user not found in changeUserPassword', async () => {
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(null);
|
||||
|
||||
expect(mockStrategyMethods.getMedications).toHaveBeenCalledWith('user1');
|
||||
expect(mockStrategyMethods.getCustomReminders).toHaveBeenCalledWith(
|
||||
'user1'
|
||||
);
|
||||
expect(mockStrategyMethods.deleteMedication).toHaveBeenCalledWith('med1');
|
||||
expect(mockStrategyMethods.deleteCustomReminder).toHaveBeenCalledWith(
|
||||
'rem1'
|
||||
);
|
||||
expect(mockStrategyMethods.deleteUser).toHaveBeenCalledWith('user1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should throw error when user not found in suspendUser', async () => {
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.suspendUser('user1')).rejects.toThrow(
|
||||
'User not found'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when user not found in activateUser', async () => {
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.activateUser('user1')).rejects.toThrow(
|
||||
'User not found'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when user not found in changeUserPassword', async () => {
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.changeUserPassword('user1', 'newpass')
|
||||
).rejects.toThrow('User not found');
|
||||
await expect(
|
||||
service.changeUserPassword('user1', 'newPassword')
|
||||
).rejects.toThrow('User not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user