fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung

- 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>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent be1e0035e0
commit c2e4467dd8
14 changed files with 442 additions and 78 deletions
@@ -16,7 +16,13 @@ function makeFakePrisma() {
const reminder = {
create: vi.fn(async ({ data }: { data: any }) => {
const id = `r-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data };
const row = {
id,
createdAt: new Date(),
updatedAt: new Date(),
emailEnabled: false,
...data,
};
rows.set(id, row);
return row;
}),
@@ -37,6 +43,14 @@ function makeFakePrisma() {
rows.set(where.id, row);
return row;
}),
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
const r = rows.get(where.id);
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
rows.set(where.id, { ...r, ...data });
return { count: 1 };
}),
delete: vi.fn(async ({ where }: { where: any }) => {
rows.delete(where.id);
}),
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
).rejects.toBeInstanceOf(ConflictException);
// ein anderer Benutzer ist davon nicht betroffen
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy();
await expect(
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
).resolves.toBeTruthy();
});
});
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
])('%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.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);
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
tenantId: 't1',
userId: 'u1',
});
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
id: 'x',
tenantId: 't1',
userId: 'u1',
dueAt: { gt: expect.any(Date) },
});
});
});
@@ -185,7 +208,9 @@ 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);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
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' });
expect(prisma.reminder.updateMany.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({
expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
description: '',
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
});
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.rows.get('x').emailAttempts).toBe(2);
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
const newDue = inHours(5);
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
expect(prisma.rows.get('x')).toMatchObject({
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
expect(out.dueAt).toEqual(new Date(newDue));
});
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const r = prisma.rows.get('x');
const loaded = { ...r };
prisma.rows.set('x', { ...r, dueAt: past() });
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.rows.get('x').title).toBe('alt');
});
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const loaded = { ...prisma.rows.get('x') };
prisma.rows.delete('x');
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('RemindersService — spaeter erinnern (D-03)', () => {
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
hasEmail: false,
});
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' });
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
id: 'u-ohne-mail',
tenantId: 't1',
});
});
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
).rejects.toBeInstanceOf(BadRequestException);
const noMail = setup();
await expect(
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
noMail.service.create('t1', 'u-ohne-mail', {
title: 'a',
dueAt: inHours(1),
emailEnabled: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(noSmtp.prisma.rows.size).toBe(0);
expect(noMail.prisma.rows.size).toBe(0);
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
BadRequestException,
);
await service.update('t1', 'u1', 'x', { emailEnabled: false });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
});
});