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.
188 lines
6.0 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|