import { BadRequestException, NotFoundException } from '@nestjs/common'; import { TaskTemplatesService } from './task-templates.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; describe('TaskTemplatesService', () => { let service: TaskTemplatesService; let prisma: { taskTemplate: { findMany: jest.Mock; findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; updateMany: jest.Mock; }; task: { create: jest.Mock }; }; let auditLog: { record: jest.Mock }; let webhooks: { dispatch: jest.Mock }; const template = { id: 'template-1', title: 'Varroa-Kontrolle', description: null, priority: 'NORMAL', assignedUserId: null, recurrenceType: 'MONTHLY', recurrenceMonth: null, recurrenceDayOfMonth: 15, intervalDays: null, nextDueDate: new Date('2026-03-15T00:00:00.000Z'), active: true, }; beforeEach(() => { prisma = { taskTemplate: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), }, task: { create: jest.fn() }, }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new TaskTemplatesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, ); }); it('findById throws NotFoundException when missing', async () => { prisma.taskTemplate.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); it('create rejects YEARLY without recurrenceMonth/recurrenceDayOfMonth', async () => { await expect( service.create( { title: 'Jahresinventur', recurrenceType: 'YEARLY' }, 'user-1', ), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.taskTemplate.create).not.toHaveBeenCalled(); }); it('create rejects MONTHLY without recurrenceDayOfMonth', async () => { await expect( service.create( { title: 'Monatscheck', recurrenceType: 'MONTHLY' }, 'user-1', ), ).rejects.toBeInstanceOf(BadRequestException); }); it('create rejects INTERVAL_DAYS without intervalDays', async () => { await expect( service.create( { title: 'Fütterung', recurrenceType: 'INTERVAL_DAYS' }, 'user-1', ), ).rejects.toBeInstanceOf(BadRequestException); }); it('create computes nextDueDate and stores the acting user as creator', async () => { prisma.taskTemplate.create.mockResolvedValue(template); await service.create( { title: 'Varroa-Kontrolle', recurrenceType: 'MONTHLY', recurrenceDayOfMonth: 15, }, 'user-1', ); const [[createArg]] = prisma.taskTemplate.create.mock.calls as [ [{ data: { nextDueDate: Date; createdByUserId: string } }], ]; expect(createArg.data.nextDueDate).toBeInstanceOf(Date); expect(createArg.data.createdByUserId).toBe('user-1'); }); it('update recomputes nextDueDate only when a recurrence field changes', async () => { prisma.taskTemplate.findUnique.mockResolvedValue(template); prisma.taskTemplate.update.mockResolvedValue(template); await service.update('template-1', { title: 'Neuer Titel' }, 'user-1'); const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [ [{ data: { nextDueDate: Date | undefined } }], ]; expect(updateArg.data.nextDueDate).toBeUndefined(); }); it('update recomputes nextDueDate when recurrenceDayOfMonth changes', async () => { prisma.taskTemplate.findUnique.mockResolvedValue(template); prisma.taskTemplate.update.mockResolvedValue(template); await service.update('template-1', { recurrenceDayOfMonth: 20 }, 'user-1'); const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [ [{ data: { nextDueDate: Date | undefined } }], ]; expect(updateArg.data.nextDueDate).toBeInstanceOf(Date); }); it('deactivate sets active to false', async () => { prisma.taskTemplate.findUnique.mockResolvedValue(template); prisma.taskTemplate.update.mockResolvedValue({ ...template, active: false, }); await service.deactivate('template-1', 'user-1'); const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [ [{ data: { active: boolean } }], ]; expect(updateArg.data.active).toBe(false); }); describe('generateDueTasks', () => { it('creates a Task and advances nextDueDate for a due template', async () => { prisma.taskTemplate.findMany.mockResolvedValue([template]); prisma.taskTemplate.updateMany.mockResolvedValue({ count: 1 }); prisma.task.create.mockResolvedValue({ id: 'task-1' }); await service.generateDueTasks(); const [[createArg]] = prisma.task.create.mock.calls as [ [{ data: { title: string; sourceTemplateId: string } }], ]; expect(createArg.data.title).toBe(template.title); expect(createArg.data.sourceTemplateId).toBe(template.id); expect(webhooks.dispatch).toHaveBeenCalledWith( 'task.created', expect.objectContaining({ id: 'task-1' }), ); }); it('skips Task creation when the compare-and-swap claim loses the race', async () => { prisma.taskTemplate.findMany.mockResolvedValue([template]); prisma.taskTemplate.updateMany.mockResolvedValue({ count: 0 }); await service.generateDueTasks(); expect(prisma.task.create).not.toHaveBeenCalled(); expect(webhooks.dispatch).not.toHaveBeenCalled(); }); it('does nothing when no template is due', async () => { prisma.taskTemplate.findMany.mockResolvedValue([]); await service.generateDueTasks(); expect(prisma.taskTemplate.updateMany).not.toHaveBeenCalled(); expect(prisma.task.create).not.toHaveBeenCalled(); }); }); });