test(diary-backend): ✅ Add unit/integration tests for diary backend functionality, including edge cases and new feature validation
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
fe26ec8e26
commit
b40513c7f6
1 changed files with 389 additions and 0 deletions
389
journal/diary/backend/__tests__/diary.service.spec.ts
Normal file
389
journal/diary/backend/__tests__/diary.service.spec.ts
Normal file
|
|
@ -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<string, unknown> = {}): Record<string, unknown> {
|
||||
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> = {}): 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue