6879c756f2
- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage - MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung - Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
// `forTenant` reicht den Klienten durch — die Bindung selbst prueft
|
|
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten gebunden wird.
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((p: unknown) => p),
|
|
}));
|
|
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
import { RemindersService } from './reminders.service';
|
|
|
|
function makeFakePrisma() {
|
|
const rows = new Map<string, any>();
|
|
let seq = 0;
|
|
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 };
|
|
rows.set(id, row);
|
|
return row;
|
|
}),
|
|
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
|
let list = [...rows.values()];
|
|
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
|
if (where?.userId) list = list.filter((r) => r.userId === where.userId);
|
|
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,
|
|
).length;
|
|
}),
|
|
};
|
|
const user = {
|
|
findFirst: vi.fn(async ({ where }: { where: any }) => ({
|
|
email: where.id === 'u-ohne-mail' ? null : `${where.id}@example.invalid`,
|
|
})),
|
|
};
|
|
return { reminder, user, rows };
|
|
}
|
|
|
|
function setup(smtp: unknown = { host: 'smtp.example.invalid' }) {
|
|
const prisma = makeFakePrisma();
|
|
const settings = { getSmtpConfig: vi.fn(async (_tenantId: string) => smtp) };
|
|
return { prisma, settings, service: new RemindersService(prisma as any, settings as any) };
|
|
}
|
|
|
|
const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString();
|
|
|
|
describe('RemindersService — anlegen', () => {
|
|
it('speichert tenantId und userId aus den Argumenten, nie aus dem DTO', async () => {
|
|
const { prisma, service } = setup();
|
|
await service.create('t1', 'u1', {
|
|
title: 'Zahnarzt',
|
|
dueAt: inHours(2),
|
|
tenantId: 'evil',
|
|
userId: 'evil',
|
|
} as any);
|
|
const data = prisma.reminder.create.mock.calls[0][0].data;
|
|
expect(data.tenantId).toBe('t1');
|
|
expect(data.userId).toBe('u1');
|
|
expect(data.description).toBe('');
|
|
});
|
|
|
|
it('bindet mit Mandant UND Benutzer', async () => {
|
|
const { service } = setup();
|
|
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
|
|
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u1');
|
|
});
|
|
|
|
it('lehnt eine vergangene Faelligkeit mit 400 ab', async () => {
|
|
const { service } = setup();
|
|
await expect(
|
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(-1) }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('lehnt eine Faelligkeit ueber 5 Jahre voraus mit 400 ab', async () => {
|
|
const { service } = setup();
|
|
await expect(
|
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(24 * 365 * 5 + 48) }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('die 101. Erinnerung eines Benutzers ergibt 409', async () => {
|
|
const { prisma, service } = setup();
|
|
for (let i = 0; i < 100; i++) {
|
|
prisma.rows.set(`x${i}`, { id: `x${i}`, tenantId: 't1', userId: 'u1', dueAt: new Date() });
|
|
}
|
|
await expect(
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('RemindersService — auflisten', () => {
|
|
it('liefert nur Zeilen von Mandant und Benutzer, nach Faelligkeit aufsteigend', async () => {
|
|
const { prisma, service } = setup();
|
|
await service.create('t1', 'u1', { title: 'spaet', dueAt: inHours(5) });
|
|
await service.create('t1', 'u1', { title: 'frueh', dueAt: inHours(1) });
|
|
await service.create('t1', 'u2', { title: 'fremd', dueAt: inHours(2) });
|
|
await service.create('t2', 'u1', { title: 'anderer Mandant', dueAt: inHours(2) });
|
|
const list = await service.list('t1', 'u1');
|
|
expect(list.map((r: any) => r.title)).toEqual(['frueh', 'spaet']);
|
|
const where = prisma.reminder.findMany.mock.calls[0]?.[0]?.where;
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
|
it('getEmailAvailability: SMTP eingerichtet und Adresse vorhanden', async () => {
|
|
const { service, settings } = setup();
|
|
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
|
|
smtpConfigured: true,
|
|
hasEmail: true,
|
|
});
|
|
expect(settings.getSmtpConfig).toHaveBeenCalledWith('t1');
|
|
});
|
|
|
|
it('getEmailAvailability: ohne SmtpConfig-Zeile ist der Versand nicht eingerichtet', async () => {
|
|
const { service } = setup(null);
|
|
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
|
|
smtpConfigured: false,
|
|
hasEmail: true,
|
|
});
|
|
});
|
|
|
|
it('getEmailAvailability: ohne Adresse im Konto hasEmail false, gelesen ueber den gebundenen Klienten', async () => {
|
|
const { prisma, service } = setup();
|
|
await expect(service.getEmailAvailability('t1', 'u-ohne-mail')).resolves.toEqual({
|
|
smtpConfigured: true,
|
|
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' });
|
|
});
|
|
|
|
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
|
|
const { prisma, service } = setup();
|
|
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true });
|
|
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(true);
|
|
});
|
|
|
|
it('anlegen ohne emailEnabled speichert false', async () => {
|
|
const { prisma, service } = setup();
|
|
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
|
|
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(false);
|
|
});
|
|
|
|
it('anlegen mit emailEnabled ergibt 400, wenn SMTP fehlt oder die Adresse fehlt', async () => {
|
|
const noSmtp = setup(null);
|
|
await expect(
|
|
noSmtp.service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
const noMail = setup();
|
|
await expect(
|
|
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);
|
|
});
|
|
|
|
it('aendern mit emailEnabled true ergibt 400 ohne SMTP; emailEnabled false geht immer', async () => {
|
|
const { prisma, service } = setup(null);
|
|
seed(prisma, 'x', future());
|
|
await expect(service.update('t1', 'u1', 'x', { emailEnabled: true })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
await service.update('t1', 'u1', 'x', { emailEnabled: false });
|
|
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
|
|
});
|
|
});
|