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(); 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, 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 }); }); });