257 lines
8.2 KiB
TypeScript
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);
|
|
}
|
|
});
|