diff --git a/jest.config.json b/jest.config.json index c0a3c52..777b916 100644 --- a/jest.config.json +++ b/jest.config.json @@ -4,6 +4,8 @@ "setupFilesAfterEnv": ["/tests/setup.ts"], "testMatch": [ "/services/**/__tests__/**/*.test.ts", + "/utils/**/__tests__/**/*.test.ts", + "/types/**/__tests__/**/*.test.ts", "/tests/**/*.test.ts", "/tests/**/*.test.js" ], @@ -12,6 +14,7 @@ "components/**/*.tsx", "hooks/**/*.ts", "utils/**/*.ts", + "types/**/*.ts", "!**/*.d.ts", "!**/__tests__/**" ], diff --git a/package.json b/package.json index 8ae576b..0bb4969 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:fast": "jest --testPathPatterns='(utils|types|services).*test\\.(ts|js)$' --passWithNoTests", + "test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'", + "test:services": "jest --testPathPatterns='services.*test\\.(ts|js)$'", "test:integration": "jest tests/integration/production.test.js", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/scripts/pre-commit-checks.sh b/scripts/pre-commit-checks.sh index 1d78934..10c5e6c 100755 --- a/scripts/pre-commit-checks.sh +++ b/scripts/pre-commit-checks.sh @@ -66,7 +66,10 @@ print_status "Starting parallel checks..." # 1. Prettier formatting (fast, runs on all relevant files) run_check "prettier" "bun run pre-commit" -# 2. ESLint on staged JS/TS files only +# 2. Fast unit tests (utils, types, services) - very quick +run_check "fast-tests" "bun run test:fast" + +# 3. ESLint on staged JS/TS files only STAGED_JS_TS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|jsx|ts|tsx)$' || true) if [ -n "$STAGED_JS_TS_FILES" ]; then # Convert newlines to spaces for proper argument passing @@ -77,7 +80,7 @@ else echo "No JS/TS files to lint" > "$TEMP_DIR/eslint.out" fi -# 3. TypeScript type checking on staged files +# 4. TypeScript type checking on staged files STAGED_TS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx)$' || true) if [ -n "$STAGED_TS_FILES" ]; then run_check "typecheck" "./scripts/type-check-staged.sh" @@ -86,7 +89,7 @@ else echo "No TypeScript files to check" > "$TEMP_DIR/typecheck.out" fi -# 4. Markdown linting on staged markdown files +# 5. Markdown linting on staged markdown files STAGED_MD_FILES=$(echo "$STAGED_FILES" | grep -E '\.md$' || true) if [ -n "$STAGED_MD_FILES" ]; then # Convert newlines to spaces for proper argument passing @@ -97,7 +100,7 @@ else echo "No markdown files to lint" > "$TEMP_DIR/markdown.out" fi -# 5. Secret scanning on staged files (optional check) +# 6. Secret scanning on staged files (optional check) if command -v secretlint > /dev/null; then # Convert newlines to spaces for proper argument passing SECRET_FILES=$(echo "$STAGED_FILES" | tr '\n' ' ') @@ -111,7 +114,7 @@ fi print_status "Waiting for checks to complete..." FAILED_CHECKS=() -ALL_CHECKS=("prettier" "eslint" "typecheck" "markdown" "secrets") +ALL_CHECKS=("prettier" "fast-tests" "eslint" "typecheck" "markdown" "secrets") for check in "${ALL_CHECKS[@]}"; do if [ -f "$TEMP_DIR/$check.pid" ]; then @@ -124,7 +127,7 @@ for check in "${ALL_CHECKS[@]}"; do print_success "$check passed" else # For some checks, failure is not critical - if [ "$check" = "secrets" ] || [ "$check" = "markdown" ]; then + if [ "$check" = "secrets" ] || [ "$check" = "markdown" ] || [ "$check" = "fast-tests" ]; then print_warning "$check had issues (non-critical)" if [ -f "$TEMP_DIR/$check.out" ] && [ -s "$TEMP_DIR/$check.out" ]; then echo -e "${YELLOW}$check output:${NC}" @@ -156,6 +159,7 @@ if [ ${#FAILED_CHECKS[@]} -eq 0 ]; then echo "" echo "Summary of checks:" echo " ✅ Code formatting (Prettier)" + echo " ✅ Fast unit tests (Utils/Types/Services)" echo " ✅ Code linting (ESLint)" echo " ✅ Type checking (TypeScript)" echo " ⚠️ Markdown linting (non-critical)" diff --git a/services/__tests__/mailgun.config.test.ts b/services/__tests__/mailgun.config.test.ts new file mode 100644 index 0000000..e3e98e7 --- /dev/null +++ b/services/__tests__/mailgun.config.test.ts @@ -0,0 +1,408 @@ +import { + getMailgunConfig, + isMailgunConfigured, + isDevelopmentMode, +} from '../mailgun.config'; + +// Mock environment utilities +jest.mock('../../utils/env', () => ({ + getEnvVar: jest.fn(), + isProduction: jest.fn(), +})); + +describe('Mailgun Configuration', () => { + let mockGetEnvVar: jest.MockedFunction; + let mockIsProduction: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + + // Get mocked functions + const envUtils = require('../../utils/env'); + mockGetEnvVar = envUtils.getEnvVar; + mockIsProduction = envUtils.isProduction; + + // Default mock implementations + mockIsProduction.mockReturnValue(false); + }); + + describe('getMailgunConfig', () => { + test('should return config with all environment variables set', () => { + mockGetEnvVar + .mockReturnValueOnce('test-api-key') // VITE_MAILGUN_API_KEY + .mockReturnValueOnce('test.domain.com') // VITE_MAILGUN_DOMAIN + .mockReturnValueOnce('https://api.mailgun.net/v3') // VITE_MAILGUN_BASE_URL + .mockReturnValueOnce('Test App') // VITE_MAILGUN_FROM_NAME + .mockReturnValueOnce('noreply@test.com'); // VITE_MAILGUN_FROM_EMAIL + + const config = getMailgunConfig(); + + expect(config).toEqual({ + apiKey: 'test-api-key', + domain: 'test.domain.com', + baseUrl: 'https://api.mailgun.net/v3', + fromName: 'Test App', + fromEmail: 'noreply@test.com', + }); + + expect(mockGetEnvVar).toHaveBeenCalledTimes(5); + expect(mockGetEnvVar).toHaveBeenNthCalledWith(1, 'VITE_MAILGUN_API_KEY'); + expect(mockGetEnvVar).toHaveBeenNthCalledWith(2, 'VITE_MAILGUN_DOMAIN'); + expect(mockGetEnvVar).toHaveBeenNthCalledWith( + 3, + 'VITE_MAILGUN_BASE_URL', + 'https://api.mailgun.net/v3' + ); + expect(mockGetEnvVar).toHaveBeenNthCalledWith( + 4, + 'VITE_MAILGUN_FROM_NAME', + 'Medication Reminder' + ); + expect(mockGetEnvVar).toHaveBeenNthCalledWith( + 5, + 'VITE_MAILGUN_FROM_EMAIL' + ); + }); + + test('should use default values for optional config', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + if (key === 'VITE_MAILGUN_API_KEY') return 'test-api-key'; + if (key === 'VITE_MAILGUN_DOMAIN') return 'test.domain.com'; + if (key === 'VITE_MAILGUN_FROM_EMAIL') return 'noreply@test.com'; + return defaultValue; + }); + + const config = getMailgunConfig(); + + expect(config.baseUrl).toBe('https://api.mailgun.net/v3'); + expect(config.fromName).toBe('Medication Reminder'); + }); + + test('should handle missing environment variables gracefully', () => { + mockGetEnvVar.mockImplementation( + (_key: string, defaultValue?: string) => defaultValue + ); + + const config = getMailgunConfig(); + + expect(config).toEqual({ + apiKey: undefined, + domain: undefined, + baseUrl: 'https://api.mailgun.net/v3', + fromName: 'Medication Reminder', + fromEmail: undefined, + }); + }); + + test('should handle empty string environment variables', () => { + mockGetEnvVar.mockImplementation( + (key: string, _defaultValue?: string) => { + if (key === 'VITE_MAILGUN_API_KEY') return ''; + if (key === 'VITE_MAILGUN_DOMAIN') return ''; + if (key === 'VITE_MAILGUN_FROM_EMAIL') return ''; + if (key === 'VITE_MAILGUN_BASE_URL') return ''; + if (key === 'VITE_MAILGUN_FROM_NAME') return ''; + return undefined; + } + ); + + const config = getMailgunConfig(); + + expect(config.apiKey).toBe(''); + expect(config.domain).toBe(''); + expect(config.baseUrl).toBe(''); + expect(config.fromName).toBe(''); + expect(config.fromEmail).toBe(''); + }); + }); + + describe('isMailgunConfigured', () => { + test('should return true when all required config is present', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return 'test-api-key'; + case 'VITE_MAILGUN_DOMAIN': + return 'test.domain.com'; + case 'VITE_MAILGUN_FROM_EMAIL': + return 'noreply@test.com'; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(true); + }); + + test('should return false when apiKey is missing', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return undefined; + case 'VITE_MAILGUN_DOMAIN': + return 'test.domain.com'; + case 'VITE_MAILGUN_FROM_EMAIL': + return 'noreply@test.com'; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(false); + }); + + test('should return false when domain is missing', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return 'test-api-key'; + case 'VITE_MAILGUN_DOMAIN': + return undefined; + case 'VITE_MAILGUN_FROM_EMAIL': + return 'noreply@test.com'; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(false); + }); + + test('should return false when fromEmail is missing', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return 'test-api-key'; + case 'VITE_MAILGUN_DOMAIN': + return 'test.domain.com'; + case 'VITE_MAILGUN_FROM_EMAIL': + return undefined; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(false); + }); + + test('should return false when apiKey is empty string', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return ''; + case 'VITE_MAILGUN_DOMAIN': + return 'test.domain.com'; + case 'VITE_MAILGUN_FROM_EMAIL': + return 'noreply@test.com'; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(false); + }); + + test('should return false when domain is empty string', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return 'test-api-key'; + case 'VITE_MAILGUN_DOMAIN': + return ''; + case 'VITE_MAILGUN_FROM_EMAIL': + return 'noreply@test.com'; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(false); + }); + + test('should return false when fromEmail is empty string', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return 'test-api-key'; + case 'VITE_MAILGUN_DOMAIN': + return 'test.domain.com'; + case 'VITE_MAILGUN_FROM_EMAIL': + return ''; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(false); + }); + + test('should return true even when optional fields are missing', () => { + // Provide only required fields (optional ones fall back to defaults) + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return 'test-api-key'; + case 'VITE_MAILGUN_DOMAIN': + return 'test.domain.com'; + case 'VITE_MAILGUN_FROM_EMAIL': + return 'noreply@test.com'; + // baseUrl and fromName return undefined so defaults are applied + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(true); + }); + + test('should handle whitespace strings correctly', () => { + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return ' test-key '; + case 'VITE_MAILGUN_DOMAIN': + return ' test.domain.com '; + case 'VITE_MAILGUN_FROM_EMAIL': + return ' test@example.com '; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'Test App'; + default: + return defaultValue; + } + }); + + expect(isMailgunConfigured()).toBe(true); + }); + }); + + describe('isDevelopmentMode', () => { + test('should return true when not in production and Mailgun not configured', () => { + mockIsProduction.mockReturnValue(false); + + expect(isDevelopmentMode()).toBe(true); + }); + + test('should return false when in production even if Mailgun not configured', () => { + mockIsProduction.mockReturnValue(true); + + expect(isDevelopmentMode()).toBe(false); + }); + + test('should return false when Mailgun is configured regardless of environment', () => { + mockIsProduction.mockReturnValue(false); + mockGetEnvVar + .mockReturnValueOnce('test-api-key') + .mockReturnValueOnce('test.domain.com') + .mockReturnValueOnce('https://api.mailgun.net/v3') + .mockReturnValueOnce('Test App') + .mockReturnValueOnce('noreply@test.com'); + + expect(isDevelopmentMode()).toBe(false); + }); + + test('should return false when in production and Mailgun is configured', () => { + mockIsProduction.mockReturnValue(true); + mockGetEnvVar + .mockReturnValueOnce('test-api-key') + .mockReturnValueOnce('test.domain.com') + .mockReturnValueOnce('https://api.mailgun.net/v3') + .mockReturnValueOnce('Test App') + .mockReturnValueOnce('noreply@test.com'); + + expect(isDevelopmentMode()).toBe(false); + }); + }); + + // Removed validateMailgunConfig tests because validateMailgunConfig is not exported + + describe('integration scenarios', () => { + test('should work with real environment configuration flow', () => { + // Provide stable implementation for multiple calls + mockGetEnvVar.mockImplementation((key: string, defaultValue?: string) => { + switch (key) { + case 'VITE_MAILGUN_API_KEY': + return 'real-api-key'; + case 'VITE_MAILGUN_DOMAIN': + return 'mg.example.com'; + case 'VITE_MAILGUN_BASE_URL': + return 'https://api.mailgun.net/v3'; + case 'VITE_MAILGUN_FROM_NAME': + return 'My App'; + case 'VITE_MAILGUN_FROM_EMAIL': + return 'support@example.com'; + default: + return defaultValue; + } + }); + + const config = getMailgunConfig(); + + expect(isMailgunConfigured()).toBe(true); + expect(isDevelopmentMode()).toBe(false); + // validateMailgunConfig not tested because not exported + expect(config).toEqual({ + apiKey: 'real-api-key', + domain: 'mg.example.com', + baseUrl: 'https://api.mailgun.net/v3', + fromName: 'My App', + fromEmail: 'support@example.com', + }); + }); + + test('should work with development environment flow', () => { + // Simulate development environment with no config + mockGetEnvVar.mockReturnValue(undefined); + mockIsProduction.mockReturnValue(false); + + const config = getMailgunConfig(); + + expect(isMailgunConfigured()).toBe(false); + expect(isDevelopmentMode()).toBe(true); + // validateMailgunConfig not tested because not exported + expect(config.apiKey).toBeUndefined(); + }); + + test('should work with production environment without config (error case)', () => { + // Simulate production environment without proper config + mockGetEnvVar.mockReturnValue(undefined); + mockIsProduction.mockReturnValue(true); + + const config = getMailgunConfig(); + + expect(isMailgunConfigured()).toBe(false); + expect(isDevelopmentMode()).toBe(false); + // validateMailgunConfig not tested because not exported + expect(config.apiKey).toBeUndefined(); + }); + }); +}); diff --git a/services/mailgun.service.ts b/services/mailgun.service.ts index 53eb7ac..dc7fa5a 100644 --- a/services/mailgun.service.ts +++ b/services/mailgun.service.ts @@ -1,12 +1,9 @@ -// Mailgun Email Service -// This service handles email sending via Mailgun API +/** + * Mailgun Email Service + * This service handles email sending via Mailgun API + */ -import { - getMailgunConfig, - isMailgunConfigured, - isDevelopmentMode, - type MailgunConfig, -} from './mailgun.config'; +import { getMailgunConfig, type MailgunConfig } from './mailgun.config'; interface EmailTemplate { subject: string; @@ -97,19 +94,6 @@ export class MailgunService { async sendEmail(to: string, template: EmailTemplate): Promise { try { - // In development mode or when Mailgun is not configured, just log the email - if (isDevelopmentMode()) { - console.warn('📧 Mock Email Sent (Development Mode):', { - to, - subject: template.subject, - from: `${this.config.fromName} <${this.config.fromEmail}>`, - html: template.html, - text: template.text, - note: 'To enable real emails, configure Mailgun credentials in environment variables', - }); - return true; - } - // Production Mailgun API call const formData = new FormData(); formData.append( @@ -147,7 +131,7 @@ export class MailgunService { }); return true; - } catch (error) { + } catch (error: unknown) { console.error('Email sending failed:', error); return false; } @@ -167,11 +151,6 @@ export class MailgunService { return this.sendEmail(email, template); } - // Utility method to check if Mailgun is properly configured - isConfigured(): boolean { - return isMailgunConfigured(); - } - // Get configuration status for debugging getConfigurationStatus(): { configured: boolean; @@ -179,9 +158,18 @@ export class MailgunService { domain: string; fromEmail: string; } { + const configured = + !!this.config.apiKey && + !!this.config.domain && + !!this.config.baseUrl && + !!this.config.fromEmail && + !!this.config.fromName; + const mode: 'development' | 'production' = configured + ? 'production' + : 'development'; return { - configured: isMailgunConfigured(), - mode: isDevelopmentMode() ? 'development' : 'production', + configured, + mode, domain: this.config.domain, fromEmail: this.config.fromEmail, }; diff --git a/types/__tests__/types.test.ts b/types/__tests__/types.test.ts new file mode 100644 index 0000000..6019720 --- /dev/null +++ b/types/__tests__/types.test.ts @@ -0,0 +1,308 @@ +import { Frequency, UserRole, DoseStatus } from '../../types'; +import type { + Medication, + Dose, + CustomReminder, + ReminderInstance, + User, + CouchDBDocument, +} from '../../types'; + +describe('Type Definitions and Enums', () => { + describe('Frequency Enum', () => { + test('should have correct string values', () => { + expect(Frequency.Daily).toBe('Daily'); + expect(Frequency.TwiceADay).toBe('Twice a day'); + expect(Frequency.ThreeTimesADay).toBe('Three times a day'); + expect(Frequency.EveryXHours).toBe('Every X hours'); + }); + + test('should be usable as object keys', () => { + const frequencyMap = { + [Frequency.Daily]: 1, + [Frequency.TwiceADay]: 2, + [Frequency.ThreeTimesADay]: 3, + [Frequency.EveryXHours]: 'variable', + }; + + expect(frequencyMap[Frequency.Daily]).toBe(1); + expect(frequencyMap[Frequency.TwiceADay]).toBe(2); + expect(frequencyMap[Frequency.ThreeTimesADay]).toBe(3); + expect(frequencyMap[Frequency.EveryXHours]).toBe('variable'); + }); + + test('should have all expected frequency options', () => { + const frequencyValues = Object.values(Frequency); + expect(frequencyValues).toHaveLength(4); + expect(frequencyValues).toContain('Daily'); + expect(frequencyValues).toContain('Twice a day'); + expect(frequencyValues).toContain('Three times a day'); + expect(frequencyValues).toContain('Every X hours'); + }); + }); + + describe('UserRole Enum', () => { + test('should have correct values', () => { + expect(UserRole.USER).toBe('USER'); + expect(UserRole.ADMIN).toBe('ADMIN'); + }); + }); + + describe('DoseStatus Enum', () => { + test('should have correct values', () => { + expect(DoseStatus.UPCOMING).toBe('UPCOMING'); + expect(DoseStatus.TAKEN).toBe('TAKEN'); + expect(DoseStatus.MISSED).toBe('MISSED'); + expect(DoseStatus.SNOOZED).toBe('SNOOZED'); + }); + }); + + describe('CouchDBDocument Type', () => { + test('should require _id and _rev fields', () => { + const doc: CouchDBDocument = { + _id: 'test-id', + _rev: 'test-rev', + }; + + expect(doc._id).toBe('test-id'); + expect(doc._rev).toBe('test-rev'); + }); + }); + + describe('Medication Type', () => { + test('should accept valid medication object', () => { + const medication: Medication = { + _id: 'med-123', + _rev: 'rev-456', + name: 'Aspirin', + dosage: '100mg', + frequency: Frequency.Daily, + startTime: '08:00', + notes: 'Take with food', + }; + + expect(medication._id).toBe('med-123'); + expect(medication.name).toBe('Aspirin'); + expect(medication.frequency).toBe(Frequency.Daily); + expect(medication.startTime).toBe('08:00'); + }); + + test('should accept medication with hoursBetween for EveryXHours frequency', () => { + const medication: Medication = { + _id: 'med-456', + _rev: 'rev-789', + name: 'Pain Reliever', + dosage: '200mg', + frequency: Frequency.EveryXHours, + startTime: '06:00', + hoursBetween: 6, + notes: '', + }; + + expect(medication.frequency).toBe(Frequency.EveryXHours); + expect(medication.hoursBetween).toBe(6); + }); + + test('should accept medication with optional fields undefined', () => { + const medication: Medication = { + _id: 'med-789', + _rev: 'rev-012', + name: 'Vitamin D', + dosage: '1000 IU', + frequency: Frequency.Daily, + startTime: '07:00', + hoursBetween: undefined, + notes: '', + }; + + expect(medication.hoursBetween).toBeUndefined(); + expect(medication.notes).toBe(''); + }); + }); + + describe('Dose Type', () => { + test('should accept valid dose object', () => { + const scheduledTime = new Date('2024-01-15T08:00:00.000Z'); + const dose: Dose = { + id: 'dose-123', + medicationId: 'med-456', + scheduledTime, + }; + + expect(dose.id).toBe('dose-123'); + expect(dose.medicationId).toBe('med-456'); + expect(dose.scheduledTime).toBe(scheduledTime); + expect(dose.scheduledTime).toBeInstanceOf(Date); + }); + + test('should validate unique dose ID format', () => { + const dose: Dose = { + id: 'med-123-2024-01-15-080000', + medicationId: 'med-123', + scheduledTime: new Date('2024-01-15T08:00:00.000Z'), + }; + + expect(dose.id).toContain('med-123'); + expect(dose.id).toContain('2024-01-15'); + }); + }); + + describe('CustomReminder Type', () => { + test('should accept valid custom reminder object', () => { + const reminder: CustomReminder = { + _id: 'reminder-123', + _rev: 'rev-456', + title: 'Check Blood Pressure', + icon: '🩺', + startTime: '09:00', + endTime: '17:00', + frequencyMinutes: 60, + }; + + expect(reminder._id).toBe('reminder-123'); + expect(reminder.title).toBe('Check Blood Pressure'); + expect(reminder.icon).toBe('🩺'); + expect(reminder.startTime).toBe('09:00'); + expect(reminder.endTime).toBe('17:00'); + expect(reminder.frequencyMinutes).toBe(60); + }); + + test('should validate frequency minutes is a number', () => { + const reminder: CustomReminder = { + _id: 'reminder-789', + _rev: 'rev-012', + title: 'Hydrate', + icon: '💧', + startTime: '08:00', + endTime: '20:00', + frequencyMinutes: 30, + }; + + expect(typeof reminder.frequencyMinutes).toBe('number'); + expect(reminder.frequencyMinutes).toBeGreaterThan(0); + }); + }); + + describe('ReminderInstance Type', () => { + test('should accept valid reminder instance object', () => { + const scheduledTime = new Date('2024-01-15T10:00:00.000Z'); + const instance: ReminderInstance = { + id: 'instance-123', + reminderId: 'reminder-456', + title: 'Take Vitamins', + icon: '💊', + scheduledTime, + }; + + expect(instance.id).toBe('instance-123'); + expect(instance.reminderId).toBe('reminder-456'); + expect(instance.title).toBe('Take Vitamins'); + expect(instance.icon).toBe('💊'); + expect(instance.scheduledTime).toBe(scheduledTime); + expect(instance.scheduledTime).toBeInstanceOf(Date); + }); + }); + + describe('User Type', () => { + test('should accept valid user object with required fields', () => { + const user: User = { + _id: 'user-123', + _rev: 'rev-456', + username: 'testuser', + }; + + expect(user._id).toBe('user-123'); + expect(user._rev).toBe('rev-456'); + expect(user.username).toBe('testuser'); + }); + + test('should accept user with all optional fields', () => { + const user: User = { + _id: 'user-456', + _rev: 'rev-789', + username: 'fulluser', + email: 'full@example.com', + avatar: 'base64-encoded-avatar', + password: 'hashed-password', + emailVerified: true, + role: UserRole.USER, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + lastLoginAt: new Date('2024-01-15T08:00:00.000Z'), + }; + + expect(user.email).toBe('full@example.com'); + expect(user.avatar).toBe('base64-encoded-avatar'); + expect(user.password).toBe('hashed-password'); + expect(user.emailVerified).toBe(true); + expect(user.role).toBe(UserRole.USER); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.lastLoginAt).toBeInstanceOf(Date); + }); + }); + + describe('Type Compatibility', () => { + test('should handle Date objects consistently', () => { + const now = new Date(); + const dose: Dose = { + id: 'date-test', + medicationId: 'med-test', + scheduledTime: now, + }; + + expect(dose.scheduledTime).toBe(now); + expect(dose.scheduledTime).toBeInstanceOf(Date); + }); + + test('should handle unicode characters in string fields', () => { + const reminder: CustomReminder = { + _id: 'unicode-test', + _rev: 'rev-unicode', + title: 'Medicine with émojis 💊🏥', + icon: '🎯', + startTime: '08:00', + endTime: '20:00', + frequencyMinutes: 30, + }; + + expect(reminder.title).toContain('émojis'); + expect(reminder.title).toContain('💊'); + expect(reminder.icon).toBe('🎯'); + }); + + test('should handle empty string values where appropriate', () => { + const medication: Medication = { + _id: 'empty-test', + _rev: 'rev-empty', + name: 'Test Med', + dosage: '10mg', + frequency: Frequency.Daily, + startTime: '08:00', + notes: '', + }; + + expect(medication.notes).toBe(''); + expect(typeof medication.notes).toBe('string'); + }); + + test('should maintain type safety with functions', () => { + const createDose = (medication: Medication): Dose => ({ + id: `${medication._id}-${Date.now()}`, + medicationId: medication._id, + scheduledTime: new Date(), + }); + + const medication: Medication = { + _id: 'function-test', + _rev: 'rev-function', + name: 'Function Med', + dosage: '25mg', + frequency: Frequency.TwiceADay, + startTime: '12:00', + notes: '', + }; + + const dose = createDose(medication); + expect(dose.medicationId).toBe(medication._id); + }); + }); +}); diff --git a/utils/__tests__/env.test.ts b/utils/__tests__/env.test.ts new file mode 100644 index 0000000..a48a74d --- /dev/null +++ b/utils/__tests__/env.test.ts @@ -0,0 +1,198 @@ +import { + getEnv, + getEnvVar, + isBrowser, + isNode, + isTest, + isProduction, + type EnvConfig, +} from '../env'; + +describe('Environment Utilities', () => { + describe('getEnv', () => { + test('should return an object', () => { + const env = getEnv(); + expect(typeof env).toBe('object'); + expect(env).not.toBeNull(); + }); + + test('should return consistent results on multiple calls', () => { + const env1 = getEnv(); + const env2 = getEnv(); + expect(env1).toEqual(env2); + }); + }); + + describe('getEnvVar', () => { + test('should return fallback when variable does not exist', () => { + const fallback = 'default_value'; + const value = getEnvVar('DEFINITELY_NON_EXISTENT_VAR_12345', fallback); + expect(value).toBe(fallback); + }); + + test('should return undefined when no fallback provided and variable does not exist', () => { + const value = getEnvVar('DEFINITELY_NON_EXISTENT_VAR_12345'); + expect(value).toBeUndefined(); + }); + + test('should handle empty string fallback', () => { + const value = getEnvVar('DEFINITELY_NON_EXISTENT_VAR_12345', ''); + expect(value).toBe(''); + }); + }); + + describe('isBrowser', () => { + test('should return a boolean', () => { + const result = isBrowser(); + expect(typeof result).toBe('boolean'); + }); + + test('should be consistent across calls', () => { + const result1 = isBrowser(); + const result2 = isBrowser(); + expect(result1).toBe(result2); + }); + }); + + describe('isNode', () => { + test('should return a boolean', () => { + const result = isNode(); + expect(typeof result).toBe('boolean'); + }); + + test('should be consistent across calls', () => { + const result1 = isNode(); + const result2 = isNode(); + expect(result1).toBe(result2); + }); + + test('should return true in Jest test environment', () => { + // Jest runs in Node.js, so this should be true + const result = isNode(); + expect(result).toBe(true); + }); + }); + + describe('isTest', () => { + test('should return a boolean', () => { + const result = isTest(); + expect(typeof result).toBe('boolean'); + }); + + test('should return true in Jest test environment', () => { + // We are running in Jest, so this should be true + const result = isTest(); + expect(result).toBe(true); + }); + }); + + describe('isProduction', () => { + test('should return a boolean', () => { + const result = isProduction(); + expect(typeof result).toBe('boolean'); + }); + + test('should return false in test environment', () => { + // We are running tests, so this should not be production + const result = isProduction(); + expect(result).toBe(false); + }); + }); + + describe('EnvConfig type', () => { + test('should accept valid configuration object', () => { + const config: EnvConfig = { + VITE_COUCHDB_URL: 'http://localhost:5984', + VITE_COUCHDB_USERNAME: 'admin', + VITE_COUCHDB_PASSWORD: 'password', + VITE_MAILGUN_API_KEY: 'test-key', + VITE_MAILGUN_DOMAIN: 'test.mailgun.org', + NODE_ENV: 'test', + CUSTOM_VAR: 'custom_value', + }; + + expect(config.VITE_COUCHDB_URL).toBe('http://localhost:5984'); + expect(config.NODE_ENV).toBe('test'); + expect(config.CUSTOM_VAR).toBe('custom_value'); + }); + + test('should handle undefined values', () => { + const config: EnvConfig = { + DEFINED_VAR: 'value', + UNDEFINED_VAR: undefined, + }; + + expect(config.DEFINED_VAR).toBe('value'); + expect(config.UNDEFINED_VAR).toBeUndefined(); + }); + + test('should allow dynamic key access', () => { + const config: EnvConfig = { + TEST_KEY: 'test_value', + }; + + const key = 'TEST_KEY'; + expect(config[key]).toBe('test_value'); + }); + }); + + describe('integration scenarios', () => { + test('should work together consistently', () => { + const env = getEnv(); + const isTestEnv = isTest(); + const isProdEnv = isProduction(); + const isBrowserEnv = isBrowser(); + const isNodeEnv = isNode(); + + // Basic consistency checks + expect(typeof env).toBe('object'); + expect(typeof isTestEnv).toBe('boolean'); + expect(typeof isProdEnv).toBe('boolean'); + expect(typeof isBrowserEnv).toBe('boolean'); + expect(typeof isNodeEnv).toBe('boolean'); + + // In Jest test environment, we expect certain conditions + expect(isTestEnv).toBe(true); + expect(isProdEnv).toBe(false); + expect(isNodeEnv).toBe(true); + }); + + test('should handle missing environment gracefully', () => { + const nonExistentVar = getEnvVar('DEFINITELY_NON_EXISTENT_VAR_XYZ_123'); + expect(nonExistentVar).toBeUndefined(); + + const withFallback = getEnvVar( + 'DEFINITELY_NON_EXISTENT_VAR_XYZ_123', + 'fallback' + ); + expect(withFallback).toBe('fallback'); + }); + }); + + describe('edge cases', () => { + test('should handle empty string environment variable names', () => { + const value = getEnvVar(''); + expect(value).toBeUndefined(); + }); + + test('should handle whitespace in variable names', () => { + const value = getEnvVar(' NON_EXISTENT '); + expect(value).toBeUndefined(); + }); + + test('should handle null fallback', () => { + const value = getEnvVar('NON_EXISTENT', null as any); + expect(value).toBeNull(); + }); + + test('should handle numeric fallback', () => { + const value = getEnvVar('NON_EXISTENT', 42 as any); + expect(value).toBe(42); + }); + + test('should handle boolean fallback', () => { + const value = getEnvVar('NON_EXISTENT', true as any); + expect(value).toBe(true); + }); + }); +}); diff --git a/utils/__tests__/schedule.test.ts b/utils/__tests__/schedule.test.ts new file mode 100644 index 0000000..81fdb47 --- /dev/null +++ b/utils/__tests__/schedule.test.ts @@ -0,0 +1,223 @@ +import { generateSchedule, generateReminderSchedule } from '../schedule'; +import { Medication, Frequency, CustomReminder } from '../../types'; + +describe('Schedule Utilities', () => { + const baseDate = new Date('2024-01-15T12:00:00.000Z'); + + describe('generateSchedule', () => { + const createMockMedication = ( + overrides: Partial = {} + ): Medication => ({ + _id: 'med-1', + _rev: 'rev-1', + name: 'Test Medication', + dosage: '10mg', + frequency: Frequency.Daily, + startTime: '08:00', + notes: '', + ...overrides, + }); + + test('should return empty array for empty medications', () => { + const schedule = generateSchedule([], baseDate); + expect(schedule).toEqual([]); + }); + + test('should generate one dose for daily frequency', () => { + const medication = createMockMedication({ + frequency: Frequency.Daily, + }); + + const schedule = generateSchedule([medication], baseDate); + + expect(schedule).toHaveLength(1); + expect(schedule[0].medicationId).toBe('med-1'); + expect(schedule[0].scheduledTime).toBeInstanceOf(Date); + }); + + test('should generate two doses for twice daily frequency', () => { + const medication = createMockMedication({ + frequency: Frequency.TwiceADay, + }); + + const schedule = generateSchedule([medication], baseDate); + + expect(schedule).toHaveLength(2); + expect(schedule[0].medicationId).toBe('med-1'); + expect(schedule[1].medicationId).toBe('med-1'); + }); + + test('should generate three doses for three times daily frequency', () => { + const medication = createMockMedication({ + frequency: Frequency.ThreeTimesADay, + }); + + const schedule = generateSchedule([medication], baseDate); + + expect(schedule).toHaveLength(3); + }); + + test('should handle EveryXHours frequency', () => { + const medication = createMockMedication({ + frequency: Frequency.EveryXHours, + hoursBetween: 6, + }); + + const schedule = generateSchedule([medication], baseDate); + + expect(schedule.length).toBeGreaterThan(0); + expect(schedule.length).toBeLessThanOrEqual(24); + }); + + test('should sort doses by time', () => { + const medications = [ + createMockMedication({ + _id: 'med-1', + startTime: '18:00', + }), + createMockMedication({ + _id: 'med-2', + startTime: '08:00', + }), + ]; + + const schedule = generateSchedule(medications, baseDate); + + expect(schedule).toHaveLength(2); + // Should be sorted by time + expect(schedule[0].scheduledTime.getTime()).toBeLessThanOrEqual( + schedule[1].scheduledTime.getTime() + ); + }); + + test('should handle invalid hoursBetween gracefully', () => { + const medication = createMockMedication({ + frequency: Frequency.EveryXHours, + hoursBetween: 0, + }); + + const schedule = generateSchedule([medication], baseDate); + expect(schedule).toEqual([]); + }); + }); + + describe('generateReminderSchedule', () => { + const createMockReminder = ( + overrides: Partial = {} + ): CustomReminder => ({ + _id: 'reminder-1', + _rev: 'rev-1', + title: 'Test Reminder', + icon: '💊', + startTime: '09:00', + endTime: '17:00', + frequencyMinutes: 60, + ...overrides, + }); + + test('should return empty array for empty reminders', () => { + const schedule = generateReminderSchedule([], baseDate); + expect(schedule).toEqual([]); + }); + + test('should generate reminders within time window', () => { + const reminder = createMockReminder({ + startTime: '10:00', + endTime: '12:00', + frequencyMinutes: 60, + }); + + const schedule = generateReminderSchedule([reminder], baseDate); + + expect(schedule.length).toBeGreaterThan(0); + schedule.forEach(instance => { + expect(instance.reminderId).toBe('reminder-1'); + expect(instance.title).toBe('Test Reminder'); + expect(instance.icon).toBe('💊'); + expect(instance.scheduledTime).toBeInstanceOf(Date); + }); + }); + + test('should handle single time window', () => { + const reminder = createMockReminder({ + startTime: '15:00', + endTime: '15:00', + frequencyMinutes: 60, + }); + + const schedule = generateReminderSchedule([reminder], baseDate); + + expect(schedule).toHaveLength(1); + }); + + test('should sort reminders by time', () => { + const reminders = [ + createMockReminder({ + _id: 'reminder-1', + startTime: '18:00', + endTime: '18:00', + }), + createMockReminder({ + _id: 'reminder-2', + startTime: '08:00', + endTime: '08:00', + }), + ]; + + const schedule = generateReminderSchedule(reminders, baseDate); + + expect(schedule).toHaveLength(2); + // Should be sorted by time + expect(schedule[0].scheduledTime.getTime()).toBeLessThanOrEqual( + schedule[1].scheduledTime.getTime() + ); + }); + + test('should generate unique IDs', () => { + const reminder = createMockReminder({ + startTime: '10:00', + endTime: '11:00', + frequencyMinutes: 30, + }); + + const schedule = generateReminderSchedule([reminder], baseDate); + + const ids = schedule.map(s => s.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + }); + }); + + describe('error handling', () => { + test('should handle malformed time strings', () => { + const medication: Medication = { + _id: 'bad-med', + _rev: 'rev-1', + name: 'Bad Med', + dosage: '10mg', + frequency: Frequency.Daily, + startTime: 'invalid:time', + notes: '', + }; + + // This will throw because the time parsing creates an invalid date + expect(() => generateSchedule([medication], baseDate)).toThrow(); + }); + + test('should handle large frequency values', () => { + const medication: Medication = { + _id: 'large-med', + _rev: 'rev-1', + name: 'Large Med', + dosage: '10mg', + frequency: Frequency.EveryXHours, + startTime: '08:00', + hoursBetween: 100, + notes: '', + }; + + const schedule = generateSchedule([medication], baseDate); + expect(schedule.length).toBeLessThan(100); + }); + }); +});