import { beforeEach, describe, expect, it, vi } from 'vitest'; /** * ReminderMailScheduler.spec (quick-260929-if2, Aufgabe 3). Ein handgebauter, * Prisma-foermiger Speicher (Konvention dieses Repos, vgl. * tender-digest.scheduler.spec.ts). Wichtig: `updateMany` prueft und setzt * OHNE `await` dazwischen — wie die eine SQL-Anweisung in der Datenbank ist der * Anspruch damit atomar, und zwei Durchlaeufe ueber DENSELBEN Speicher koennen * ihn nur einmal gewinnen (T-IF2-06). */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__tenantClient(tenantId)), forSystem: vi.fn((prisma: any) => prisma.__systemClient()), })); import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { ReminderMailScheduler } from './reminder-mail.scheduler'; const NOW = new Date('2026-09-29T12:00:00.000Z'); const minutesAgo = (m: number) => new Date(NOW.getTime() - m * 60_000); interface Row { id: string; tenantId: string; userId: string; title: string; description: string; dueAt: Date; emailEnabled: boolean; emailSentAt: Date | null; emailAttempts: number; } function row(over: Partial & { id: string }): Row { return { tenantId: 't1', userId: 'u1', title: `Titel ${over.id}`, description: '', dueAt: minutesAgo(1), emailEnabled: true, emailSentAt: null, emailAttempts: 0, ...over, }; } const sameTime = (a: Date | null, b: Date | null) => a === null || b === null ? a === b : a.getTime() === b.getTime(); function makeStore( rows: Row[], emails: Record = { u1: 'u1@example.invalid' }, inactive: string[] = [], ) { const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => { let list = rows.filter( (r) => r.emailEnabled === where.emailEnabled && r.emailSentAt === where.emailSentAt && r.emailAttempts < where.emailAttempts.lt && r.dueAt.getTime() <= where.dueAt.lte.getTime() && r.dueAt.getTime() >= where.dueAt.gte.getTime(), ); if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime()); return list .slice(0, take) .map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt })); }); const boundLog: string[] = []; const tenantClient = (tenantId: string) => ({ reminder: { updateMany: vi.fn(async ({ where, data }: any) => { boundLog.push(`updateMany:${tenantId}`); let count = 0; for (const r of rows) { if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue; if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue; if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue; if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue; if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue; if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt; if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment; count++; } return { count }; }), findFirst: vi.fn(async ({ where }: any) => { boundLog.push(`findFirst:${tenantId}`); const r = rows.find( (x) => x.id === where.id && x.tenantId === tenantId && sameTime(x.dueAt, where.dueAt), ); return r ? { title: r.title, description: r.description, dueAt: r.dueAt } : null; }), }, user: { findFirst: vi.fn(async ({ where }: any) => { const isActive = !inactive.includes(where.id); if ('isActive' in where && where.isActive !== isActive) return null; return { email: emails[where.id] ?? null }; }), }, }); const prisma: any = { __systemClient: () => ({ reminder: { findMany: systemFindMany } }), __tenantClient: tenantClient, }; return { prisma, rows, systemFindMany, boundLog }; } function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) { const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() }; const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) }; const mail = { sendReminderEmail: vi.fn(async () => { if (opts.sendResult instanceof Error) throw opts.sendResult; return opts.sendResult ?? true; }), }; const scheduler = new ReminderMailScheduler( registry as any, prisma, settings as any, mail as any, ); return { scheduler, registry, settings, mail }; } beforeEach(() => { vi.clearAllMocks(); }); describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => { it('zwei Instanzen ueber denselben Speicher senden genau eine Mail', async () => { const store = makeStore([row({ id: 'a' })]); const one = makeScheduler(store.prisma); const two = makeScheduler(store.prisma); await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]); expect( one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length, ).toBe(1); expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailAttempts).toBe(1); }); it('ein zweiter Durchlauf danach sendet nicht erneut', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler, mail } = makeScheduler(store.prisma); await scheduler.runTick(NOW); await scheduler.runTick(new Date(NOW.getTime() + 30_000)); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); }); it('ein ueberlappender Durchlauf im selben Prozess wird uebersprungen (running)', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler } = makeScheduler(store.prisma); const first = scheduler.runTick(NOW); await scheduler.runTick(NOW); // laeuft, waehrend der erste noch nicht fertig ist await first; expect(store.systemFindMany).toHaveBeenCalledTimes(1); // danach ist der Riegel wieder offen await scheduler.runTick(NOW); expect(store.systemFindMany).toHaveBeenCalledTimes(2); }); it('sendet mit Titel, Beschreibung und Faelligkeit an die Adresse des Besitzers, gebunden an dessen Mandanten', async () => { const store = makeStore([row({ id: 'a', tenantId: 't7', userId: 'u1', description: 'Text' })], { u1: 'anna@example.invalid', }); const { scheduler, mail, settings } = makeScheduler(store.prisma); await scheduler.runTick(NOW); expect(mail.sendReminderEmail).toHaveBeenCalledWith('t7', 'anna@example.invalid', { title: 'Titel a', description: 'Text', dueAt: store.rows[0].dueAt, }); expect(settings.getSmtpConfig).toHaveBeenCalledWith('t7'); expect(forTenant).toHaveBeenCalledWith(store.prisma, 't7'); }); }); describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => { it('ein Transportfehler gibt den Anspruch frei; der naechste Durchlauf versucht es erneut', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false }); await scheduler.runTick(NOW); expect(store.rows[0].emailSentAt).toBeNull(); expect(store.rows[0].emailAttempts).toBe(1); await scheduler.runTick(new Date(NOW.getTime() + 30_000)); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2); }); it('nach drei Versuchen ist die Erinnerung kein Kandidat mehr', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false }); for (let i = 0; i < 5; i++) await scheduler.runTick(new Date(NOW.getTime() + i * 30_000)); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(3); expect(store.rows[0].emailAttempts).toBe(3); }); it('wirft der Versand, wird der Anspruch ebenfalls freigegeben und der Durchlauf bricht nicht ab', async () => { const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]); const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: new Error('kaputt') }); await expect(scheduler.runTick(NOW)).resolves.toBeUndefined(); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2); expect(store.rows.every((r) => r.emailSentAt === null)).toBe(true); }); it('die Freigabe trifft nur den eigenen Anspruch (gleicher Zeitstempel)', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler } = makeScheduler(store.prisma, { sendResult: false }); const spy = vi.spyOn(store.prisma, '__tenantClient'); await scheduler.runTick(NOW); const client = spy.mock.results[0].value; const releaseCall = client.reminder.updateMany.mock.calls[1][0]; expect(releaseCall.where).toEqual({ id: 'a', tenantId: 't1', emailSentAt: NOW }); expect(releaseCall.data).toEqual({ emailSentAt: null }); }); it('ohne SmtpConfig: kein Versand, Anspruch bleibt, keine Wiederholung', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler, mail } = makeScheduler(store.prisma, { smtp: null }); await scheduler.runTick(NOW); await scheduler.runTick(new Date(NOW.getTime() + 30_000)); expect(mail.sendReminderEmail).not.toHaveBeenCalled(); expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailAttempts).toBe(1); }); it('ohne E-Mail-Adresse des Benutzers: kein Versand, Anspruch bleibt', async () => { const store = makeStore([row({ id: 'a' })], { u1: null }); const { scheduler, mail } = makeScheduler(store.prisma); await scheduler.runTick(NOW); await scheduler.runTick(new Date(NOW.getTime() + 30_000)); expect(mail.sendReminderEmail).not.toHaveBeenCalled(); expect(store.rows[0].emailSentAt).toEqual(NOW); }); it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => { const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']); const { scheduler, mail } = makeScheduler(store.prisma); const spy = vi.spyOn(store.prisma, '__tenantClient'); await scheduler.runTick(NOW); await scheduler.runTick(new Date(NOW.getTime() + 30_000)); expect(mail.sendReminderEmail).not.toHaveBeenCalled(); expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailAttempts).toBe(1); const client = spy.mock.results[0].value; expect(client.user.findFirst.mock.calls[0][0].where).toEqual({ id: 'u1', tenantId: 't1', isActive: true, }); }); }); describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => { it('die Abfrage waehlt nur emailEnabled, ohne Anspruch, unter 3 Versuchen, faellig und nicht aelter als 24 h', async () => { const store = makeStore([ row({ id: 'ok' }), row({ id: 'aus', emailEnabled: false }), row({ id: 'schon', emailSentAt: minutesAgo(1) }), row({ id: 'drei', emailAttempts: 3 }), row({ id: 'zukunft', dueAt: new Date(NOW.getTime() + 60_000) }), row({ id: 'alt', dueAt: new Date(NOW.getTime() - 25 * 3600_000) }), ]); const { scheduler, mail } = makeScheduler(store.prisma); await scheduler.runTick(NOW); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); const args = store.systemFindMany.mock.calls[0][0]; expect(args.where).toEqual({ emailEnabled: true, emailSentAt: null, emailAttempts: { lt: 3 }, dueAt: { lte: NOW, gte: new Date(NOW.getTime() - 24 * 3600_000) }, }); expect(args.take).toBe(200); expect(args.orderBy).toEqual({ dueAt: 'asc' }); }); it('der Systemklient liest nur skalar, ohne Relation', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler } = makeScheduler(store.prisma); await scheduler.runTick(NOW); expect(store.systemFindMany.mock.calls[0][0].select).toEqual({ id: true, tenantId: true, userId: true, dueAt: true, }); expect(forSystem).toHaveBeenCalledTimes(1); }); it('eine fehlerhafte Zeile haelt die uebrigen nicht an', async () => { const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]); const { scheduler, mail } = makeScheduler(store.prisma); // erste Zeile (b ist aelter, kommt zuerst): der Benutzer-Zugriff wirft let calls = 0; const original = store.prisma.__tenantClient; store.prisma.__tenantClient = (tenantId: string) => { const client = original(tenantId); client.user.findFirst = vi.fn(async () => { if (++calls === 1) throw new Error('DB weg'); return { email: 'u1@example.invalid' }; }); return client; }; await scheduler.runTick(NOW); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); }); it('nach einem Verschieben (Spuren zurueckgesetzt, neue Faelligkeit) gibt es genau eine weitere Mail (D-03)', async () => { const store = makeStore([row({ id: 'a' })]); const { scheduler, mail } = makeScheduler(store.prisma); await scheduler.runTick(NOW); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); // Wie RemindersService.snooze: neue Faelligkeit, Spuren zurueck const later = new Date(NOW.getTime() + 10 * 60_000); Object.assign(store.rows[0], { dueAt: later, emailSentAt: null, emailAttempts: 0 }); await scheduler.runTick(new Date(later.getTime() + 5_000)); await scheduler.runTick(new Date(later.getTime() + 35_000)); expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2); }); }); describe('ReminderMailScheduler — Start', () => { it('registriert genau ein Intervall "reminder-email" und ersetzt ein vorhandenes', () => { vi.useFakeTimers(); try { const store = makeStore([]); const { scheduler, registry } = makeScheduler(store.prisma); scheduler.onApplicationBootstrap(); expect(registry.deleteInterval).toHaveBeenCalledWith('reminder-email'); expect(registry.addInterval).toHaveBeenCalledTimes(1); expect(registry.addInterval.mock.calls[0][0]).toBe('reminder-email'); clearInterval(registry.addInterval.mock.calls[0][1]); } finally { vi.useRealTimers(); } }); it('ein Fehler beim Registrieren wird nur protokolliert, nie geworfen', () => { const store = makeStore([]); const { scheduler, registry } = makeScheduler(store.prisma); registry.addInterval.mockImplementation(() => { throw new Error('doppelt'); }); expect(() => scheduler.onApplicationBootstrap()).not.toThrow(); }); });