c2e4467dd8
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung) - Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck - UpdateReminderDto lehnt null ab (400 statt 500) - keine Mails an deaktivierte Benutzer - Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt - Bearbeiten schickt dueAt nur bei geaenderter Zeit Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
182 lines
7.5 KiB
TypeScript
182 lines
7.5 KiB
TypeScript
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');
|
|
});
|
|
});
|