Files
kita-planer/tests/announcements.integration.test.cjs
T

253 lines
24 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-require-imports -- Exercise real actions with isolated request and mail boundaries. */
const { test, before, beforeEach, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm');
const ts = require('typescript'); const { randomUUID } = require('node:crypto');
const { PrismaClient } = require('@prisma/client');
const React = require('react'); const { renderToStaticMarkup } = require('react-dom/server');
const { render } = require('@react-email/render');
let prisma, current, f, service, actions, load;
const tenants = [], emails = [], afters = [];
let failedEmail = null;
const sender = async input => { emails.push(input); return input.to === failedEmail ? { success: false, error: 'synthetic failure' } : { success: true }; };
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} }; cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
fileName: file,
}).outputText;
const req = spec => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === 'server-only') return {};
if (spec === '@/lib/prisma') return { prisma };
if (spec === 'next/headers') return { headers: async () => new Headers() };
if (spec === 'next/server') return { after: callback => afters.push(callback) };
if (spec === 'next/cache') return { revalidatePath() {} };
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
if (spec.startsWith('@/') || spec.startsWith('.')) {
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const authBoundary = { requireKitaSession: async () => ({ user: current }), requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; } };
before(async () => {
const raw = process.env.TEST_DATABASE_URL;
if (!raw) throw new Error('TEST_DATABASE_URL muss auf die separate lokale kita_announcements_test zeigen.');
const url = new URL(raw); assert.ok(['127.0.0.1','localhost'].includes(url.hostname)); assert.equal(url.pathname, '/kita_announcements_test');
prisma = new PrismaClient({ datasourceUrl: raw }); process.env.AUTH_URL = 'http://127.0.0.1:3014';
load = loader({ '@/lib/auth-utils': authBoundary, '@/lib/mail': { sendAppEmail: sender } });
service = load('src/lib/announcement-delivery.ts'); actions = load('src/actions/announcements.ts');
});
after(async () => { if (prisma) { await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); await prisma.$disconnect(); } });
beforeEach(async () => {
emails.length = 0; afters.length = 0; failedEmail = null;
const kita = await prisma.kita.create({ data: { name: 'Mitteilungen Test', slug: randomUUID() } }); tenants.push(kita.id);
const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Testhaushalt' } });
f = { kita, family };
const admin = await person('ADMIN', null); const parent = await person('ELTERN', family.id);
f = { ...f, admin, parent }; current = admin;
});
async function person(role = 'ELTERN', familyId = f.family.id, extra = {}) {
return prisma.user.create({ data: { kitaId: f.kita.id, familyId, role, email: `${randomUUID()}@example.invalid`, firstName: 'Test', lastName: role,
passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), ...extra } });
}
function form(data) { const result = new FormData(); for (const [key, value] of Object.entries(data)) result.set(key, value); return result; }
async function publication(sendEmail = true, audience = 'ELTERN') {
assert.equal((await actions.createAnnouncement(form({ title: 'Sommerfest Aufbau', content: 'Am Dienstag treffen wir uns.', audience, sendEmail: sendEmail ? 'on' : 'off' }))).success, true);
return prisma.announcement.findFirstOrThrow({ where: { kitaId: f.kita.id }, orderBy: { createdAt: 'desc' } });
}
const jobs = id => prisma.announcementEmail.findMany({ where: { kitaId: f.kita.id, announcementId: id }, orderBy: { createdAt: 'asc' } });
const edit = (row, data = {}) => actions.updateAnnouncement(form({ id: row.id, revision: String(row.revision), title: row.title, content: 'Aktualisierung: Wir treffen uns am Mittwoch.', sendEmail: 'off', ...data }));
const deliver = (now = new Date()) => service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender, now);
async function flush() { while (afters.length) await afters.shift()(); }
test('Veröffentlichung speichert Versandaufträge vor der Antwort; after sendet einzeln', async () => {
const row = await publication(); assert.equal(emails.length, 0); assert.equal((await jobs(row.id)).length, 1);
await flush(); assert.equal(emails.length, 1); assert.equal(emails[0].to, f.parent.email); assert.ok((await jobs(row.id))[0].sentAt);
});
test('Eine fehlgeschlagene Adresse blockiert andere Empfänger nicht', async () => {
const partner = await person('KOORDINATOR'); const row = await publication(); failedEmail = f.parent.email;
const result = await deliver(); assert.equal(result.sent, 1); assert.equal(result.failed, 1); assert.equal(emails.length, 2);
assert.ok((await jobs(row.id)).find(job => job.userId === partner.id).sentAt);
});
test('Wiederholung verwendet denselben Auftrag und lässt erfolgreiche Sendungen aus', async () => {
await person(); const row = await publication(); failedEmail = f.parent.email; await deliver(); const first = emails.find(mail => mail.to === f.parent.email);
failedEmail = null; assert.equal((await actions.retryAnnouncementEmails(row.id, 1)).queued, 1); await flush();
assert.equal(emails.length, 3); assert.equal(emails[2].idempotencyKey, first.idempotencyKey);
assert.equal((await actions.retryAnnouncementEmails(row.id, 1)).queued, 0); assert.equal((await jobs(row.id)).filter(job => job.sentAt).length, 2);
});
test('Automatische Wiederholung wartet bis zur gespeicherten Frist', async () => {
const row = await publication(); failedEmail = f.parent.email; const now = new Date(); await deliver(now);
const job = (await jobs(row.id))[0]; assert.ok(job.nextAttemptAt > now); failedEmail = null;
assert.equal((await deliver(now)).sent, 0); assert.equal((await deliver(job.nextAttemptAt)).sent, 1);
});
test('Parallele Worker versenden eine Veröffentlichung nur einmal', async () => {
await publication(); await Promise.all([deliver(), deliver()]); assert.equal(emails.length, 1);
});
test('Eine abgelaufene Reservierung wird nach einem Worker-Abbruch übernommen', async () => {
const row = await publication(); await prisma.announcementEmail.update({ where: { id: (await jobs(row.id))[0].id }, data: { claimToken: 'aborted', leaseUntil: new Date(0) } });
assert.equal((await deliver()).sent, 1);
});
test('Neue Fassung verwirft alte ungesendete Aufträge und setzt gelesen zurück', async () => {
const row = await publication(); current = f.parent; assert.equal((await actions.markAnnouncementRead(row.id, 1)).success, true); current = f.admin;
assert.equal((await edit(row)).changed, true); const updated = await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } });
assert.equal(updated.revision, 2); assert.ok(updated.editedAt); assert.equal(updated.audience, row.audience); assert.equal(updated.authorId, row.authorId);
assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0);
assert.ok((await jobs(row.id))[0].skippedAt); await flush(); assert.equal(emails.length, 0);
});
test('Eine alte geöffnete Fassung darf eine Korrektur nicht als gelesen markieren', async () => {
const row = await publication(false); await edit(row); current = f.parent;
assert.ok((await actions.markAnnouncementRead(row.id, 1)).error); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0);
assert.equal((await actions.markAnnouncementRead(row.id, 2)).success, true);
});
test('Paralleles Lesen und Korrigieren hinterlässt keinen Lesestatus für den alten Text', async () => {
const row = await publication(false);
const reader = loader({ '@/lib/auth-utils': { requireKitaSession: async () => ({ user: f.parent }) } })('src/actions/announcements.ts');
const results = await Promise.all([reader.markAnnouncementRead(row.id, 1), edit(row)]); assert.equal(results[1].success, true);
assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0);
});
test('Veraltete und parallele Bearbeitungen überschreiben keine neuere Korrektur', async () => {
const row = await publication(false); const results = await Promise.all([edit(row), edit(row, { content: 'Eine andere Korrektur.' })]);
assert.equal(results.filter(result => result.success).length, 1); assert.equal(results.filter(result => result.error).length, 1);
assert.ok((await edit(row)).error); assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).revision, 2);
});
test('Unveränderter Text erhält Version und Lesestatus; Versand bleibt einmalig', async () => {
const row = await publication(false); current = f.parent; await actions.markAnnouncementRead(row.id, 1); current = f.admin;
const input = { content: row.content, sendEmail: 'on' }; assert.equal((await edit(row, input)).queued, 1); assert.equal((await edit(row, input)).queued, 0);
assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).revision, 1); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 1);
});
test('Korrektur-Mail ist ausdrücklich optional und versendet nur die aktuelle Fassung', async () => {
const row = await publication(); await deliver(); assert.equal((await edit(row, { sendEmail: 'on' })).queued, 1); await flush();
assert.equal(emails.length, 2); assert.match(emails[1].subject, /^Aktualisierte/); assert.ok((await render(emails[1].react)).includes('Mittwoch'));
assert.notEqual(emails[0].idempotencyKey, emails[1].idempotencyKey);
});
test('Bereits laufender Versand verhindert Änderungen bis zum Abschluss', async () => {
const row = await publication(); let release; let entered; const started = new Promise(resolve => { entered = resolve; });
const blocked = async input => { entered(); await new Promise(resolve => { release = resolve; }); emails.push(input); return { success: true }; };
const sending = service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, blocked);
await started; assert.match((await edit(row)).error, /läuft gerade/); release(); await sending; assert.equal((await edit(row)).success, true);
});
test('Rollen- und Sessionänderung entzieht die Bearbeitungsberechtigung auch bei alter Session', async () => {
const row = await publication(false); await prisma.user.update({ where: { id: f.admin.id }, data: { role: 'KOORDINATOR' } }); assert.ok((await edit(row)).error);
await prisma.user.update({ where: { id: f.admin.id }, data: { role: 'ADMIN', sessionVersion: { increment: 1 } } }); assert.ok((await edit(row)).error);
});
test('Fremde Kita kann weder bearbeiten noch Versand wiederholen oder als gelesen markieren', async () => {
const row = await publication(); const other = await prisma.kita.create({ data: { name: 'Fremd', slug: randomUUID() } }); tenants.push(other.id);
current = { ...f.admin, kitaId: other.id }; assert.ok((await edit(row)).error); assert.ok((await actions.retryAnnouncementEmails(row.id, 1)).error); assert.ok((await actions.markAnnouncementRead(row.id, 1)).error);
});
test('Zusammengesetzte Fremdschlüssel verhindern fremde Nutzer und Beiträge', async () => {
const row = await publication(false); const other = await prisma.kita.create({ data: { name: 'Fremd', slug: randomUUID() } }); tenants.push(other.id);
const foreign = await prisma.user.create({ data: { kitaId: other.id, email: `${randomUUID()}@example.invalid`, firstName: 'Fremd', lastName: 'Test', passwordHash: 'test' } });
const base = { kitaId: f.kita.id, announcementId: row.id, userId: f.parent.id, revision: 1 };
await assert.rejects(prisma.announcementEmail.create({ data: { ...base, userId: foreign.id } }), error => error.code === 'P2003');
await assert.rejects(prisma.announcementEmail.create({ data: { ...base, kitaId: other.id, userId: foreign.id } }), error => error.code === 'P2003');
});
test('Empfängergruppe und aktive Konten bestimmen die vorbereiteten Aufträge', async () => {
await person('ERZIEHER', null); await person('ELTERN', f.family.id, { emailVerifiedAt: null }); await person('ELTERN', f.family.id, { disabledAt: new Date() });
await person('ELTERN', f.family.id, { privacyPolicyAcceptedAt: null }); await person('ELTERN', f.family.id, { lockedUntil: new Date(Date.now() + 3600000) });
const parent = await publication(); assert.equal((await jobs(parent.id)).length, 1);
const team = await publication(true, 'ERZIEHER'); assert.equal((await jobs(team.id)).length, 1);
});
test('Berechtigung wird vor Wiederholung erneut geprüft', async () => {
const row = await publication(); await prisma.user.update({ where: { id: f.parent.id }, data: { role: 'ERZIEHER' } });
assert.equal((await deliver()).skipped, 1); assert.equal(emails.length, 0); assert.ok((await jobs(row.id))[0].skippedAt);
});
test('Deaktivierung und widerrufener Consent stoppen vorbereitete Sendungen', async () => {
const second = await person(); await publication(); await prisma.user.update({ where: { id: f.parent.id }, data: { disabledAt: new Date() } });
await prisma.user.update({ where: { id: second.id }, data: { privacyPolicyAcceptedAt: null } }); assert.equal((await deliver()).skipped, 2); assert.equal(emails.length, 0);
});
test('Fehlende App-Adresse bleibt als Fehler wiederholbar gespeichert', async () => {
const row = await publication(); const saved = [process.env.AUTH_URL, process.env.NEXTAUTH_URL, process.env.NEXT_PUBLIC_SITE_URL];
try { delete process.env.AUTH_URL; delete process.env.NEXTAUTH_URL; delete process.env.NEXT_PUBLIC_SITE_URL; assert.equal((await deliver()).failed, 1); assert.ok((await jobs(row.id))[0].failed); assert.equal(emails.length, 0); }
finally { ['AUTH_URL','NEXTAUTH_URL','NEXT_PUBLIC_SITE_URL'].forEach((key, i) => { if (saved[i] === undefined) delete process.env[key]; else process.env[key] = saved[i]; }); }
});
test('Der begrenzte Batch wird in weiteren Läufen vollständig abgearbeitet', async () => {
for (let i = 0; i < 14; i++) await person(); await publication(); assert.equal((await deliver()).sent, 12); assert.equal((await deliver()).sent, 3); assert.equal(emails.length, 15);
});
test('Speicherfehler bei Versandaufträgen macht die Veröffentlichung vollständig rückgängig', async () => {
const local = loader({ '@/lib/auth-utils': authBoundary, '@/lib/announcement-delivery': { queueAnnouncementEmails: async () => { throw new Error('synthetic failure'); } } })('src/actions/announcements.ts');
await assert.rejects(local.createAnnouncement(form({ title: 'Kein Teilzustand', content: 'Kein Teilzustand erlaubt.', audience: 'ELTERN', sendEmail: 'on' })));
assert.equal(await prisma.announcement.count({ where: { kitaId: f.kita.id } }), 0);
});
test('Anhänge sperren Versand bis zum vollständigen Schreiben und bleiben bei Korrektur erhalten', async () => {
let release; const blocked = new Promise(resolve => { release = resolve; });
const local = loader({ '@/lib/auth-utils': authBoundary, 'fs/promises': { mkdir: async () => {}, writeFile: async () => blocked, rm: async () => {} } })('src/actions/announcements.ts');
const data = form({ title: 'Mit Anhang', content: 'Ein Dokument mit Details.', audience: 'ELTERN', sendEmail: 'on' }); data.append('attachments', new File(['%PDF-1.4\ntest\n%%EOF'], 'info.pdf', { type: 'application/pdf' }));
const creating = local.createAnnouncement(data); let row;
for (let attempt = 0; attempt < 100; attempt++) { row = await prisma.announcement.findFirst({ where: { kitaId: f.kita.id }, include: { attachments: true } }); if (row) break; await new Promise(resolve => setTimeout(resolve, 10)); }
assert.ok(row); assert.equal(row.emailReady, false); assert.equal((await deliver()).sent, 0);
release(); assert.equal((await creating).success, true); assert.equal((await edit(row)).success, true);
assert.equal(await prisma.attachment.count({ where: { announcementId: row.id } }), 1); assert.equal((await jobs(row.id))[0].skippedAt !== null, true);
});
test('Fehler beim Schreiben des Anhangs entfernt auch vorbereitete E-Mails', async () => {
const local = loader({ '@/lib/auth-utils': authBoundary, 'fs/promises': { mkdir: async () => {}, writeFile: async () => { throw new Error('synthetic disk failure'); }, rm: async () => {} } })('src/actions/announcements.ts');
const data = form({ title: 'Kaputter Anhang', content: 'Ein Dokument mit Details.', audience: 'ELTERN', sendEmail: 'on' }); data.append('attachments', new File(['%PDF-1.4\ntest\n%%EOF'], 'info.pdf', { type: 'application/pdf' }));
assert.ok((await local.createAnnouncement(data)).error); assert.equal(await prisma.announcement.count({ where: { kitaId: f.kita.id } }), 0); assert.equal(await prisma.announcementEmail.count({ where: { kitaId: f.kita.id } }), 0);
});
test('Verwaltungsübersicht bietet Suche und Pagination für ältere Beiträge', async () => {
await prisma.announcement.createMany({ data: Array.from({ length: 14 }, (_, i) => ({ kitaId: f.kita.id, authorId: f.admin.id, title: `Meldung ${i}`, content: `Beschreibung ${i}`, createdAt: new Date(Date.now() - i * 1000) })) });
const pages = loader({ '@/lib/auth-utils': authBoundary, 'next/link': { __esModule: true, default: ({ href, children, ...props }) => React.createElement('a', { href, ...props }, children) }, './news-form': { NewsForm: () => null } });
const page = pages('src/app/dashboard/admin/news/page.tsx').default;
const first = renderToStaticMarkup(await page({ searchParams: Promise.resolve({}) })); assert.ok(first.includes('Ältere Beiträge')); assert.equal((first.match(/Bearbeiten &amp; Versand/g) || []).length, 12);
const second = renderToStaticMarkup(await page({ searchParams: Promise.resolve({ page: '2' }) })); assert.equal((second.match(/Bearbeiten &amp; Versand/g) || []).length, 2); assert.ok(second.includes('Neuere Beiträge'));
const search = renderToStaticMarkup(await page({ searchParams: Promise.resolve({ q: 'Beschreibung 13' }) })); assert.ok(search.includes('Meldung 13')); assert.ok(!search.includes('Meldung 0')); assert.ok(search.includes('value="Beschreibung 13"'));
});
test('Korrekturen älterer Beiträge erscheinen wieder vorne im ungelesenen Elternfeed', async () => {
const row = await publication(false); await prisma.announcement.update({ where: { id: row.id }, data: { createdAt: new Date('2020-01-01'), updatedAt: new Date('2020-01-01') } });
await prisma.announcement.create({ data: { kitaId: f.kita.id, authorId: f.admin.id, title: 'Neue Meldung', content: 'Neuere Meldung zum Kitaalltag.', audience: 'ELTERN' } });
await edit(row); current = f.parent;
const feed = await load('src/lib/announcements.ts').getAnnouncements({ user: f.parent }, { take: 3 });
assert.equal(feed[0].id, row.id); assert.equal(feed[0].revision, 2); assert.equal(feed[0].isUnread, true); assert.ok(feed[0].editedAt);
});
test('Nach Abbruch vor Freigabe wird ein vollständig geschriebener Anhang wieder aufgenommen', async () => {
const row = await publication(); await prisma.announcement.update({ where: { id: row.id }, data: { emailReady: false, createdAt: new Date(Date.now() - 600000), attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } });
const recovered = loader({ 'node:fs/promises': { stat: async () => ({ size: 15 }) } })('src/lib/announcement-delivery.ts');
assert.equal((await recovered.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 1); assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).emailReady, true);
});
test('Teilweise geschriebene und fehlende Anhänge geben den Versand nicht frei', async () => {
const row = await publication(); await prisma.announcement.update({ where: { id: row.id }, data: { emailReady: false, createdAt: new Date(Date.now() - 600000), attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } });
const partial = loader({ 'node:fs/promises': { stat: async () => ({ size: 3 }) } })('src/lib/announcement-delivery.ts');
assert.equal((await partial.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 0);
const missing = loader({ 'node:fs/promises': { stat: async () => { throw new Error('missing'); } } })('src/lib/announcement-delivery.ts');
assert.equal((await missing.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 0); assert.equal(emails.length, 0);
});
test('Fehlende Anhänge anderer Beiträge verdrängen eine wiederherstellbare Veröffentlichung nicht', async () => {
const old = new Date(Date.now() - 600000);
for (let i = 0; i < 13; i++) await prisma.announcement.create({ data: { id: `${f.kita.id}-unfinished-${i}`, kitaId: f.kita.id, authorId: f.admin.id, title: 'Unfertig', content: 'Ein Anhang fehlt.', emailReady: false, createdAt: old, attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } });
const row = await prisma.announcement.create({ data: { id: `${f.kita.id}-zz-complete`, kitaId: f.kita.id, authorId: f.admin.id, title: 'Fertig', content: 'Der Anhang ist vollständig.', audience: 'ELTERN', emailReady: false, createdAt: old, attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } }, emails: { create: { kitaId: f.kita.id, userId: f.parent.id, revision: 1 } } }, include: { attachments: true } });
const recovered = loader({ 'node:fs/promises': { stat: async file => { if (file.endsWith(row.attachments[0].id)) return { size: 15 }; throw new Error('missing'); } } })('src/lib/announcement-delivery.ts');
assert.equal((await recovered.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 1);
});
test('Adminrechte erhalten Haushalts- und Teammail-Zugehörigkeit; globale Admins sind keine Empfänger', async () => {
const staff = await person('ADMIN', null);
const educator = await prisma.educator.create({ data: { kitaId: f.kita.id, firstName: 'Test', lastName: 'Fachkraft' } });
await prisma.staffProfile.create({ data: { kitaId: f.kita.id, userId: staff.id, educatorId: educator.id, employmentStart: new Date('2020-01-01'), trackingStart: new Date('2020-01-01') } });
const parentAdmin = await person('ADMIN'); await person('SUPERADMIN');
const parentRow = await publication(true, 'ELTERN'); assert.deepEqual((await jobs(parentRow.id)).map(job => job.userId).sort(), [f.parent.id, parentAdmin.id].sort());
const staffRow = await publication(true, 'ERZIEHER'); assert.deepEqual((await jobs(staffRow.id)).map(job => job.userId), [staff.id]);
await deliver(); assert.ok(emails.some(mail => mail.to === staff.email));
});
test('Drei parallele Worker verarbeiten viele Empfänger ohne Konflikte oder doppelte Sendungen', async () => {
for (let i = 0; i < 23; i++) await person(); const row = await publication();
const slow = async input => { await new Promise(resolve => setTimeout(resolve, 10)); return sender(input); };
await Promise.all(Array.from({ length: 3 }, () => service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, slow)));
await deliver(); await deliver();
assert.equal(emails.length, 24); assert.equal(new Set(emails.map(mail => mail.to)).size, 24); assert.equal((await jobs(row.id)).filter(job => job.sentAt).length, 24);
});