Files
ApiCore/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts
T
Nick Szaunig 4609a0f9ec feat: Wiederkehrende Aufgaben-Vorlagen (recurring task templates)
Neue TaskTemplate-Entität mit YEARLY/MONTHLY/INTERVAL_DAYS-Wiederholung.
Fällige Aufgaben werden lazy bei GET /tasks erzeugt (Compare-and-Swap auf
nextDueDate), damit mehrere API-Replicas im Kubernetes-Cluster dieselbe
Vorlage nicht doppelt feuern - ohne zusätzliche Cron-Abhängigkeit.

Frontend: Verwaltungsseite unter /settings/task-templates zum Anlegen,
Auflisten und Deaktivieren von Vorlagen.
2026-08-25 05:36:55 +02:00

188 lines
6.0 KiB
TypeScript

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