Files
listify/listify-api/src/tasks/task-digest.service.spec.ts
Bastian Wagner 9519409ec7 test
2026-06-29 15:25:14 +02:00

257 lines
8.2 KiB
TypeScript

import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UserEntity } from '../auth/user.entity';
import { MailService } from '../mail/mail.service';
import { InMemoryRepository } from '../testing/in-memory-repository';
import { TaskDigestService } from './task-digest.service';
import { TaskPushService } from './task-push.service';
import { UserTaskEntity } from './user-task.entity';
describe('TaskDigestService', () => {
let usersRepository: InMemoryRepository<UserEntity>;
let tasksRepository: InMemoryRepository<UserTaskEntity>;
let mailService: Pick<MailService, 'sendTaskDigestEmail'>;
let taskPushService: Pick<TaskPushService, 'sendTaskDigest'>;
let service: TaskDigestService;
beforeEach(() => {
usersRepository = new InMemoryRepository<UserEntity>();
tasksRepository = new InMemoryRepository<UserTaskEntity>();
mailService = {
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
};
taskPushService = {
sendTaskDigest: jest
.fn()
.mockResolvedValue({
enabled: true,
subscriptionCount: 1,
sentCount: 1,
failedCount: 0,
}),
};
service = new TaskDigestService(
usersRepository as never,
tasksRepository as never,
new ConfigService({
CLIENT_URL: 'http://client.test',
TASK_DIGEST_TIMEZONE: 'Europe/Budapest',
}),
mailService as MailService,
taskPushService as TaskPushService,
);
});
it('sends morning digest with today and overdue open tasks', async () => {
await saveUser({ taskDigestPreference: 'both' });
await saveTask({ id: 'today-task', dueDate: '2026-06-29' });
await saveTask({ id: 'overdue-task', title: 'Alt', dueDate: '2026-06-28' });
await saveTask({
id: 'future-task',
title: 'Zukunft',
dueDate: '2026-06-30',
});
await saveTask({
id: 'done-task',
title: 'Fertig',
dueDate: '2026-06-29',
completed: true,
});
await service.processDueDigests(new Date('2026-06-29T07:00:00.000Z'));
expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith(
'owner@example.com',
{
displayName: 'Owner',
slot: 'morning',
date: '2026-06-29',
tasksUrl: 'http://client.test/tasks',
todayTasks: [
{
title: 'Heute',
notes: undefined,
dueDate: '2026-06-29',
},
],
overdueTasks: [
{
title: 'Alt',
notes: undefined,
dueDate: '2026-06-28',
},
],
},
);
expect(taskPushService.sendTaskDigest).toHaveBeenCalledWith('owner-1', {
slot: 'morning',
date: '2026-06-29',
tasksUrl: 'http://client.test/tasks',
todayTasks: [
{
title: 'Heute',
notes: undefined,
dueDate: '2026-06-29',
},
],
overdueTasks: [
{
title: 'Alt',
notes: undefined,
dueDate: '2026-06-28',
},
],
});
expect(
(await usersRepository.findOne({ where: { id: 'owner-1' } }))
?.taskDigestMorningProcessedDate,
).toBe('2026-06-29');
});
it('does not send when there are no due tasks but marks the slot processed', async () => {
await saveUser({ taskDigestPreference: 'both' });
await saveTask({ id: 'future-task', dueDate: '2026-06-30' });
await service.processDueDigests(new Date('2026-06-29T07:00:00.000Z'));
expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled();
expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled();
expect(
(await usersRepository.findOne({ where: { id: 'owner-1' } }))
?.taskDigestMorningProcessedDate,
).toBe('2026-06-29');
});
it('respects none and morning-only preferences', async () => {
await saveUser({
id: 'none-user',
email: 'none@example.com',
taskDigestPreference: 'none',
});
await saveUser({
id: 'morning-user',
email: 'morning@example.com',
taskDigestPreference: 'morning',
});
await saveUser({
id: 'both-user',
email: 'both@example.com',
taskDigestPreference: 'both',
});
await saveTask({ id: 'none-task', ownerId: 'none-user' });
await saveTask({ id: 'morning-task', ownerId: 'morning-user' });
await saveTask({ id: 'both-task', ownerId: 'both-user' });
await service.processDueDigests(new Date('2026-06-29T13:00:00.000Z'));
expect(mailService.sendTaskDigestEmail).toHaveBeenCalledTimes(1);
expect(taskPushService.sendTaskDigest).toHaveBeenCalledTimes(1);
expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith(
'both@example.com',
expect.objectContaining({ slot: 'afternoon' }),
);
});
it('does not process outside the configured delivery hours', async () => {
await saveUser({ taskDigestPreference: 'both' });
await saveTask({ id: 'today-task' });
await service.processDueDigests(new Date('2026-06-29T08:30:00.000Z'));
expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled();
expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled();
});
it('sends a test digest without marking the regular digest processed', async () => {
await saveUser({ taskDigestPreference: 'none' });
const result = await service.sendTestDigest('owner-1');
expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith(
'owner@example.com',
expect.objectContaining({
displayName: 'Owner',
todayTasks: [
{
title: 'Test-Benachrichtigung',
notes:
'Diese Aufgabe wurde nur fuer den Benachrichtigungstest erzeugt.',
dueDate: expect.any(String),
},
],
overdueTasks: [],
}),
);
expect(taskPushService.sendTaskDigest).toHaveBeenCalledWith(
'owner-1',
expect.objectContaining({
todayTasks: [
expect.objectContaining({ title: 'Test-Benachrichtigung' }),
],
}),
);
expect(result.mail.sent).toBe(true);
expect(result.push).toEqual({
enabled: true,
subscriptionCount: 1,
sentCount: 1,
failedCount: 0,
sent: true,
});
expect(result.usedFallbackTask).toBe(true);
expect(
(await usersRepository.findOne({ where: { id: 'owner-1' } }))
?.taskDigestMorningProcessedDate,
).toBeNull();
});
it('returns separate mail and push status for test digest failures', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
await saveUser();
await saveTask({ id: 'today-task' });
jest
.mocked(mailService.sendTaskDigestEmail)
.mockRejectedValueOnce(new Error('smtp down'));
const result = await service.sendTestDigest('owner-1');
expect(taskPushService.sendTaskDigest).toHaveBeenCalledTimes(1);
expect(result.mail).toEqual({ sent: false, error: 'smtp down' });
expect(result.push.sent).toBe(true);
expect(result.usedFallbackTask).toBe(false);
});
async function saveUser(overrides: Partial<UserEntity> = {}) {
return usersRepository.save({
id: overrides.id ?? 'owner-1',
email: overrides.email ?? 'owner@example.com',
name: overrides.name ?? 'Owner',
passwordHash: 'hash',
verified: overrides.verified ?? true,
onboardingCompleted: false,
taskDigestPreference: overrides.taskDigestPreference ?? 'both',
taskDigestMorningProcessedDate:
overrides.taskDigestMorningProcessedDate ?? null,
taskDigestAfternoonProcessedDate:
overrides.taskDigestAfternoonProcessedDate ?? null,
createdAt: new Date(),
updatedAt: new Date(),
} as UserEntity);
}
async function saveTask(overrides: Partial<UserTaskEntity> = {}) {
return tasksRepository.save({
id: overrides.id ?? 'task-1',
ownerId: overrides.ownerId ?? 'owner-1',
title: overrides.title ?? 'Heute',
notes: overrides.notes ?? null,
dueDate: overrides.dueDate ?? '2026-06-29',
completed: overrides.completed ?? false,
completedAt: overrides.completedAt ?? null,
deletedAt: overrides.deletedAt ?? null,
createdAt: new Date(),
updatedAt: new Date(),
} as UserTaskEntity);
}
});