diff --git a/maternal-app/maternal-app-backend/src/modules/analytics/pattern-analysis.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/analytics/pattern-analysis.service.spec.ts new file mode 100644 index 0000000..b633ea1 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/analytics/pattern-analysis.service.spec.ts @@ -0,0 +1,790 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { + PatternAnalysisService, + SleepPattern, + FeedingPattern, + DiaperPattern, +} from './pattern-analysis.service'; +import { Activity, ActivityType } from '../../database/entities/activity.entity'; +import { Child } from '../../database/entities/child.entity'; + +describe('PatternAnalysisService', () => { + let service: PatternAnalysisService; + let activityRepository: Repository; + let childRepository: Repository; + + const mockChild = { + id: 'child_123', + name: 'Baby Jane', + birthDate: new Date(Date.now() - 3 * 30 * 24 * 60 * 60 * 1000), // 3 months old + familyId: 'family_123', + }; + + const mockActivityRepository = { + find: jest.fn(), + findOne: jest.fn(), + }; + + const mockChildRepository = { + find: jest.fn(), + findOne: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PatternAnalysisService, + { + provide: getRepositoryToken(Activity), + useValue: mockActivityRepository, + }, + { + provide: getRepositoryToken(Child), + useValue: mockChildRepository, + }, + ], + }).compile(); + + service = module.get(PatternAnalysisService); + activityRepository = module.get>( + getRepositoryToken(Activity), + ); + childRepository = module.get>( + getRepositoryToken(Child), + ); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('analyzePatterns', () => { + it('should analyze all patterns for a child', async () => { + const sleepActivities = [ + { + id: '1', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-01T20:00:00'), + endedAt: new Date('2025-01-02T06:00:00'), + }, + { + id: '2', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-02T13:00:00'), + endedAt: new Date('2025-01-02T15:00:00'), + }, + { + id: '3', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-02T20:30:00'), + endedAt: new Date('2025-01-03T07:00:00'), + }, + ]; + + const feedingActivities = [ + { + id: '4', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T08:00:00'), + metadata: { method: 'bottle' }, + }, + { + id: '5', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T11:00:00'), + metadata: { method: 'nursing' }, + }, + { + id: '6', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T14:00:00'), + metadata: { method: 'bottle' }, + }, + ]; + + const diaperActivities = [ + { + id: '7', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T09:00:00'), + metadata: { type: 'wet' }, + }, + { + id: '8', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T12:00:00'), + metadata: { type: 'dirty' }, + }, + { + id: '9', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T15:00:00'), + metadata: { type: 'both' }, + }, + { + id: '10', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T18:00:00'), + metadata: { type: 'wet' }, + }, + ]; + + const allActivities = [ + ...sleepActivities, + ...feedingActivities, + ...diaperActivities, + ]; + + mockActivityRepository.find.mockResolvedValue(allActivities); + mockChildRepository.findOne.mockResolvedValue(mockChild); + + const result = await service.analyzePatterns('child_123', 7); + + expect(result).toHaveProperty('sleep'); + expect(result).toHaveProperty('feeding'); + expect(result).toHaveProperty('diaper'); + expect(result).toHaveProperty('recommendations'); + expect(result).toHaveProperty('concernsDetected'); + expect(Array.isArray(result.recommendations)).toBe(true); + expect(Array.isArray(result.concernsDetected)).toBe(true); + }); + + it('should throw error if child not found', async () => { + mockActivityRepository.find.mockResolvedValue([]); + mockChildRepository.findOne.mockResolvedValue(null); + + await expect(service.analyzePatterns('child_123')).rejects.toThrow( + 'Child not found', + ); + }); + + it('should use custom day range', async () => { + mockActivityRepository.find.mockResolvedValue([]); + mockChildRepository.findOne.mockResolvedValue(mockChild); + + await service.analyzePatterns('child_123', 14); + + expect(activityRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + childId: 'child_123', + startedAt: expect.any(Object), // Between query + }), + }), + ); + }); + }); + + describe('analyzeSleepPatterns', () => { + it('should return null with insufficient data', async () => { + const activities = [ + { + id: '1', + type: ActivityType.SLEEP, + startedAt: new Date(), + endedAt: new Date(), + }, + ]; + + const result = await service.analyzeSleepPatterns(activities, mockChild); + + expect(result).toBeNull(); + }); + + it('should calculate sleep pattern statistics', async () => { + const sleepActivities = [ + { + id: '1', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-01T20:00:00'), + endedAt: new Date('2025-01-02T06:00:00'), // 10 hours + }, + { + id: '2', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-02T13:00:00'), + endedAt: new Date('2025-01-02T15:00:00'), // 2 hours nap + }, + { + id: '3', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-02T20:30:00'), + endedAt: new Date('2025-01-03T07:00:00'), // 10.5 hours + }, + ]; + + const result = await service.analyzeSleepPatterns( + sleepActivities, + mockChild, + ); + + expect(result).not.toBeNull(); + expect(result!.averageDuration).toBeGreaterThan(0); + expect(result!.averageBedtime).toMatch(/\d{2}:\d{2}/); + expect(result!.averageWakeTime).toMatch(/\d{2}:\d{2}/); + expect(result!.nightWakings).toBeGreaterThanOrEqual(0); + expect(result!.napCount).toBeGreaterThanOrEqual(0); + expect(result!.consistency).toBeGreaterThanOrEqual(0); + expect(result!.consistency).toBeLessThanOrEqual(1); + expect(['improving', 'stable', 'declining']).toContain(result!.trend); + }); + + it('should detect improving sleep trend', async () => { + const sleepActivities = [ + { + id: '1', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-01T20:00:00'), + endedAt: new Date('2025-01-02T04:00:00'), // 8 hours + }, + { + id: '2', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-02T20:00:00'), + endedAt: new Date('2025-01-03T05:00:00'), // 9 hours + }, + { + id: '3', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-03T20:00:00'), + endedAt: new Date('2025-01-04T07:00:00'), // 11 hours + }, + ]; + + const result = await service.analyzeSleepPatterns( + sleepActivities, + mockChild, + ); + + expect(result!.trend).toBe('improving'); + }); + + it('should detect declining sleep trend', async () => { + const sleepActivities = [ + { + id: '1', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-01T20:00:00'), + endedAt: new Date('2025-01-02T07:00:00'), // 11 hours + }, + { + id: '2', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-02T20:00:00'), + endedAt: new Date('2025-01-03T05:00:00'), // 9 hours + }, + { + id: '3', + type: ActivityType.SLEEP, + startedAt: new Date('2025-01-03T20:00:00'), + endedAt: new Date('2025-01-04T04:00:00'), // 8 hours + }, + ]; + + const result = await service.analyzeSleepPatterns( + sleepActivities, + mockChild, + ); + + expect(result!.trend).toBe('declining'); + }); + }); + + describe('analyzeFeedingPatterns', () => { + it('should return null with insufficient data', async () => { + const activities = [ + { + id: '1', + type: ActivityType.FEEDING, + startedAt: new Date(), + }, + ]; + + const result = await service.analyzeFeedingPatterns(activities, mockChild); + + expect(result).toBeNull(); + }); + + it('should calculate feeding pattern statistics', async () => { + const feedingActivities = [ + { + id: '1', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T08:00:00'), + endedAt: new Date('2025-01-01T08:20:00'), + metadata: { method: 'bottle' }, + }, + { + id: '2', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T11:00:00'), + endedAt: new Date('2025-01-01T11:15:00'), + metadata: { method: 'nursing' }, + }, + { + id: '3', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T14:00:00'), + endedAt: new Date('2025-01-01T14:20:00'), + metadata: { method: 'bottle' }, + }, + ]; + + const result = await service.analyzeFeedingPatterns( + feedingActivities, + mockChild, + ); + + expect(result).not.toBeNull(); + expect(result!.averageInterval).toBeGreaterThan(0); + expect(result!.averageDuration).toBeGreaterThan(0); + expect(result!.totalFeedings).toBe(3); + expect(result!.feedingMethod).toHaveProperty('bottle'); + expect(result!.feedingMethod).toHaveProperty('nursing'); + expect(result!.consistency).toBeGreaterThanOrEqual(0); + expect(['increasing', 'stable', 'decreasing']).toContain(result!.trend); + }); + + it('should handle feedings without duration', async () => { + const feedingActivities = [ + { + id: '1', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T08:00:00'), + metadata: { method: 'bottle' }, + }, + { + id: '2', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T11:00:00'), + metadata: { method: 'bottle' }, + }, + { + id: '3', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T14:00:00'), + metadata: { method: 'bottle' }, + }, + ]; + + const result = await service.analyzeFeedingPatterns( + feedingActivities, + mockChild, + ); + + expect(result!.averageDuration).toBe(0); + }); + + it('should track feeding methods', async () => { + const feedingActivities = [ + { + id: '1', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T08:00:00'), + metadata: { method: 'bottle' }, + }, + { + id: '2', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T11:00:00'), + metadata: { method: 'bottle' }, + }, + { + id: '3', + type: ActivityType.FEEDING, + startedAt: new Date('2025-01-01T14:00:00'), + metadata: { method: 'nursing' }, + }, + ]; + + const result = await service.analyzeFeedingPatterns( + feedingActivities, + mockChild, + ); + + expect(result!.feedingMethod.bottle).toBe(2); + expect(result!.feedingMethod.nursing).toBe(1); + }); + }); + + describe('analyzeDiaperPatterns', () => { + it('should return null with insufficient data', async () => { + const activities = [ + { + id: '1', + type: ActivityType.DIAPER, + startedAt: new Date(), + metadata: { type: 'wet' }, + }, + ]; + + const result = await service.analyzeDiaperPatterns(activities, mockChild); + + expect(result).toBeNull(); + }); + + it('should calculate diaper pattern statistics', async () => { + const diaperActivities = [ + { + id: '1', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T08:00:00'), + metadata: { type: 'wet' }, + }, + { + id: '2', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T11:00:00'), + metadata: { type: 'dirty' }, + }, + { + id: '3', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T14:00:00'), + metadata: { type: 'both' }, + }, + { + id: '4', + type: ActivityType.DIAPER, + startedAt: new Date('2025-01-01T17:00:00'), + metadata: { type: 'wet' }, + }, + ]; + + const result = await service.analyzeDiaperPatterns( + diaperActivities, + mockChild, + ); + + expect(result).not.toBeNull(); + expect(result!.wetDiapersPerDay).toBeGreaterThan(0); + expect(result!.dirtyDiapersPerDay).toBeGreaterThan(0); + expect(result!.averageInterval).toBeGreaterThan(0); + expect(typeof result!.isHealthy).toBe('boolean'); + expect(typeof result!.notes).toBe('string'); + }); + + it('should detect healthy diaper pattern', async () => { + const diaperActivities = [ + { + id: '1', + type: ActivityType.DIAPER, + startedAt: new Date(Date.now() - 12 * 60 * 60 * 1000), + metadata: { type: 'wet' }, + }, + { + id: '2', + type: ActivityType.DIAPER, + startedAt: new Date(Date.now() - 9 * 60 * 60 * 1000), + metadata: { type: 'both' }, + }, + { + id: '3', + type: ActivityType.DIAPER, + startedAt: new Date(Date.now() - 6 * 60 * 60 * 1000), + metadata: { type: 'wet' }, + }, + { + id: '4', + type: ActivityType.DIAPER, + startedAt: new Date(Date.now() - 3 * 60 * 60 * 1000), + metadata: { type: 'wet' }, + }, + { + id: '5', + type: ActivityType.DIAPER, + startedAt: new Date(), + metadata: { type: 'dirty' }, + }, + ]; + + const result = await service.analyzeDiaperPatterns( + diaperActivities, + mockChild, + ); + + expect(result!.isHealthy).toBe(true); + expect(result!.notes).toContain('healthy'); + }); + + it('should detect unhealthy diaper pattern', async () => { + const diaperActivities = [ + { + id: '1', + type: ActivityType.DIAPER, + startedAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + metadata: { type: 'wet' }, + }, + { + id: '2', + type: ActivityType.DIAPER, + startedAt: new Date(Date.now() - 12 * 60 * 60 * 1000), + metadata: { type: 'wet' }, + }, + { + id: '3', + type: ActivityType.DIAPER, + startedAt: new Date(), + metadata: { type: 'wet' }, + }, + ]; + + const result = await service.analyzeDiaperPatterns( + diaperActivities, + mockChild, + ); + + expect(result!.isHealthy).toBe(false); + expect(result!.notes).toContain('below expected'); + }); + }); + + describe('generateRecommendations', () => { + it('should generate sleep recommendations for low consistency', () => { + const sleepPattern: SleepPattern = { + averageDuration: 600, + averageBedtime: '20:00', + averageWakeTime: '06:00', + nightWakings: 2, + napCount: 2, + consistency: 0.5, + trend: 'stable', + }; + + const recommendations = (service as any).generateRecommendations( + sleepPattern, + null, + null, + mockChild, + ); + + expect(recommendations).toContain( + expect.stringContaining('consistent bedtime routine'), + ); + }); + + it('should generate recommendations for frequent night wakings', () => { + const sleepPattern: SleepPattern = { + averageDuration: 600, + averageBedtime: '20:00', + averageWakeTime: '06:00', + nightWakings: 5, + napCount: 2, + consistency: 0.8, + trend: 'stable', + }; + + const recommendations = (service as any).generateRecommendations( + sleepPattern, + null, + null, + mockChild, + ); + + expect(recommendations).toContain( + expect.stringContaining('reduce night wakings'), + ); + }); + + it('should generate recommendations for low sleep duration', () => { + const sleepPattern: SleepPattern = { + averageDuration: 480, // 8 hours + averageBedtime: '20:00', + averageWakeTime: '04:00', + nightWakings: 1, + napCount: 1, + consistency: 0.8, + trend: 'stable', + }; + + const recommendations = (service as any).generateRecommendations( + sleepPattern, + null, + null, + mockChild, + ); + + expect(recommendations).toContain( + expect.stringContaining('earlier bedtimes or longer naps'), + ); + }); + + it('should generate feeding recommendations', () => { + const feedingPattern: FeedingPattern = { + averageInterval: 3, + averageDuration: 15, + totalFeedings: 8, + feedingMethod: { bottle: 8 }, + consistency: 0.5, + trend: 'decreasing', + }; + + const recommendations = (service as any).generateRecommendations( + null, + feedingPattern, + null, + mockChild, + ); + + expect(recommendations.length).toBeGreaterThan(0); + expect(recommendations).toContain( + expect.stringMatching(/regular schedule|decline/), + ); + }); + + it('should generate diaper recommendations for unhealthy pattern', () => { + const diaperPattern: DiaperPattern = { + wetDiapersPerDay: 2, + dirtyDiapersPerDay: 0.5, + averageInterval: 6, + isHealthy: false, + notes: 'Low output', + }; + + const recommendations = (service as any).generateRecommendations( + null, + null, + diaperPattern, + mockChild, + ); + + expect(recommendations).toContain( + expect.stringContaining('adequate hydration'), + ); + }); + }); + + describe('detectConcerns', () => { + it('should detect declining sleep trend', () => { + const sleepPattern: SleepPattern = { + averageDuration: 600, + averageBedtime: '20:00', + averageWakeTime: '06:00', + nightWakings: 2, + napCount: 2, + consistency: 0.8, + trend: 'declining', + }; + + const concerns = (service as any).detectConcerns( + sleepPattern, + null, + null, + mockChild, + ); + + expect(concerns).toContain( + expect.stringContaining('Sleep duration has been decreasing'), + ); + }); + + it('should detect frequent night wakings concern', () => { + const sleepPattern: SleepPattern = { + averageDuration: 600, + averageBedtime: '20:00', + averageWakeTime: '06:00', + nightWakings: 6, + napCount: 2, + consistency: 0.8, + trend: 'stable', + }; + + const concerns = (service as any).detectConcerns( + sleepPattern, + null, + null, + mockChild, + ); + + expect(concerns).toContain( + expect.stringContaining('Frequent night wakings'), + ); + }); + + it('should detect feeding concerns', () => { + const feedingPattern: FeedingPattern = { + averageInterval: 4, + averageDuration: 15, + totalFeedings: 10, + feedingMethod: { bottle: 10 }, + consistency: 0.7, + trend: 'decreasing', + }; + + const concerns = (service as any).detectConcerns( + null, + feedingPattern, + null, + mockChild, + ); + + expect(concerns).toContain( + expect.stringContaining('Feeding frequency appears to be decreasing'), + ); + }); + + it('should detect diaper concerns', () => { + const diaperPattern: DiaperPattern = { + wetDiapersPerDay: 2, + dirtyDiapersPerDay: 0.5, + averageInterval: 8, + isHealthy: false, + notes: 'Low', + }; + + const concerns = (service as any).detectConcerns( + null, + null, + diaperPattern, + mockChild, + ); + + expect(concerns).toContain( + expect.stringContaining('Diaper output is below expected'), + ); + }); + }); + + describe('helper methods', () => { + it('should calculate average time correctly', () => { + const dates = [ + new Date('2025-01-01T20:00:00'), + new Date('2025-01-02T20:30:00'), + new Date('2025-01-03T19:30:00'), + ]; + + const avgTime = (service as any).calculateAverageTime(dates); + + expect(avgTime).toMatch(/\d{2}:\d{2}/); + expect(avgTime).toBe('20:00'); + }); + + it('should return 00:00 for empty date array', () => { + const avgTime = (service as any).calculateAverageTime([]); + + expect(avgTime).toBe('00:00'); + }); + + it('should calculate standard deviation correctly', () => { + const values = [10, 12, 14, 16, 18]; + + const stdDev = (service as any).calculateStdDev(values); + + expect(stdDev).toBeGreaterThan(0); + expect(stdDev).toBeCloseTo(2.83, 1); + }); + + it('should calculate age in months correctly', () => { + const birthDate = new Date(Date.now() - 6 * 30 * 24 * 60 * 60 * 1000); // ~6 months ago + + const ageInMonths = (service as any).calculateAgeInMonths(birthDate); + + expect(ageInMonths).toBeGreaterThanOrEqual(5); + expect(ageInMonths).toBeLessThanOrEqual(7); + }); + }); +});