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>
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
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<Row> & { 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<string, string | null> = { u1: 'u1@example.invalid' }) {
|
|
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) => ({ 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);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|