diff --git a/journal/diary/backend/__tests__/diary.service.spec.ts b/journal/diary/backend/__tests__/diary.service.spec.ts new file mode 100644 index 0000000..59f427b --- /dev/null +++ b/journal/diary/backend/__tests__/diary.service.spec.ts @@ -0,0 +1,389 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { DiaryService } from '../diary.service'; +import { DiaryEntry } from '../entities/diary-entry.entity'; +import { EncryptionService } from '@common/encryption.service'; +import { mockRepository, MockRepository } from '@test-helpers/mock-repository'; +import { mockEncryptionService, MockEncryptionService } from '@test-helpers/mock-encryption'; + +jest.mock('@common/resolve-short-id', () => ({ + resolveShortId: jest.fn(async (_repo: unknown, id: string) => id), +})); + +jest.mock('@lilith/crypt', () => ({ + generateId: jest.fn().mockReturnValue('a1b2c3d4-1111-2222-3333-444455556666'), +})); + +const UUID_1 = 'a1b2c3d4-1111-2222-3333-444455556666'; +const UUID_2 = 'b2c3d4e5-2222-3333-4444-555566667777'; + +function makeDiaryRow(overrides: Record = {}): Record { + return { + id: UUID_1, + date: '2026-03-22', + time: '14:30', + content: 'Feeling productive today.', + feelings: [{ category: 'joy', intensity: 7 }], + triggers: ['work'], + context_links: [], + exploration_log: null, + metadata: null, + domain_id: null, + created_at: new Date(), + updated_at: new Date(), + deleted_at: null, + ...overrides, + }; +} + +function makeDiaryEntry(overrides: Partial = {}): DiaryEntry { + return { + id: UUID_1, + date: '2026-03-22', + time: '14:30', + contentEncrypted: null, + feelings: [{ category: 'joy', intensity: 7 }], + triggers: ['work'], + contextLinks: [], + explorationLog: null, + metadata: null, + domainId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + } as DiaryEntry; +} + +describe('DiaryService', () => { + let service: DiaryService; + let diaryRepo: MockRepository; + let encryptionSvc: MockEncryptionService; + let eventEmitter: { emit: jest.Mock }; + + beforeEach(async () => { + diaryRepo = mockRepository(); + encryptionSvc = mockEncryptionService(); + eventEmitter = { emit: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DiaryService, + { provide: EncryptionService, useValue: encryptionSvc }, + { provide: getRepositoryToken(DiaryEntry), useValue: diaryRepo }, + { provide: EventEmitter2, useValue: eventEmitter }, + ], + }).compile(); + + service = module.get(DiaryService); + }); + + describe('create', () => { + it('should create a diary entry with encrypted content', async () => { + const dto = { + content: 'Today was great.', + feelings: [{ category: 'joy', intensity: 8 }], + triggers: ['sunshine'], + }; + + const result = await service.create(dto as never); + + expect(encryptionSvc.encryptedInsert).toHaveBeenCalledWith( + 'diary_entries', + expect.arrayContaining([ + expect.objectContaining({ name: 'id', value: UUID_1, encrypted: false }), + expect.objectContaining({ name: 'content_encrypted', value: 'Today was great.', encrypted: true }), + expect.objectContaining({ name: 'feelings', value: JSON.stringify([{ category: 'joy', intensity: 8 }]), encrypted: false }), + expect.objectContaining({ name: 'triggers', value: JSON.stringify(['sunshine']), encrypted: false }), + ]), + ); + expect(result).toBeDefined(); + expect(result.id).toBe(UUID_1); + }); + + it('should emit activity event on creation', async () => { + await service.create({ content: 'Test' } as never); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'activity', + expect.objectContaining({ content: 'New diary entry' }), + ); + }); + + it('should use provided date and time when given', async () => { + const dto = { + content: 'Backdated entry', + date: '2026-03-20', + time: '09:00', + }; + + await service.create(dto as never); + + expect(encryptionSvc.encryptedInsert).toHaveBeenCalledWith( + 'diary_entries', + expect.arrayContaining([ + expect.objectContaining({ name: 'date', value: '2026-03-20' }), + expect.objectContaining({ name: 'time', value: '09:00' }), + ]), + ); + }); + }); + + describe('findOne', () => { + it('should return decrypted diary entry', async () => { + const row = makeDiaryRow(); + encryptionSvc.decryptedSelect.mockResolvedValue([row]); + + const result = await service.findOne(UUID_1); + + expect(result.id).toBe(UUID_1); + expect(result.content).toBe('Feeling productive today.'); + expect(result.feelings).toEqual([{ category: 'joy', intensity: 7 }]); + }); + + it('should throw NotFoundException when not found', async () => { + encryptionSvc.decryptedSelect.mockResolvedValue([]); + + await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findAll', () => { + it('should return paginated decrypted results', async () => { + const rows = [makeDiaryRow(), makeDiaryRow({ id: UUID_2, date: '2026-03-21' })]; + encryptionSvc.decryptedSelect.mockResolvedValue(rows); + + const qb = diaryRepo.createQueryBuilder(); + qb.getCount.mockResolvedValue(2); + + const result = await service.findAll({}); + + expect(result.data).toHaveLength(2); + expect(result.meta).toEqual({ page: 1, limit: 20, total: 2, totalPages: 1 }); + }); + + it('should apply date range filters', async () => { + encryptionSvc.decryptedSelect.mockResolvedValue([]); + const qb = diaryRepo.createQueryBuilder(); + qb.getCount.mockResolvedValue(0); + + await service.findAll({ dateStart: '2026-03-01', dateEnd: '2026-03-31' }); + + expect(qb.andWhere).toHaveBeenCalledWith('e.date >= :dateStart', { dateStart: '2026-03-01' }); + expect(qb.andWhere).toHaveBeenCalledWith('e.date <= :dateEnd', { dateEnd: '2026-03-31' }); + }); + + it('should apply domainId filter', async () => { + encryptionSvc.decryptedSelect.mockResolvedValue([]); + const qb = diaryRepo.createQueryBuilder(); + qb.getCount.mockResolvedValue(0); + + await service.findAll({ domainId: 'dom-1' }); + + expect(qb.andWhere).toHaveBeenCalledWith('e.domainId = :domainId', { domainId: 'dom-1' }); + }); + + it('should filter by feeling category client-side', async () => { + const rows = [ + makeDiaryRow({ feelings: [{ category: 'joy', intensity: 8 }] }), + makeDiaryRow({ id: UUID_2, feelings: [{ category: 'sadness', intensity: 5 }] }), + ]; + encryptionSvc.decryptedSelect.mockResolvedValue(rows); + const qb = diaryRepo.createQueryBuilder(); + qb.getCount.mockResolvedValue(2); + + const result = await service.findAll({ feelingCategory: 'joy' }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].feelings).toEqual([{ category: 'joy', intensity: 8 }]); + }); + + it('should filter by minIntensity client-side', async () => { + const rows = [ + makeDiaryRow({ feelings: [{ category: 'joy', intensity: 3 }] }), + makeDiaryRow({ id: UUID_2, feelings: [{ category: 'joy', intensity: 8 }] }), + ]; + encryptionSvc.decryptedSelect.mockResolvedValue(rows); + const qb = diaryRepo.createQueryBuilder(); + qb.getCount.mockResolvedValue(2); + + const result = await service.findAll({ minIntensity: 5 }); + + expect(result.data).toHaveLength(1); + }); + }); + + describe('update', () => { + it('should update encrypted content', async () => { + const row = makeDiaryRow(); + encryptionSvc.decryptedSelect.mockResolvedValue([row]); + + await service.update(UUID_1, { content: 'Updated content' } as never); + + expect(encryptionSvc.encryptedUpdate).toHaveBeenCalledWith( + 'diary_entries', + expect.arrayContaining([ + expect.objectContaining({ name: 'content_encrypted', value: 'Updated content', encrypted: true }), + ]), + [{ column: 'id', operator: '=', value: UUID_1 }], + ); + }); + + it('should update feelings and triggers', async () => { + const row = makeDiaryRow(); + encryptionSvc.decryptedSelect.mockResolvedValue([row]); + + await service.update(UUID_1, { + feelings: [{ category: 'calm', intensity: 9 }], + triggers: ['meditation'], + } as never); + + expect(encryptionSvc.encryptedUpdate).toHaveBeenCalledWith( + 'diary_entries', + expect.arrayContaining([ + expect.objectContaining({ name: 'feelings', value: JSON.stringify([{ category: 'calm', intensity: 9 }]) }), + expect.objectContaining({ name: 'triggers', value: JSON.stringify(['meditation']) }), + ]), + expect.any(Array), + ); + }); + + it('should emit activity event on update', async () => { + const row = makeDiaryRow(); + encryptionSvc.decryptedSelect.mockResolvedValue([row]); + + await service.update(UUID_1, { content: 'Updated' } as never); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'activity', + expect.objectContaining({ content: 'Updated diary entry' }), + ); + }); + + it('should throw NotFoundException for non-existent entry', async () => { + encryptionSvc.decryptedSelect.mockResolvedValue([]); + + await expect(service.update('nonexistent', {} as never)).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateExplorationLog', () => { + it('should update exploration log via encrypted update', async () => { + const row = makeDiaryRow(); + encryptionSvc.decryptedSelect.mockResolvedValue([row]); + + const explorationLog = [ + { question: 'What triggered this?', answer: 'A conversation', extractedFeelings: [{ category: 'anxiety', intensity: 6 }] }, + ]; + + await service.updateExplorationLog(UUID_1, explorationLog); + + expect(encryptionSvc.encryptedUpdate).toHaveBeenCalledWith( + 'diary_entries', + expect.arrayContaining([ + expect.objectContaining({ name: 'exploration_log', value: JSON.stringify(explorationLog) }), + ]), + [{ column: 'id', operator: '=', value: UUID_1 }], + ); + }); + }); + + describe('softRemove', () => { + it('should soft remove an existing entry', async () => { + const entry = makeDiaryEntry(); + diaryRepo.findOne.mockResolvedValue(entry); + diaryRepo.softRemove.mockResolvedValue({ ...entry, deletedAt: new Date() }); + + await service.softRemove(UUID_1); + + expect(diaryRepo.softRemove).toHaveBeenCalledWith(entry); + }); + + it('should emit activity event on removal', async () => { + const entry = makeDiaryEntry(); + diaryRepo.findOne.mockResolvedValue(entry); + diaryRepo.softRemove.mockResolvedValue(entry); + + await service.softRemove(UUID_1); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'activity', + expect.objectContaining({ content: 'Removed diary entry' }), + ); + }); + + it('should throw NotFoundException for non-existent entry', async () => { + diaryRepo.findOne.mockResolvedValue(null); + + await expect(service.softRemove('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('getFeelingTrends', () => { + it('should return aggregated trends by date and category', async () => { + const entries = [ + makeDiaryEntry({ + date: '2026-03-20', + feelings: [ + { category: 'joy', intensity: 8 }, + { category: 'calm', intensity: 6 }, + ], + }), + makeDiaryEntry({ + id: UUID_2, + date: '2026-03-20', + feelings: [ + { category: 'joy', intensity: 4 }, + ], + }), + ]; + const qb = diaryRepo.createQueryBuilder(); + qb.getMany.mockResolvedValue(entries); + + const result = await service.getFeelingTrends(30); + + expect(result).toEqual(expect.arrayContaining([ + expect.objectContaining({ date: '2026-03-20', category: 'joy', avgIntensity: 6, entryCount: 2 }), + expect.objectContaining({ date: '2026-03-20', category: 'calm', avgIntensity: 6, entryCount: 1 }), + ])); + }); + + it('should filter by category when provided', async () => { + const entries = [ + makeDiaryEntry({ + date: '2026-03-20', + feelings: [ + { category: 'joy', intensity: 8 }, + { category: 'sadness', intensity: 3 }, + ], + }), + ]; + const qb = diaryRepo.createQueryBuilder(); + qb.getMany.mockResolvedValue(entries); + + const result = await service.getFeelingTrends(30, 'joy'); + + expect(result).toHaveLength(1); + expect(result[0].category).toBe('joy'); + }); + + it('should return empty array when no entries', async () => { + const qb = diaryRepo.createQueryBuilder(); + qb.getMany.mockResolvedValue([]); + + const result = await service.getFeelingTrends(7); + + expect(result).toEqual([]); + }); + }); + + describe('getCorrelations', () => { + it('should return empty array (placeholder)', async () => { + const result = await service.getCorrelations('joy'); + + expect(result).toEqual([]); + }); + }); +});