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; let tasksRepository: InMemoryRepository; let mailService: Pick; let taskPushService: Pick; let service: TaskDigestService; beforeEach(() => { usersRepository = new InMemoryRepository(); tasksRepository = new InMemoryRepository(); 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 = {}) { 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 = {}) { 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); } });