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
@@ -1,4 +1,4 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant` reicht den Klienten durch — die Bindung selbst prueft
@@ -27,6 +27,19 @@ function makeFakePrisma() {
if (orderBy?.dueAt === 'asc') list.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
return list;
}),
findFirst: vi.fn(async ({ where }: { where: any }) => {
const r = rows.get(where.id);
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return null;
return r;
}),
update: vi.fn(async ({ where, data }: { where: any; data: any }) => {
const row = { ...rows.get(where.id), ...data };
rows.set(where.id, row);
return row;
}),
delete: vi.fn(async ({ where }: { where: any }) => {
rows.delete(where.id);
}),
count: vi.fn(async ({ where }: { where?: any } = {}) => {
return [...rows.values()].filter(
(r) => r.tenantId === where?.tenantId && r.userId === where?.userId,
@@ -104,3 +117,130 @@ describe('RemindersService — auflisten', () => {
expect(where).toEqual({ tenantId: 't1', userId: 'u1' });
});
});
/** Zeile direkt in den Speicher legen (auch faellige, die `create` ablehnen wuerde). */
function seed(
prisma: ReturnType<typeof makeFakePrisma>,
id: string,
dueAt: Date,
owner: { tenantId: string; userId: string } = { tenantId: 't1', userId: 'u1' },
) {
prisma.rows.set(id, {
id,
...owner,
title: 'alt',
description: 'beschr',
dueAt,
emailEnabled: false,
emailSentAt: new Date(),
emailAttempts: 2,
});
}
const past = () => new Date(Date.now() - 3600_000);
const future = () => new Date(Date.now() + 3600_000);
describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)', () => {
it.each([
['fremder Benutzer', { tenantId: 't1', userId: 'u2' }],
['fremder Mandant', { tenantId: 't2', userId: 'u1' }],
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
const { prisma, service } = setup();
seed(prisma, 'x', past(), owner);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException);
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.has('x')).toBe(true);
});
it('eine unbekannte Kennung ergibt 404', async () => {
const { service } = setup();
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException);
});
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'n' });
expect(prisma.reminder.findFirst.mock.calls[0]?.[0]?.where).toEqual({
id: 'x',
tenantId: 't1',
userId: 'u1',
});
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({
id: 'x',
tenantId: 't1',
userId: 'u1',
});
});
});
describe('RemindersService — aendern', () => {
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', past());
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException);
});
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await expect(service.update('t1', 'u1', 'x', { dueAt: inHours(-2) })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('aendert nur die gesetzten Felder', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
const newDue = inHours(9);
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({
description: '',
dueAt: new Date(newDue),
});
});
});
describe('RemindersService — spaeter erinnern (D-03)', () => {
it('eine noch nicht faellige Erinnerung laesst sich nicht verschieben (409)', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(3) })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', past());
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(-1) })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('schreibt dueAt UND setzt emailSentAt und emailAttempts zurueck', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', past());
const newDue = inHours(1);
await service.snooze('t1', 'u1', 'x', { dueAt: newDue });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
});
});
describe('RemindersService — loeschen (E-02)', () => {
it('loescht die eigene Zeile, vor und nach der Faelligkeit', async () => {
const { prisma, service } = setup();
seed(prisma, 'a', future());
seed(prisma, 'b', past());
await expect(service.remove('t1', 'u1', 'a')).resolves.toEqual({ deleted: true });
await expect(service.remove('t1', 'u1', 'b')).resolves.toEqual({ deleted: true });
expect(prisma.rows.size).toBe(0);
});
});