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:
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
|
||||
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' }) {
|
||||
function makeStore(
|
||||
rows: Row[],
|
||||
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
|
||||
inactive: string[] = [],
|
||||
) {
|
||||
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
|
||||
let list = rows.filter(
|
||||
(r) =>
|
||||
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
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 }));
|
||||
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[] = [];
|
||||
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
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 (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 (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++;
|
||||
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })),
|
||||
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 };
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
return { prisma, rows, systemFindMany, boundLog };
|
||||
}
|
||||
|
||||
function makeScheduler(
|
||||
prisma: any,
|
||||
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
|
||||
) {
|
||||
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 = {
|
||||
@@ -112,7 +122,12 @@ function makeScheduler(
|
||||
return opts.sendResult ?? true;
|
||||
}),
|
||||
};
|
||||
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any);
|
||||
const scheduler = new ReminderMailScheduler(
|
||||
registry as any,
|
||||
prisma,
|
||||
settings as any,
|
||||
mail as any,
|
||||
);
|
||||
return { scheduler, registry, settings, mail };
|
||||
}
|
||||
|
||||
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
|
||||
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(
|
||||
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);
|
||||
});
|
||||
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
|
||||
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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user