import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; interface TaskTemplateBody { id: string; nextDueDate: string; active: boolean; recurrenceType: string; recurrenceDayOfMonth: number | null; intervalDays: number | null; } describe('TaskTemplates (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const beekeeperEmail = `task-templates-e2e-beekeeper-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let beekeeperId: string; let beekeeperToken: string; let templateId: string; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const beekeeper = await prisma.user.create({ data: { email: beekeeperEmail, passwordHash, name: 'Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; }); afterAll(async () => { if (templateId) { const generated = await prisma.task.findMany({ where: { sourceTemplateId: templateId }, select: { id: true }, }); const generatedIds = generated.map((task) => task.id); // Written without a user, so not covered by the userId cleanup below. await prisma.auditLog.deleteMany({ where: { entityType: 'Task', entityId: { in: generatedIds } }, }); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Task', aggregateId: { in: generatedIds } }, }); await prisma.task.deleteMany({ where: { sourceTemplateId: templateId } }); await prisma.taskTemplate.delete({ where: { id: templateId } }); } await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); await prisma.user.deleteMany({ where: { id: beekeeperId } }); await app.close(); }); it('rejects a YEARLY template missing recurrenceMonth/recurrenceDayOfMonth', () => { return request(app.getHttpServer()) .post('/api/v1/task-templates') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ title: 'Jahresinventur', recurrenceType: 'YEARLY' }) .expect(400); }); it('creates an INTERVAL_DAYS template and computes nextDueDate', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/task-templates') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ title: `Fütterung ${Date.now()}`, recurrenceType: 'INTERVAL_DAYS', intervalDays: 1, }) .expect(201); const body = response.body as TaskTemplateBody; templateId = body.id; expect(body.nextDueDate).toEqual(expect.any(String)); }); it('generates a due Task lazily on GET /tasks and advances nextDueDate', async () => { const yesterday = new Date(); yesterday.setUTCDate(yesterday.getUTCDate() - 1); await prisma.taskTemplate.update({ where: { id: templateId }, data: { nextDueDate: yesterday }, }); await request(app.getHttpServer()) .get('/api/v1/tasks') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); const generated = await prisma.task.findMany({ where: { sourceTemplateId: templateId }, }); expect(generated).toHaveLength(1); const [generatedTask] = generated; expect(generatedTask.createdByUserId).toBeNull(); const template = await prisma.taskTemplate.findUniqueOrThrow({ where: { id: templateId }, }); expect(template.nextDueDate.getTime()).toBeGreaterThan(yesterday.getTime()); // Same trail as any other task: an audit entry (by no user - the // template made it) and TaskCreated for calendar and webhooks. const audit = await prisma.auditLog.findMany({ where: { entityType: 'Task', entityId: generatedTask.id }, }); expect(audit).toEqual([ expect.objectContaining({ action: 'CREATE', userId: null }), ]); const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'Task', aggregateId: generatedTask.id }, }); expect(events).toEqual([ expect.objectContaining({ type: 'TaskCreated', actorUserId: null, payload: { relatedType: 'GENERAL', relatedId: null, dueDate: generatedTask.dueDate?.toISOString().slice(0, 10), }, }), ]); }); it('creates nothing more for an occurrence already generated', async () => { await request(app.getHttpServer()) .get('/api/v1/tasks') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); await expect( prisma.task.count({ where: { sourceTemplateId: templateId } }), ).resolves.toBe(1); }); it('rejects a recurrence emptied of a field it needs', () => { return request(app.getHttpServer()) .patch(`/api/v1/task-templates/${templateId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ recurrenceType: 'MONTHLY', recurrenceDayOfMonth: null }) .expect(400); }); it('clears the interval when switching to a monthly recurrence', async () => { const response = await request(app.getHttpServer()) .patch(`/api/v1/task-templates/${templateId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ recurrenceType: 'MONTHLY', recurrenceDayOfMonth: 5, intervalDays: null, }) .expect(200); const body = response.body as TaskTemplateBody; expect(body.recurrenceType).toBe('MONTHLY'); expect(body.recurrenceDayOfMonth).toBe(5); expect(body.intervalDays).toBeNull(); const auditEntry = await prisma.auditLog.findFirst({ where: { entityType: 'TaskTemplate', entityId: templateId, action: 'UPDATE', userId: beekeeperId, }, orderBy: { timestamp: 'desc' }, }); expect(auditEntry?.afterData).toEqual( expect.objectContaining({ intervalDays: null }), ); }); it('deactivates a template', async () => { const response = await request(app.getHttpServer()) .post(`/api/v1/task-templates/${templateId}/deactivate`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(201); expect((response.body as TaskTemplateBody).active).toBe(false); }); });