import 'reflect-metadata'; import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import { RemindersController } from './reminders.controller'; function makeService() { return { list: vi.fn(async (..._args: unknown[]) => []), getEmailAvailability: vi.fn(async (..._args: unknown[]) => ({ smtpConfigured: true, hasEmail: true, })), create: vi.fn(async (..._args: unknown[]) => ({})), update: vi.fn(async (..._args: unknown[]) => ({})), snooze: vi.fn(async (..._args: unknown[]) => ({})), remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })), }; } const req = (tenantId?: string) => ({ tenantId }) as any; const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any; const proto = RemindersController.prototype as any; describe('RemindersController — Rollen', () => { it.each([ 'list', 'emailStatus', 'create', 'update', 'snooze', 'remove', ])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); }); it('haengt an Pfad reminders', () => { expect(Reflect.getMetadata('path', RemindersController)).toBe('reminders'); }); }); describe('RemindersController — Mandant', () => { it('reicht req.tenantId und die Benutzerkennung an den Dienst weiter', async () => { const service = makeService(); const controller = new RemindersController(service as any); await controller.list(req('t1'), user); await controller.emailStatus(req('t1'), user); expect(service.getEmailAvailability).toHaveBeenCalledWith('t1', 'u1'); await controller.create(req('t1'), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }); expect(service.list).toHaveBeenCalledWith('t1', 'u1'); expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', 'u1']); await controller.update(req('t1'), user, 'x', { title: 'b' }); await controller.snooze(req('t1'), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }); await controller.remove(req('t1'), user, 'x'); expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']); expect(service.snooze.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']); expect(service.remove).toHaveBeenCalledWith('t1', 'u1', 'x'); }); it('wirft ForbiddenException ohne req.tenantId', async () => { const controller = new RemindersController(makeService() as any); await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf( ForbiddenException, ); await expect( controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }), ).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException); await expect( controller.create(req(), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }), ).rejects.toBeInstanceOf(ForbiddenException); }); it('die globale Pipe verwirft untergeschobene Felder (T-IF2-02)', async () => { const pipe = new ValidationPipe({ whitelist: true, transform: true }); const out: any = await pipe.transform( { title: ' a ', dueAt: '2099-01-01T10:00:00.000Z', tenantId: 'evil', userId: 'evil', emailSentAt: '2020-01-01T00:00:00.000Z', emailAttempts: 9, }, { type: 'body', metatype: CreateReminderDto }, ); expect(out).not.toHaveProperty('tenantId'); expect(out).not.toHaveProperty('userId'); expect(out).not.toHaveProperty('emailSentAt'); expect(out).not.toHaveProperty('emailAttempts'); expect(out.title).toBe('a'); }); }); describe('RemindersController — Pipe fuer Aendern und Verschieben', () => { it('Aendern verwirft untergeschobene Felder, ein Teil-Update ist erlaubt', async () => { const pipe = new ValidationPipe({ whitelist: true, transform: true }); const out: any = await pipe.transform( { title: 'b', tenantId: 'evil', userId: 'evil', emailAttempts: 0 }, { type: 'body', metatype: UpdateReminderDto }, ); expect(out).toEqual({ title: 'b' }); }); it.each([ 'title', 'description', 'dueAt', 'emailEnabled', ])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => { const pipe = new ValidationPipe({ whitelist: true, transform: true }); await expect( pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }), ).rejects.toBeInstanceOf(BadRequestException); }); it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => { const pipe = new ValidationPipe({ whitelist: true, transform: true }); await expect( pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }), ).rejects.toBeInstanceOf(BadRequestException); await expect( pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }), ).resolves.toEqual({}); }); it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => { const pipe = new ValidationPipe({ whitelist: true, transform: true }); const out: any = await pipe.transform( { dueAt: '2099-01-01T10:00:00.000Z', userId: 'evil' }, { type: 'body', metatype: SnoozeReminderDto }, ); expect(out).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' }); await expect( pipe.transform({ dueAt: 'morgen' }, { type: 'body', metatype: SnoozeReminderDto }), ).rejects.toBeTruthy(); }); }); describe('RemindersController — emailEnabled', () => { it('die Pipe laesst emailEnabled beim Anlegen und Aendern durch und verlangt einen Wahrheitswert', async () => { const pipe = new ValidationPipe({ whitelist: true, transform: true }); const created: any = await pipe.transform( { title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: true }, { type: 'body', metatype: CreateReminderDto }, ); expect(created.emailEnabled).toBe(true); const updated: any = await pipe.transform( { emailEnabled: false }, { type: 'body', metatype: UpdateReminderDto }, ); expect(updated.emailEnabled).toBe(false); await expect( pipe.transform( { title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: 'ja' }, { type: 'body', metatype: CreateReminderDto }, ), ).rejects.toBeTruthy(); }); }); describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => { it('deklariert list vor jeder :id-Route', () => { const methods = Object.getOwnPropertyNames(RemindersController.prototype); const listIdx = methods.indexOf('list'); expect(listIdx).toBeGreaterThanOrEqual(0); for (const name of methods) { const path = Reflect.getMetadata('path', proto[name]); if (typeof path === 'string' && path.startsWith(':id')) { expect(listIdx).toBeLessThan(methods.indexOf(name)); // auch die statische Route email-status steht vor jeder :id-Route expect(methods.indexOf('emailStatus')).toBeLessThan(methods.indexOf(name)); } } expect(methods.indexOf('emailStatus')).toBeGreaterThanOrEqual(0); expect(Reflect.getMetadata('path', proto.emailStatus)).toBe('email-status'); }); });