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:
Claude Code 2026-03-25 23:19:56 -07:00
parent fe26ec8e26
commit b40513c7f6

View 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([]);
});
});
});