diff --git a/wellness/self-care/backend/__tests__/self-care.service.spec.ts b/wellness/self-care/backend/__tests__/self-care.service.spec.ts new file mode 100644 index 0000000..e605366 --- /dev/null +++ b/wellness/self-care/backend/__tests__/self-care.service.spec.ts @@ -0,0 +1,621 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { SelfCareService } from '../self-care.service'; +import { SelfCareBaselineService } from '../self-care-baseline.service'; +import { SelfCareCategory } from '../entities/self-care-category.entity'; +import { SelfCareLog } from '../entities/self-care-log.entity'; +import { SelfCareBaseline, BaselineSnapshotEntry } from '../entities/self-care-baseline.entity'; +import { mockRepository, MockRepository } from '@test-helpers/mock-repository'; +import { SelfCareCondition } from '@life-platform/shared'; + +jest.mock('@common/resolve-short-id', () => ({ + resolveShortId: jest.fn(async (_repo: unknown, id: string) => id), +})); + +const UUID_1 = 'a1b2c3d4-1111-2222-3333-444455556666'; +const UUID_2 = 'b2c3d4e5-2222-3333-4444-555566667777'; +const UUID_3 = 'c3d4e5f6-3333-4444-5555-666677778888'; +const UUID_CAT1 = 'd4e5f6a7-4444-5555-6666-777788889999'; +const UUID_CAT2 = 'e5f6a7b8-5555-6666-7777-888899990000'; +const UUID_LOG1 = 'f6a7b8c9-6666-7777-8888-999900001111'; + +function makeCategory(overrides: Partial = {}): SelfCareCategory { + return { + id: UUID_CAT1, + name: 'Haircut', + slug: 'haircut', + description: null, + cadenceDays: 30, + cadenceWarningDays: 24, + isActive: true, + sortOrder: 0, + icon: null, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as SelfCareCategory; +} + +function makeLog(overrides: Partial = {}): SelfCareLog { + return { + id: UUID_LOG1, + categoryId: UUID_CAT1, + loggedAt: new Date(), + condition: SelfCareCondition.Good, + notes: null, + photoUrl: null, + isBackdated: false, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as SelfCareLog; +} + +function makeBaseline(overrides: Partial = {}): SelfCareBaseline { + return { + id: UUID_1, + name: 'Baseline #1', + takenAt: new Date(), + notes: null, + snapshot: [], + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as SelfCareBaseline; +} + +describe('SelfCareService', () => { + let service: SelfCareService; + let categoryRepo: MockRepository; + let logRepo: MockRepository; + let eventEmitter: { emit: jest.Mock }; + + beforeEach(async () => { + categoryRepo = mockRepository(); + logRepo = mockRepository(); + eventEmitter = { emit: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SelfCareService, + { provide: getRepositoryToken(SelfCareCategory), useValue: categoryRepo }, + { provide: getRepositoryToken(SelfCareLog), useValue: logRepo }, + { provide: EventEmitter2, useValue: eventEmitter }, + ], + }).compile(); + + service = module.get(SelfCareService); + }); + + describe('createCategory', () => { + it('should create a category with auto-generated slug and warning days', async () => { + const dto = { name: 'Hair Color', cadenceDays: 60 }; + const created = makeCategory({ name: 'Hair Color', slug: 'hair-color', cadenceDays: 60, cadenceWarningDays: 48 }); + categoryRepo.create.mockReturnValue(created); + categoryRepo.save.mockResolvedValue(created); + + const result = await service.createCategory(dto as never); + + expect(categoryRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Hair Color', + slug: 'hair-color', + cadenceDays: 60, + cadenceWarningDays: 48, + isActive: true, + })); + expect(result).toEqual(created); + }); + + it('should use provided slug when given', async () => { + const dto = { name: 'Nails', slug: 'custom-slug', cadenceDays: 14 }; + categoryRepo.create.mockImplementation((d) => d); + categoryRepo.save.mockImplementation((e) => Promise.resolve(e)); + + await service.createCategory(dto as never); + + expect(categoryRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + slug: 'custom-slug', + })); + }); + + it('should use provided cadenceWarningDays', async () => { + const dto = { name: 'Skin', cadenceDays: 7, cadenceWarningDays: 5 }; + categoryRepo.create.mockImplementation((d) => d); + categoryRepo.save.mockImplementation((e) => Promise.resolve(e)); + + await service.createCategory(dto as never); + + expect(categoryRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + cadenceWarningDays: 5, + })); + }); + }); + + describe('logCategory', () => { + it('should log a category entry', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory()); + const savedLog = makeLog({ condition: SelfCareCondition.Excellent }); + logRepo.create.mockReturnValue(savedLog); + logRepo.save.mockResolvedValue(savedLog); + + const result = await service.logCategory(UUID_CAT1, { condition: SelfCareCondition.Excellent } as never); + + expect(logRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + categoryId: UUID_CAT1, + condition: SelfCareCondition.Excellent, + })); + expect(result).toEqual(savedLog); + }); + + it('should emit self-care.logged and activity events', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory({ name: 'Haircut' })); + const savedLog = makeLog(); + logRepo.create.mockReturnValue(savedLog); + logRepo.save.mockResolvedValue(savedLog); + + await service.logCategory(UUID_CAT1, { condition: SelfCareCondition.Good } as never); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'self-care.logged', + expect.objectContaining({ categoryId: UUID_CAT1 }), + ); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'activity', + expect.objectContaining({ content: expect.stringContaining('Haircut') }), + ); + }); + + it('should mark entry as backdated when loggedAt is more than 1 hour ago', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory()); + logRepo.create.mockImplementation((d) => d); + logRepo.save.mockImplementation((e) => Promise.resolve(e)); + + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + + await service.logCategory(UUID_CAT1, { + condition: SelfCareCondition.Good, + loggedAt: twoHoursAgo, + } as never); + + expect(logRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + isBackdated: true, + })); + }); + + it('should not mark as backdated when loggedAt is recent', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory()); + logRepo.create.mockImplementation((d) => d); + logRepo.save.mockImplementation((e) => Promise.resolve(e)); + + await service.logCategory(UUID_CAT1, { + condition: SelfCareCondition.Baseline, + } as never); + + expect(logRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + isBackdated: false, + })); + }); + + it('should throw NotFoundException for non-existent category', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.logCategory('nonexistent', { condition: 'good' } as never)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getCategoryLogs', () => { + it('should return paginated logs for a category', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory()); + const logs = [makeLog(), makeLog({ id: UUID_2 })]; + const qb = logRepo.createQueryBuilder(); + qb.getManyAndCount.mockResolvedValue([logs, 2]); + + const result = await service.getCategoryLogs(UUID_CAT1, {}); + + expect(qb.where).toHaveBeenCalledWith('log.categoryId = :categoryId', { categoryId: UUID_CAT1 }); + expect(result.data).toHaveLength(2); + expect(result.meta).toEqual({ page: 1, limit: 20, total: 2, totalPages: 1 }); + }); + + it('should apply date range and condition filters', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory()); + const qb = logRepo.createQueryBuilder(); + qb.getManyAndCount.mockResolvedValue([[], 0]); + + await service.getCategoryLogs(UUID_CAT1, { + dateStart: '2026-01-01', + dateEnd: '2026-03-31', + condition: SelfCareCondition.Good, + }); + + expect(qb.andWhere).toHaveBeenCalledWith('log.loggedAt >= :dateStart', { dateStart: '2026-01-01' }); + expect(qb.andWhere).toHaveBeenCalledWith('log.loggedAt <= :dateEnd', { dateEnd: '2026-03-31' }); + expect(qb.andWhere).toHaveBeenCalledWith('log.condition = :condition', { condition: SelfCareCondition.Good }); + }); + + it('should throw NotFoundException for non-existent category', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.getCategoryLogs('nonexistent', {})).rejects.toThrow(NotFoundException); + }); + }); + + describe('getStatusBoard', () => { + it('should return empty array when no active categories', async () => { + categoryRepo.find.mockResolvedValue([]); + + const result = await service.getStatusBoard(); + + expect(result).toEqual([]); + }); + + it('should mark overdue categories', async () => { + const cat = makeCategory({ cadenceDays: 7, cadenceWarningDays: 5 }); + categoryRepo.find.mockResolvedValue([cat]); + + // Last log was 10 days ago + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + logRepo.findOne.mockResolvedValue(makeLog({ loggedAt: tenDaysAgo })); + + // computeStreak needs these + categoryRepo.findOne.mockResolvedValue(cat); + logRepo.find.mockResolvedValue([makeLog({ loggedAt: tenDaysAgo })]); + + const result = await service.getStatusBoard(); + + expect(result).toHaveLength(1); + expect(result[0].isOverdue).toBe(true); + expect(result[0].isWarning).toBe(false); + }); + + it('should mark warning categories', async () => { + const cat = makeCategory({ cadenceDays: 10, cadenceWarningDays: 7 }); + categoryRepo.find.mockResolvedValue([cat]); + + // Last log was 8 days ago (within cadence but past warning) + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + logRepo.findOne.mockResolvedValue(makeLog({ loggedAt: eightDaysAgo })); + + categoryRepo.findOne.mockResolvedValue(cat); + logRepo.find.mockResolvedValue([makeLog({ loggedAt: eightDaysAgo })]); + + const result = await service.getStatusBoard(); + + expect(result).toHaveLength(1); + expect(result[0].isOverdue).toBe(false); + expect(result[0].isWarning).toBe(true); + }); + + it('should handle never-logged categories', async () => { + const cat = makeCategory(); + categoryRepo.find.mockResolvedValue([cat]); + logRepo.findOne.mockResolvedValue(null); + + categoryRepo.findOne.mockResolvedValue(cat); + logRepo.find.mockResolvedValue([]); + + const result = await service.getStatusBoard(); + + expect(result).toHaveLength(1); + expect(result[0].daysSince).toBeNull(); + expect(result[0].lastLog).toBeNull(); + }); + + it('should sort: overdue first, then warning, then ok, then never-logged', async () => { + const catOverdue = makeCategory({ id: UUID_1, name: 'Overdue', cadenceDays: 5, cadenceWarningDays: 3 }); + const catWarning = makeCategory({ id: UUID_2, name: 'Warning', cadenceDays: 10, cadenceWarningDays: 5 }); + const catNeverLogged = makeCategory({ id: UUID_3, name: 'Never' }); + categoryRepo.find.mockResolvedValue([catNeverLogged, catWarning, catOverdue]); + + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + const sixDaysAgo = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000); + + logRepo.findOne.mockImplementation(({ where }: { where: { categoryId: string } }) => { + if (where.categoryId === UUID_1) return Promise.resolve(makeLog({ loggedAt: tenDaysAgo })); + if (where.categoryId === UUID_2) return Promise.resolve(makeLog({ loggedAt: sixDaysAgo })); + return Promise.resolve(null); + }); + + categoryRepo.findOne.mockImplementation(({ where }: { where: { id: string } }) => { + if (where.id === UUID_1) return Promise.resolve(catOverdue); + if (where.id === UUID_2) return Promise.resolve(catWarning); + if (where.id === UUID_3) return Promise.resolve(catNeverLogged); + return Promise.resolve(null); + }); + + logRepo.find.mockImplementation(({ where }: { where: { categoryId: string } }) => { + if (where.categoryId === UUID_1) return Promise.resolve([makeLog({ loggedAt: tenDaysAgo })]); + if (where.categoryId === UUID_2) return Promise.resolve([makeLog({ loggedAt: sixDaysAgo })]); + return Promise.resolve([]); + }); + + const result = await service.getStatusBoard(); + + expect(result).toHaveLength(3); + expect(result[0].isOverdue).toBe(true); + expect(result[1].isWarning).toBe(true); + expect(result[2].daysSince).toBeNull(); + }); + }); + + describe('getCategoryStats', () => { + it('should return stats with zero values when no logs', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory({ cadenceDays: 14 })); + logRepo.find.mockResolvedValue([]); + + const result = await service.getCategoryStats(UUID_CAT1); + + expect(result.totalLogs).toBe(0); + expect(result.streakCount).toBe(0); + expect(result.avgDaysBetweenLogs).toBeNull(); + expect(result.targetCadence).toBe(14); + expect(result.conditionBreakdown).toEqual({}); + }); + + it('should calculate condition breakdown and average gap', async () => { + const cat = makeCategory({ cadenceDays: 7 }); + categoryRepo.findOne.mockResolvedValue(cat); + + const now = new Date(); + const logs = [ + makeLog({ loggedAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000), condition: SelfCareCondition.Good }), + makeLog({ loggedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000), condition: SelfCareCondition.Excellent }), + makeLog({ loggedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), condition: SelfCareCondition.Good }), + ]; + logRepo.find.mockResolvedValue(logs); + + const result = await service.getCategoryStats(UUID_CAT1); + + expect(result.totalLogs).toBe(3); + expect(result.conditionBreakdown).toEqual({ + [SelfCareCondition.Good]: 2, + [SelfCareCondition.Excellent]: 1, + }); + expect(result.avgDaysBetweenLogs).toBeGreaterThan(0); + }); + + it('should throw NotFoundException for non-existent category', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.getCategoryStats('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('computeStreak', () => { + it('should return 0 when no logs', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory()); + logRepo.find.mockResolvedValue([]); + + const result = await service.computeStreak(UUID_CAT1); + + expect(result).toBe(0); + }); + + it('should return 0 for non-existent category', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + const result = await service.computeStreak('nonexistent'); + + expect(result).toBe(0); + }); + + it('should count consecutive logs within cadence', async () => { + const cat = makeCategory({ cadenceDays: 7 }); + categoryRepo.findOne.mockResolvedValue(cat); + + const now = new Date(); + const logs = [ + makeLog({ loggedAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000) }), + makeLog({ loggedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000) }), + makeLog({ loggedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000) }), + ]; + logRepo.find.mockResolvedValue(logs); + + const result = await service.computeStreak(UUID_CAT1); + + // All gaps are within cadenceDays+1 = 8 days + expect(result).toBe(3); + }); + + it('should break streak when gap exceeds cadence', async () => { + const cat = makeCategory({ cadenceDays: 3 }); + categoryRepo.findOne.mockResolvedValue(cat); + + const now = new Date(); + const logs = [ + makeLog({ loggedAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000) }), + makeLog({ loggedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000) }), + makeLog({ loggedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000) }), + ]; + logRepo.find.mockResolvedValue(logs); + + const result = await service.computeStreak(UUID_CAT1); + + // First gap is 2 days (within cadence+1=4), second gap is 17 days (exceeds) + expect(result).toBe(2); + }); + }); + + describe('findBySlug', () => { + it('should return a category by slug', async () => { + const cat = makeCategory({ slug: 'haircut' }); + categoryRepo.findOne.mockResolvedValue(cat); + + const result = await service.findBySlug('haircut'); + + expect(categoryRepo.findOne).toHaveBeenCalledWith({ where: { slug: 'haircut' } }); + expect(result).toEqual(cat); + }); + + it('should throw NotFoundException for non-existent slug', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.findBySlug('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); +}); + +describe('SelfCareBaselineService', () => { + let service: SelfCareBaselineService; + let baselineRepo: MockRepository; + let selfCareService: { + getStatusBoard: jest.Mock; + findOne: jest.Mock; + }; + + beforeEach(async () => { + baselineRepo = mockRepository(); + selfCareService = { + getStatusBoard: jest.fn(), + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SelfCareBaselineService, + { provide: getRepositoryToken(SelfCareBaseline), useValue: baselineRepo }, + { provide: SelfCareService, useValue: selfCareService }, + ], + }).compile(); + + service = module.get(SelfCareBaselineService); + }); + + describe('capture', () => { + it('should capture a baseline snapshot from status board', async () => { + selfCareService.getStatusBoard.mockResolvedValue([ + { + category: makeCategory({ id: UUID_CAT1, name: 'Haircut' }), + daysSince: 5, + isOverdue: false, + isWarning: false, + lastLog: makeLog({ condition: SelfCareCondition.Good, loggedAt: new Date('2026-03-17') }), + streakCount: 3, + }, + ]); + const created = makeBaseline({ name: 'March Baseline' }); + baselineRepo.create.mockReturnValue(created); + baselineRepo.save.mockResolvedValue(created); + + const result = await service.capture({ name: 'March Baseline' } as never); + + expect(baselineRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'March Baseline', + snapshot: expect.arrayContaining([ + expect.objectContaining({ + categoryId: UUID_CAT1, + categoryName: 'Haircut', + condition: SelfCareCondition.Good, + }), + ]), + })); + expect(result).toEqual(created); + }); + }); + + describe('findAll', () => { + it('should return all baselines ordered by takenAt DESC', async () => { + const baselines = [makeBaseline(), makeBaseline({ id: UUID_2 })]; + baselineRepo.find.mockResolvedValue(baselines); + + const result = await service.findAll(); + + expect(baselineRepo.find).toHaveBeenCalledWith({ order: { takenAt: 'DESC' } }); + expect(result).toHaveLength(2); + }); + }); + + describe('findOne', () => { + it('should return a baseline by id', async () => { + const baseline = makeBaseline(); + baselineRepo.findOne.mockResolvedValue(baseline); + + const result = await service.findOne(UUID_1); + + expect(result).toEqual(baseline); + }); + + it('should throw NotFoundException for non-existent baseline', async () => { + baselineRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('compare', () => { + it('should compare two baselines and detect improvements', async () => { + const snapshotA: BaselineSnapshotEntry[] = [ + { categoryId: UUID_CAT1, categoryName: 'Haircut', daysSinceLog: 5, condition: SelfCareCondition.Baseline, lastLoggedAt: '2026-03-10' }, + ]; + const snapshotB: BaselineSnapshotEntry[] = [ + { categoryId: UUID_CAT1, categoryName: 'Haircut', daysSinceLog: 2, condition: SelfCareCondition.Good, lastLoggedAt: '2026-03-20' }, + ]; + + baselineRepo.findOne + .mockResolvedValueOnce(makeBaseline({ id: UUID_1, snapshot: snapshotA })) + .mockResolvedValueOnce(makeBaseline({ id: UUID_2, snapshot: snapshotB })); + + const result = await service.compare(UUID_1, UUID_2); + + expect(result).toHaveLength(1); + expect(result[0].change).toBe('improved'); + expect(result[0].ordinalDiff).toBe(1); + }); + + it('should detect declines', async () => { + const snapshotA: BaselineSnapshotEntry[] = [ + { categoryId: UUID_CAT1, categoryName: 'Haircut', daysSinceLog: 2, condition: SelfCareCondition.Excellent, lastLoggedAt: '2026-03-10' }, + ]; + const snapshotB: BaselineSnapshotEntry[] = [ + { categoryId: UUID_CAT1, categoryName: 'Haircut', daysSinceLog: 10, condition: SelfCareCondition.Rough, lastLoggedAt: '2026-03-20' }, + ]; + + baselineRepo.findOne + .mockResolvedValueOnce(makeBaseline({ id: UUID_1, snapshot: snapshotA })) + .mockResolvedValueOnce(makeBaseline({ id: UUID_2, snapshot: snapshotB })); + + const result = await service.compare(UUID_1, UUID_2); + + expect(result[0].change).toBe('declined'); + expect(result[0].ordinalDiff).toBe(-3); + }); + + it('should detect unchanged conditions', async () => { + const snapshot: BaselineSnapshotEntry[] = [ + { categoryId: UUID_CAT1, categoryName: 'Haircut', daysSinceLog: 3, condition: SelfCareCondition.Good, lastLoggedAt: '2026-03-10' }, + ]; + + baselineRepo.findOne + .mockResolvedValueOnce(makeBaseline({ id: UUID_1, snapshot })) + .mockResolvedValueOnce(makeBaseline({ id: UUID_2, snapshot })); + + const result = await service.compare(UUID_1, UUID_2); + + expect(result[0].change).toBe('unchanged'); + expect(result[0].ordinalDiff).toBe(0); + }); + + it('should return unknown when condition data is missing', async () => { + const snapshotA: BaselineSnapshotEntry[] = [ + { categoryId: UUID_CAT1, categoryName: 'Haircut', daysSinceLog: null, condition: null, lastLoggedAt: null }, + ]; + const snapshotB: BaselineSnapshotEntry[] = [ + { categoryId: UUID_CAT1, categoryName: 'Haircut', daysSinceLog: 5, condition: SelfCareCondition.Good, lastLoggedAt: '2026-03-20' }, + ]; + + baselineRepo.findOne + .mockResolvedValueOnce(makeBaseline({ id: UUID_1, snapshot: snapshotA })) + .mockResolvedValueOnce(makeBaseline({ id: UUID_2, snapshot: snapshotB })); + + const result = await service.compare(UUID_1, UUID_2); + + expect(result[0].change).toBe('unknown'); + expect(result[0].ordinalDiff).toBe(0); + }); + }); +});