test(tests-test): Add test utilities for wellness backend mocking and validation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:20:01 -07:00
parent fc29de9c5a
commit ec429da540

View file

@ -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> = {}): 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> = {}): 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> = {}): 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);
});
});
});