feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen

- API: Aendern (409 wenn faellig), Spaeter erinnern (409 wenn nicht faellig, setzt E-Mail-Spur zurueck), Loeschen; fremde Kennungen 404
- Kachel: faellige Zeilen hervorgehoben mit Erledigt und drei Spaeter-Optionen, kuenftige mit Bearbeiten und Loeschen
- Zeit-Hilfen (morgen zur gleichen Uhrzeit), Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:59:43 +02:00
parent 325c5ddbf2
commit 709b41a007
16 changed files with 946 additions and 57 deletions
@@ -2,13 +2,16 @@ import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CreateReminderDto } from './dto/reminder.dto';
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
import { RemindersController } from './reminders.controller';
function makeService() {
return {
list: vi.fn(async (..._args: unknown[]) => []),
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 })),
};
}
@@ -17,7 +20,7 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = RemindersController.prototype as any;
describe('RemindersController — Rollen', () => {
it.each(['list', 'create'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
it.each(['list', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
@@ -34,11 +37,22 @@ describe('RemindersController — Mandant', () => {
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.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);
@@ -65,6 +79,29 @@ describe('RemindersController — Mandant', () => {
});
});
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('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 — Routen-Reihenfolge (statisch vor :id)', () => {
it('deklariert list vor jeder :id-Route', () => {
const methods = Object.getOwnPropertyNames(RemindersController.prototype);