import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { mailSink } from './support/mailSink.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' // Mail-Warteschlange (termineOutbox): Nachbau von Appwrite mit Schema und Datumsfeldern, Standard-Limit 25 wie in // Produktion. Der Mailtransport ist ein Fake, der Sendeversuche zaehlt und gezielt Fehler wirft (nodemailer-Fehler // tragen `code` und SMTP-`responseCode`). Alle Zeiten sind feste Parameter. const aw = await startAppwriteMock({ schema: { termineOutbox: schemaKeys('termineOutbox') }, datetimes: TERMINE_DATETIMES, defaultLimit: 25, }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { createDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { loadTemplate } = await import('../server/services/mailTemplates.js') const { icsTermin } = await import('../server/services/termine/ics.js') const O = await import('../server/services/termine/outbox.js') const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O const { MAIL_VORRANG, MAIL_NACHRANGIG, mailKlasse, pruefePlatz, mailBudget } = O after(() => setMailTransportForTests(null)) const MIN = 60000 const STUNDE = 60 * MIN const TAG = 24 * STUNDE const JETZT = new Date('2026-10-05T08:00:00.000Z') const plus = (ms, von = JETZT) => new Date(von.getTime() + ms) const ms = (wert) => new Date(wert).getTime() const col = () => config.collections.termineOutbox /** Transport: zaehlt Aufrufe, wirft die Fehler aus `fehler` der Reihe nach (leer = Erfolg), sonst landet die Mail in der Senke. */ function fakeTransport() { const senke = mailSink() const transport = { senke, aufrufe: 0, fehler: [], immer: null, verzoegerungMs: 0, async sendMail(mail) { transport.aufrufe += 1 if (transport.verzoegerungMs) await new Promise((resolve) => setTimeout(resolve, transport.verzoegerungMs)) const fehler = transport.fehler.shift() ?? transport.immer if (fehler) throw fehler return senke.sendMail(mail) }, } return transport } const smtp = (responseCode, code = 'EENVELOPE') => Object.assign(new Error(`${responseCode} Testfehler`), { responseCode, code }) const EAUTH = Object.assign(new Error('Invalid login: 535 Authentication failed'), { code: 'EAUTH', responseCode: 535 }) const NUR_535 = Object.assign(new Error('535 5.7.8 Authentication failed'), { responseCode: 535 }) const ETIMEDOUT = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' }) const ABSENDER_VORHER = config.smtp.from let tr let log beforeEach(async () => { config.smtp.from = 'noreply@webklar.com' for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(col(), id) resetOutboxForTests() config.termine.mailsProStunde = 200 config.termine.mailsProFunktion = 40 config.termine.mailsReserve = 0.25 tr = fakeTransport() setMailTransportForTests(tr) // Fehler-/Warnlogs der Warteschlange abfangen (Testausgabe bleibt sauber, Logs sind pruefbar) log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } }) afterEach(async () => { await warteAufVersand() mock.restoreAll() config.termine.mailsProStunde = 200 config.termine.mailsProFunktion = 40 config.termine.mailsReserve = 0.25 config.smtp.from = ABSENDER_VORHER }) const werte = (id, extra = {}) => ({ ...Object.fromEntries(loadTemplate(id).placeholders.map((p) => [p, `Wert-${p}`])), ...extra }) const eintrag = (extra = {}) => ({ featureId: 'f_feature01', bookingId: 'b_buchung01', art: 'G01', an: 'anna@example.com', fromName: 'Salon Muster', replyTo: 'info@salon-muster.de', template: 'G01', values: werte('G01', { betreff: 'Bitte bestätigen Sie Ihren Termin bei Salon Muster' }), blocks: { sie: true, bestaetigen: true }, ...extra, }) const kalender = (methode) => icsTermin({ uid: 'b_buchung01@termine.webklar.com', sequenz: 0, methode, start: '2026-10-12T10:00:00.000+02:00', ende: new Date('2026-10-12T08:45:00.000Z'), titel: 'Haarschnitt – Salon Muster', ort: 'Hauptstraße 1, 67655 Kaiserslautern', organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' }, teilnehmer: { name: 'Anna Müller', email: 'anna@example.com' }, jetzt: JETZT, }) /** Eintrag direkt in der Datenbank (wartet), ohne Sendeversuch. */ const legeAn = (extra = {}) => createDocument(col(), { featureId: 'f_feature01', bookingId: 'b_direkt', art: 'G05', an: 'anna@example.com', fromName: 'Salon Muster', replyTo: 'info@salon-muster.de', subject: 'Erinnerung: Ihr Termin', html: '

Hallo

', text: 'Hallo', ics: '', icsMethod: '', status: 'wartet', versuche: 0, naechsterVersuch: JETZT.toISOString(), fehler: '', createdAt: JETZT.toISOString(), ...extra, }) const lies = async (id) => aw.docs('termineOutbox').get(id) const alle = () => [...aw.docs('termineOutbox').values()] // --------------------------------------------------------------------------------------------------------------------- // Einreihen und Senden // --------------------------------------------------------------------------------------------------------------------- test('einreihen: rendert sofort, speichert wartet und sendet danach im Hintergrund (From = Betrieb, replyTo, nie BCC)', async () => { const doc = await einreihen(eintrag(), JETZT) assert.equal(doc.status, 'wartet') assert.equal(doc.versuche, 0) assert.equal(tr.aufrufe, 0, 'einreihen wartet nicht auf den Versand') assert.deepEqual( { featureId: doc.featureId, bookingId: doc.bookingId, art: doc.art, an: doc.an, fromName: doc.fromName, replyTo: doc.replyTo }, { featureId: 'f_feature01', bookingId: 'b_buchung01', art: 'G01', an: 'anna@example.com', fromName: 'Salon Muster', replyTo: 'info@salon-muster.de' }, ) assert.equal(doc.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Muster') assert.match(doc.html, /Wert-leistung/, 'Vorlage ist gerendert gespeichert') assert.match(doc.text, /Wert-leistung/) assert.equal(ms(doc.createdAt), JETZT.getTime()) assert.equal(ms(doc.naechsterVersuch), JETZT.getTime()) await warteAufVersand() assert.equal(tr.senke.sent.length, 1) const [mail] = tr.senke.sent assert.deepEqual(mail.from, { address: 'noreply@webklar.com', name: 'Salon Muster' }) assert.deepEqual(mail.to, [{ address: 'anna@example.com', name: '' }]) assert.deepEqual(mail.replyTo, [{ address: 'info@salon-muster.de', name: '' }]) assert.equal(mail.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Muster') assert.equal(mail.html, doc.html) assert.equal(mail.text, doc.text) assert.equal('bcc' in mail, false) assert.equal('icalEvent' in mail, false) }) test('Erfolg: Status gesendet, an/html/text/ics geleert, Betreff und Absender bleiben; Löschtermin 7 Tage nach dem Versand', async () => { const doc = await einreihen(eintrag({ template: 'G02', art: 'G02', values: werte('G02', { betreff: 'Ihr Termin' }), blocks: { sie: true }, ics: kalender('REQUEST') }), JETZT) await warteAufVersand() const danach = await lies(doc.$id) assert.equal(danach.status, 'gesendet') assert.equal(danach.versuche, 1) assert.equal(ms(danach.gesendetAt), JETZT.getTime()) assert.equal(ms(danach.naechsterVersuch), JETZT.getTime() + 7 * TAG) for (const feld of ['an', 'html', 'text', 'ics']) assert.equal(danach[feld] || '', '', `${feld} ist geleert`) assert.equal(danach.subject, 'Ihr Termin') assert.equal(danach.fromName, 'Salon Muster') assert.equal(danach.bookingId, 'b_buchung01') assert.equal(danach.art, 'G02') }) test('Kalenderdatei geht mit ihrer Methode als icalEvent raus (REQUEST, CANCEL, PUBLISH)', async () => { for (const methode of ['REQUEST', 'CANCEL', 'PUBLISH']) { const ics = kalender(methode) await einreihen(eintrag({ bookingId: `b_${methode}`, ics, icsMethod: methode }), JETZT) } await warteAufVersand() assert.equal(tr.senke.sent.length, 3) assert.deepEqual(tr.senke.sent.map((m) => m.icalEvent.method).sort(), ['CANCEL', 'PUBLISH', 'REQUEST']) for (const mail of tr.senke.sent) assert.match(mail.icalEvent.content, new RegExp(`METHOD:${mail.icalEvent.method}`)) }) test('einreihen weist falsche Eingaben ab und speichert nichts (fehlende Felder, ics-Methode passt nicht oder ist unbekannt)', async () => { for (const feld of ['featureId', 'bookingId', 'art', 'an', 'fromName', 'template']) { await assert.rejects(() => einreihen(eintrag({ [feld]: '' }), JETZT), TypeError, feld) } await assert.rejects(() => einreihen(eintrag({ ics: kalender('REQUEST'), icsMethod: 'CANCEL' }), JETZT), TypeError, 'METHOD in ics = REQUEST, icsMethod = CANCEL') await assert.rejects(() => einreihen(eintrag({ ics: kalender('REQUEST'), icsMethod: 'REPLY' }), JETZT), TypeError, 'unbekannte Methode') await assert.rejects(() => einreihen(eintrag(), 'gestern'), TypeError, 'now muss ein Date sein') assert.equal(alle().length, 0) await warteAufVersand() assert.equal(tr.aufrufe, 0) }) test('Vorlagenprobleme (fehlender Wert) landen im Log, die Mail wird trotzdem gespeichert und gesendet', async () => { const unvollstaendig = werte('G01', { betreff: 'Betreff' }) delete unvollstaendig.leistung await einreihen(eintrag({ values: unvollstaendig }), JETZT) await warteAufVersand() assert.ok(log.warn.mock.calls.some((call) => /G01.*leistung/.test(call.arguments.join(' '))), 'Warnung zu {{leistung}}') assert.equal(tr.senke.sent.length, 1) }) test('sendeEintrag meldet das Ergebnis: gesendet, uebersprungen (unbekannt oder schon erledigt)', async () => { const doc = await legeAn() assert.equal(await sendeEintrag(doc.$id, JETZT), 'gesendet') assert.equal(await sendeEintrag(doc.$id, JETZT), 'uebersprungen', 'zweiter Aufruf: nichts mehr zu tun') assert.equal(await sendeEintrag('gibt-es-nicht', JETZT), 'uebersprungen') assert.equal(tr.aufrufe, 1) }) test('verarbeite sendet nur fällige wartet-Einträge, älteste zuerst; gesendet und fehler bleiben unberührt', async () => { const faellig = await legeAn({ bookingId: 'b_faellig', an: 'faellig@example.com', naechsterVersuch: plus(-5 * MIN).toISOString(), createdAt: plus(-9 * MIN).toISOString() }) const aelter = await legeAn({ bookingId: 'b_aelter', an: 'aelter@example.com', naechsterVersuch: plus(-1 * MIN).toISOString(), createdAt: plus(-30 * MIN).toISOString() }) const spaeter = await legeAn({ bookingId: 'b_spaeter', naechsterVersuch: plus(MIN).toISOString() }) const fertig = await legeAn({ bookingId: 'b_fertig', status: 'gesendet', an: '', html: '', text: '' }) const kaputt = await legeAn({ bookingId: 'b_kaputt', status: 'fehler' }) const ergebnis = await verarbeite(JETZT) assert.deepEqual(ergebnis, { faellig: 2, gesendet: 2 }) assert.equal(tr.aufrufe, 2) assert.deepEqual(tr.senke.sent.map((m) => m.to[0].address), ['aelter@example.com', 'faellig@example.com'], 'älteste zuerst') assert.equal((await lies(faellig.$id)).status, 'gesendet') assert.equal((await lies(aelter.$id)).status, 'gesendet') assert.equal((await lies(spaeter.$id)).status, 'wartet') assert.equal((await lies(fertig.$id)).status, 'gesendet') assert.equal((await lies(kaputt.$id)).status, 'fehler') await assert.rejects(() => verarbeite('jetzt'), TypeError) }) test('verarbeite liest alle Seiten (mehr als 25 fällige Einträge)', async () => { for (let i = 0; i < 30; i++) await legeAn({ bookingId: `b_${i}` }) const ergebnis = await verarbeite(JETZT) assert.deepEqual(ergebnis, { faellig: 30, gesendet: 30 }) assert.equal(tr.senke.sent.length, 30) }) // --------------------------------------------------------------------------------------------------------------------- // Stundenbudget // --------------------------------------------------------------------------------------------------------------------- test('Stundenbudget 2: der dritte Eintrag wartet (unverändert, ohne Versuch) und geht erst nach einer Stunde raus', async () => { config.termine.mailsProStunde = 2 for (const nummer of [1, 2, 3]) await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT) await warteAufVersand() assert.equal(tr.senke.sent.length, 2) const zustaende = alle().map((d) => d.status).sort() assert.deepEqual(zustaende, ['gesendet', 'gesendet', 'wartet']) const wartend = alle().find((d) => d.status === 'wartet') assert.equal(wartend.versuche, 0) assert.ok(wartend.an && wartend.html, 'Inhalt bleibt erhalten') assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 1, gesendet: 0 }) assert.equal(tr.senke.sent.length, 2, 'innerhalb der Stunde bleibt es bei zwei Mails') assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 1, gesendet: 1 }) assert.equal(tr.senke.sent.length, 3) assert.equal(tr.aufrufe, 3, 'nur gesendete Mails zaehlen, kein Versuch fuer den wartenden Eintrag') }) test('Stundenbudget ist ein gleitendes Fenster und zählt nur Mails, die wirklich rausgingen', async () => { config.termine.mailsProStunde = 2 await einreihen(eintrag({ bookingId: 'b_a' }), JETZT) await einreihen(eintrag({ bookingId: 'b_b' }), plus(30 * MIN)) await warteAufVersand() await einreihen(eintrag({ bookingId: 'b_c' }), plus(61 * MIN)) // a ist raus aus dem Fenster, b nicht: ein Platz frei await einreihen(eintrag({ bookingId: 'b_d' }), plus(61 * MIN)) await warteAufVersand() assert.equal(tr.senke.sent.length, 3) assert.deepEqual(alle().filter((d) => d.status === 'wartet').map((d) => d.bookingId), ['b_d']) await verarbeite(plus(89 * MIN)) assert.equal(tr.senke.sent.length, 3, 'b ist noch im Fenster (bis 90 Minuten)') await verarbeite(plus(90 * MIN)) assert.equal(tr.senke.sent.length, 4) }) test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verbindungsfehler)', async () => { config.termine.mailsProStunde = 1 tr.fehler.push(smtp(451), ETIMEDOUT) const doc = await einreihen(eintrag(), JETZT) await warteAufVersand() await verarbeite(plus(MIN)) // zweiter Fehlversuch assert.equal(tr.aufrufe, 2) assert.equal((await lies(doc.$id)).status, 'wartet') await verarbeite(plus(6 * MIN)) // gelingt: der einzige Platz war frei assert.equal(tr.senke.sent.length, 1) assert.equal((await lies(doc.$id)).status, 'gesendet') }) // --------------------------------------------------------------------------------------------------------------------- // Mail-Fairness: Kontingent je Funktion, Vorrang-Reserve // --------------------------------------------------------------------------------------------------------------------- /** Eintrag einer bestimmten Funktion und Art (Vorlage bleibt G01: die Warteschlange prueft Art und Vorlage nicht gegeneinander). */ const von = (featureId, art, nummer, extra = {}) => eintrag({ featureId, art, bookingId: `b_${featureId}_${art}_${nummer}`, ...extra }) test('mailKlasse: G01/G02/G08 vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => { assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08']) assert.deepEqual(MAIL_NACHRANGIG, ['G05', 'K20', 'K21']) const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', K20: 'nachrangig', K21: 'nachrangig' } assert.deepEqual(Object.fromEntries(Object.keys(erwartet).map((art) => [art, mailKlasse(art)])), erwartet) assert.equal(mailKlasse('unbekannt'), 'normal') }) test('Kontingent je Funktion: die Funktion A bekommt 3 von 5 Mails, B ist unberührt, nach einer Stunde geht der Rest raus', async () => { config.termine.mailsProFunktion = 3 for (let i = 1; i <= 5; i++) await einreihen(von('f_A', 'G02', i), JETZT) await warteAufVersand() assert.equal(tr.senke.sent.length, 3) const wartendA = alle().filter((d) => d.status === 'wartet') assert.equal(wartendA.length, 2) for (const d of wartendA) { assert.equal(d.versuche, 0, 'ein gebremster Eintrag ist kein Versuch') assert.equal(d.fehler, '') assert.ok(d.an && d.html && d.text, 'Inhalt bleibt erhalten') assert.equal(ms(d.naechsterVersuch), JETZT.getTime()) } assert.equal(tr.aufrufe, 3, 'kein Transportaufruf für die gebremsten Einträge') for (let i = 1; i <= 2; i++) await einreihen(von('f_B', 'G02', i), plus(MIN)) await warteAufVersand() assert.equal(tr.senke.sent.length, 5, 'Funktion B ist vom Kontingent von A nicht betroffen') assert.deepEqual(alle().filter((d) => d.status === 'gesendet' && d.featureId === 'f_B').length, 2) assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 2, gesendet: 0 }, 'A steht bis 60 Minuten nach dem ersten Versand') assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 2, gesendet: 2 }) assert.equal(tr.senke.sent.length, 7) assert.equal(alle().filter((d) => d.status === 'wartet').length, 0) }) test('verarbeite überspringt Einträge einer ausgeschöpften Funktion und bedient die Funktionen dahinter (älteste zuerst)', async () => { config.termine.mailsProFunktion = 2 for (let i = 0; i < 4; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, createdAt: plus(i * 1000).toISOString() }) for (let i = 0; i < 2; i++) await legeAn({ featureId: 'f_B', art: 'G02', bookingId: `b_b${i}`, createdAt: plus(10000 + i * 1000).toISOString() }) assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 6, gesendet: 4 }) const gesendet = alle().filter((d) => d.status === 'gesendet') assert.deepEqual(gesendet.map((d) => d.bookingId).sort(), ['b_a0', 'b_a1', 'b_b0', 'b_b1']) const wartend = alle().filter((d) => d.status === 'wartet') assert.deepEqual(wartend.map((d) => d.bookingId).sort(), ['b_a2', 'b_a3']) assert.ok(wartend.every((d) => d.versuche === 0)) }) test('Vorrang-Reserve: nachrangige Mails (G05, K20, K21) nehmen höchstens 6 von 8 Plätzen, die letzten gehören dem Vorrang', async () => { config.termine.mailsProStunde = 8 for (let i = 1; i <= 10; i++) await einreihen(von('f_A', 'G05', i), JETZT) await warteAufVersand() assert.equal(tr.senke.sent.length, 6) assert.equal(alle().filter((d) => d.status === 'wartet').length, 4) await einreihen(von('f_A', 'G01', 1), plus(MIN)) await warteAufVersand() assert.equal(tr.senke.sent.length, 7, 'Platz 7 geht an den Vorrang') assert.deepEqual(await verarbeite(plus(2 * MIN)), { faellig: 4, gesendet: 0 }, 'das siebte G05 wartet, obwohl noch ein Platz frei ist') assert.equal(tr.senke.sent.length, 7) assert.ok(alle().filter((d) => d.status === 'wartet').every((d) => d.versuche === 0 && d.html)) await einreihen(von('f_A', 'G08', 1), plus(3 * MIN)) await warteAufVersand() assert.equal(tr.senke.sent.length, 8, 'Platz 8 geht an den Vorrang') await einreihen(von('f_A', 'G03', 1), plus(4 * MIN)) await warteAufVersand() assert.equal(tr.senke.sent.length, 8, 'das Gesamtbudget gilt weiter') assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 5, gesendet: 5 }, 'nach einer Stunde fließt alles wieder (4 G05 und die G03)') }) test('Vorrang-Reserve: K20 und K21 zählen wie G05; G03/G04/G06/G07 (normal) werden von der Reserve nicht gebremst', async () => { config.termine.mailsProStunde = 8 await einreihen(von('f_A', 'G05', 1), JETZT) await einreihen(von('f_A', 'K20', 1), JETZT) await einreihen(von('f_A', 'K21', 1), JETZT) await einreihen(von('f_A', 'G05', 2), JETZT) await einreihen(von('f_A', 'K20', 2), JETZT) await einreihen(von('f_A', 'K21', 2), JETZT) await einreihen(von('f_A', 'K21', 3), JETZT) await warteAufVersand() assert.equal(tr.senke.sent.length, 6, 'höchstens 6 nachrangige') for (const art of ['G03', 'G04']) await einreihen(von('f_A', art, 1), JETZT) await warteAufVersand() assert.equal(tr.senke.sent.length, 8, 'die Reserve bremst normale Mails nicht') }) test('pruefePlatz: Reihenfolge Pause, Gesamtbudget, Reserve, Kontingent (nur lesen, belegt nichts)', async () => { config.termine.mailsProStunde = 4 // nachrangig höchstens 3 config.termine.mailsProFunktion = 2 assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G01'), null) assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G01'), null, 'Prüfen belegt keinen Platz') await einreihen(von('f_A', 'G05', 1), JETZT) await einreihen(von('f_A', 'G05', 2), JETZT) await warteAufVersand() assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G01'), 'kontingent', 'Funktion A hat 2 von 2') assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G05'), 'kontingent', '2 nachrangige von höchstens 3: die Reserve greift noch nicht, das Kontingent schon') assert.equal(pruefePlatz(JETZT.getTime(), 'f_B', 'G05'), null, '2 nachrangige von höchstens 3') await einreihen(von('f_B', 'G05', 1), JETZT) await warteAufVersand() assert.equal(pruefePlatz(JETZT.getTime(), 'f_C', 'K21'), 'reserve', '3 nachrangige von höchstens 3') assert.equal(pruefePlatz(JETZT.getTime(), 'f_C', 'G02'), null, 'Vorrang darf noch') await einreihen(von('f_C', 'G02', 1), JETZT) await warteAufVersand() assert.equal(pruefePlatz(JETZT.getTime(), 'f_D', 'G01'), 'budget', '4 von 4') assert.equal(pruefePlatz(JETZT.getTime(), 'f_D', 'G05'), 'budget', 'Gesamtbudget vor Reserve') tr.fehler.push(EAUTH) await einreihen(von('f_D', 'G01', 1), plus(61 * MIN)) await warteAufVersand() assert.equal(pruefePlatz(plus(62 * MIN).getTime(), 'f_E', 'G01'), 'pause', 'Pause vor allem anderen') assert.equal(pruefePlatz(plus(77 * MIN).getTime(), 'f_E', 'G01'), null, 'Pause vorbei (15 Minuten)') }) test('gleitendes Fenster je Funktion: 59 Minuten nach dem Versand noch belegt, nach 60 wieder frei', async () => { config.termine.mailsProFunktion = 1 await einreihen(von('f_A', 'G02', 1), JETZT) await warteAufVersand() await einreihen(von('f_A', 'G02', 2), plus(10 * MIN)) await warteAufVersand() assert.equal(tr.senke.sent.length, 1) assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 1, gesendet: 0 }) assert.equal(pruefePlatz(plus(59 * MIN).getTime(), 'f_A', 'G02'), 'kontingent') assert.equal(pruefePlatz(plus(60 * MIN).getTime(), 'f_A', 'G02'), null) assert.deepEqual(await verarbeite(plus(60 * MIN)), { faellig: 1, gesendet: 1 }) }) test('Fehlversuche (4xx, Verbindung) und Anmeldepause belegen weder einen Gesamt- noch einen Funktionsplatz', async () => { config.termine.mailsProFunktion = 1 config.termine.mailsProStunde = 1 tr.fehler.push(smtp(451), EAUTH) const a = await einreihen(von('f_A', 'G02', 1), JETZT) await warteAufVersand() assert.equal((await lies(a.$id)).versuche, 1) assert.equal(mailBudget(JETZT).stunde.benutzt, 0, '4xx belegt nichts') await verarbeite(plus(MIN)) // Anmeldefehler: Pause, kein Fehlversuch assert.equal(mailBudget(plus(MIN)).stunde.benutzt, 0, 'die Anmeldepause belegt nichts') assert.deepEqual(mailBudget(plus(MIN)).spitzen, []) assert.equal((await verarbeite(plus(16 * MIN))).gesendet, 1, 'der einzige Platz der Funktion und der Stunde war frei') assert.equal((await lies(a.$id)).status, 'gesendet') }) test('gleichzeitige Versuche einer Funktion halten das Kontingent: 6 Einträge, Kontingent 3 ⇒ genau 3 Transportaufrufe', async () => { config.termine.mailsProFunktion = 3 tr.verzoegerungMs = 20 const ids = [] for (let i = 0; i < 6; i++) ids.push((await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_${i}` })).$id) const antworten = await Promise.all(ids.map((id) => sendeEintrag(id, JETZT))) assert.equal(tr.aufrufe, 3) assert.equal(antworten.filter((a) => a === 'gesendet').length, 3) assert.equal(antworten.filter((a) => a === 'wartet').length, 3) assert.equal(alle().filter((d) => d.status === 'gesendet').length, 3) }) test('mailBudget: Zahlen und Funktions-IDs, Spitzen sortiert und auf 3 begrenzt, nie eine Adresse', async () => { for (let i = 1; i <= 2; i++) await einreihen(von('f_A', 'G02', i), JETZT) await einreihen(von('f_A', 'G05', 1), JETZT) for (let i = 1; i <= 2; i++) await einreihen(von('f_B', 'G01', i), JETZT) await warteAufVersand() assert.equal(tr.senke.sent.length, 5) assert.deepEqual(mailBudget(plus(MIN)), { stunde: { benutzt: 5, max: 200 }, nachrangig: { benutzt: 1, max: 150 }, jeFunktionMax: 40, spitzen: [{ featureId: 'f_A', benutzt: 3 }, { featureId: 'f_B', benutzt: 2 }], }) assert.ok(!JSON.stringify(mailBudget(JETZT)).includes('@'), 'keine Adresse') for (const [id, anzahl] of [['f_C', 4], ['f_D', 1]]) { for (let i = 1; i <= anzahl; i++) await einreihen(von(id, 'G03', i), plus(MIN)) } await warteAufVersand() const budget = mailBudget(plus(2 * MIN)) assert.equal(budget.stunde.benutzt, 10) assert.deepEqual(budget.spitzen, [{ featureId: 'f_C', benutzt: 4 }, { featureId: 'f_A', benutzt: 3 }, { featureId: 'f_B', benutzt: 2 }], 'höchstens 3, größte zuerst') config.termine.mailsProStunde = 8 config.termine.mailsProFunktion = 5 config.termine.mailsReserve = 0.5 const neu = mailBudget(plus(2 * MIN)) assert.deepEqual([neu.stunde.max, neu.nachrangig.max, neu.jeFunktionMax], [8, 4, 5], 'liest die Werte bei jedem Aufruf') assert.equal(mailBudget(plus(60 * MIN)).stunde.benutzt, 5, 'A und B (Versand um 08:00) sind nach 60 Minuten raus, C und D (08:01) noch nicht') assert.deepEqual(mailBudget(plus(61 * MIN)), { stunde: { benutzt: 0, max: 8 }, nachrangig: { benutzt: 0, max: 4 }, jeFunktionMax: 5, spitzen: [] }) assert.throws(() => mailBudget('jetzt'), TypeError) }) test('Reserve: mit sehr kleinem Stundenbudget darf mindestens eine nachrangige Mail raus', async () => { config.termine.mailsProStunde = 1 assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G05'), null) assert.equal(mailBudget(JETZT).nachrangig.max, 1) config.termine.mailsProStunde = 3 config.termine.mailsReserve = 0.9 assert.equal(mailBudget(JETZT).nachrangig.max, 1, 'floor(3 × 0.1) = 0 ⇒ mindestens 1') }) // --------------------------------------------------------------------------------------------------------------------- // Fehlerklassen // --------------------------------------------------------------------------------------------------------------------- test('4xx: erneut nach 1, 5 und 30 Minuten (ab dem jeweils letzten Versuch), nach dem vierten Fehlschlag fehler', async () => { tr.immer = smtp(451) const doc = await einreihen(eintrag(), JETZT) await warteAufVersand() assert.equal(tr.aufrufe, 1) let d = await lies(doc.$id) assert.equal(d.status, 'wartet') assert.equal(d.versuche, 1) assert.equal(ms(d.naechsterVersuch), JETZT.getTime() + 1 * MIN) assert.match(d.fehler, /451/) await verarbeite(plus(59 * 1000)) assert.equal(tr.aufrufe, 1, 'vor Ablauf der Minute kein neuer Versuch') await verarbeite(plus(1 * MIN)) assert.equal(tr.aufrufe, 2) d = await lies(doc.$id) assert.equal(d.versuche, 2) assert.equal(ms(d.naechsterVersuch), plus(6 * MIN).getTime(), '5 Minuten nach dem zweiten Versuch') await verarbeite(plus(5 * MIN)) assert.equal(tr.aufrufe, 2) await verarbeite(plus(6 * MIN)) assert.equal(tr.aufrufe, 3) d = await lies(doc.$id) assert.equal(d.status, 'wartet') assert.equal(ms(d.naechsterVersuch), plus(36 * MIN).getTime(), '30 Minuten nach dem dritten Versuch') await verarbeite(plus(35 * MIN)) assert.equal(tr.aufrufe, 3) await verarbeite(plus(36 * MIN)) assert.equal(tr.aufrufe, 4) d = await lies(doc.$id) assert.equal(d.status, 'fehler') assert.equal(d.versuche, 4) assert.match(d.fehler, /451/) assert.equal(ms(d.naechsterVersuch), plus(36 * MIN).getTime() + 48 * STUNDE, 'Löschtermin 48 Stunden nach dem endgültigen Fehler') assert.ok(d.an && d.html, 'bei fehler bleibt der Inhalt bis zum Löschen (Portal zeigt den Hinweis)') await verarbeite(plus(3 * TAG)) assert.equal(tr.aufrufe, 4, 'danach kein weiterer Versuch') assert.equal(log.error.mock.calls.length >= 1, true, 'endgültiger Fehler steht im Log') }) test('Verbindungsfehler ohne SMTP-Antwort werden wie 4xx wiederholt; der zweite Versuch kann gelingen', async () => { tr.fehler.push(ETIMEDOUT) const doc = await einreihen(eintrag(), JETZT) await warteAufVersand() let d = await lies(doc.$id) assert.equal(d.status, 'wartet') assert.equal(d.versuche, 1) assert.match(d.fehler, /ETIMEDOUT/) await verarbeite(plus(MIN)) d = await lies(doc.$id) assert.equal(d.status, 'gesendet') assert.equal(d.versuche, 2) assert.equal(ms(d.gesendetAt), plus(MIN).getTime()) assert.equal(d.an || '', '') assert.equal(tr.senke.sent.length, 1) }) test('nicht konfigurierter SMTP-Server ist ein vorübergehender Fehler (wartet, kein Absturz im Hintergrund)', async () => { setMailTransportForTests(null) const doc = await einreihen(eintrag(), JETZT) await warteAufVersand() const d = await lies(doc.$id) assert.equal(d.status, 'wartet') assert.equal(d.versuche, 1) assert.match(d.fehler, /SMTP/) }) test('550 und andere 5xx (außer 535): sofort fehler, kein zweiter Versuch', async () => { for (const [nummer, fehler] of [[550, smtp(550)], [554, smtp(554, 'EMESSAGE')], [500, smtp(500)]]) { tr.immer = fehler const doc = await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT) await warteAufVersand() const d = await lies(doc.$id) assert.equal(d.status, 'fehler', String(nummer)) assert.equal(d.versuche, 1) assert.match(d.fehler, new RegExp(String(nummer))) assert.equal(ms(d.naechsterVersuch), JETZT.getTime() + 48 * STUNDE) } const vorher = tr.aufrufe await verarbeite(plus(2 * TAG)) assert.equal(tr.aufrufe, vorher) }) test('ungültige Eingabe (TypeError aus sendGuestMail) ist endgültig: fehler ohne Versuch am Transport', async () => { const doc = await einreihen(eintrag({ an: 'keine-adresse' }), JETZT) await warteAufVersand() const d = await lies(doc.$id) assert.equal(d.status, 'fehler') assert.equal(d.versuche, 1) assert.equal(tr.aufrufe, 0) await verarbeite(plus(TAG)) assert.equal(tr.aufrufe, 0) }) for (const [name, fehler] of [['EAUTH', EAUTH], ['535 ohne Code', NUR_535]]) { test(`Anmeldefehler (${name}): 15 Minuten Pause für alle, Einträge bleiben wartet, in der Pause keine Sendeversuche, danach weiter`, async () => { tr.fehler.push(fehler) const a = await einreihen(eintrag({ bookingId: 'b_a' }), JETZT) await warteAufVersand() assert.equal(tr.aufrufe, 1) const da = await lies(a.$id) assert.equal(da.status, 'wartet') assert.equal(da.versuche, 0, 'Anmeldefehler ist kein Fehlversuch dieser Mail') assert.equal(ms(da.naechsterVersuch), JETZT.getTime()) assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime()) assert.ok(log.error.mock.calls.some((call) => /pausiert/i.test(call.arguments.join(' '))), 'Pause steht im Log') // neue Einträge und Jobläufe in der Pause: kein einziger Sendeversuch const b = await einreihen(eintrag({ bookingId: 'b_b' }), plus(1 * MIN)) await warteAufVersand() await verarbeite(plus(5 * MIN)) await verarbeite(plus(14 * MIN + 59 * 1000)) assert.equal(await sendeEintrag(a.$id, plus(10 * MIN)), 'wartet') assert.equal(tr.aufrufe, 1, 'während der Pause kein Sendeversuch') assert.equal((await lies(b.$id)).status, 'wartet') // nach der Pause geht alles raus assert.deepEqual(await verarbeite(plus(15 * MIN)), { faellig: 2, gesendet: 2 }) assert.equal(tr.aufrufe, 3) assert.equal(tr.senke.sent.length, 2) assert.equal((await lies(a.$id)).status, 'gesendet') assert.equal((await lies(b.$id)).status, 'gesendet') }) } test('pausiertBis: null ohne Pause; weitere Anmeldefehler nach der Pause verlängern sie erneut', async () => { assert.equal(pausiertBis(), null) tr.fehler.push(EAUTH, EAUTH) await einreihen(eintrag(), JETZT) await warteAufVersand() assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime()) await verarbeite(plus(15 * MIN)) assert.equal(pausiertBis().getTime(), plus(30 * MIN).getTime()) resetOutboxForTests() assert.equal(pausiertBis(), null) }) // --------------------------------------------------------------------------------------------------------------------- // Parallelität // --------------------------------------------------------------------------------------------------------------------- test('zwei verarbeite (und ein sendeEintrag) gleichzeitig ⇒ genau eine Mail', async () => { tr.verzoegerungMs = 30 const doc = await legeAn() await Promise.all([verarbeite(JETZT), verarbeite(JETZT), sendeEintrag(doc.$id, JETZT)]) assert.equal(tr.aufrufe, 1) assert.equal(tr.senke.sent.length, 1) const d = await lies(doc.$id) assert.equal(d.status, 'gesendet') assert.equal(d.versuche, 1) }) test('zwei verarbeite gleichzeitig bei einem Fehlschlag ⇒ nur ein Versuch (der zweite sieht den neuen Termin)', async () => { tr.verzoegerungMs = 30 tr.immer = smtp(451) const doc = await legeAn() await Promise.all([verarbeite(JETZT), verarbeite(JETZT)]) assert.equal(tr.aufrufe, 1) const d = await lies(doc.$id) assert.equal(d.versuche, 1) assert.equal(ms(d.naechsterVersuch), plus(MIN).getTime()) }) test('Stundenbudget gilt auch bei gleichzeitigen Versuchen für verschiedene Einträge', async () => { config.termine.mailsProStunde = 3 tr.verzoegerungMs = 20 const ids = [] for (let i = 0; i < 6; i++) ids.push((await legeAn({ bookingId: `b_${i}`, art: 'G02' })).$id) await Promise.all(ids.map((id) => sendeEintrag(id, JETZT))) assert.equal(tr.aufrufe, 3) assert.equal(alle().filter((d) => d.status === 'gesendet').length, 3) }) // --------------------------------------------------------------------------------------------------------------------- // Speicherfehler nach dem Versand // --------------------------------------------------------------------------------------------------------------------- /** Die nächsten `n` PATCH-Anfragen scheitern mit 500 (der Nachbau kann nur einen eingeplanten Fehler gleichzeitig). */ function patchSchlaegtFehl(n) { const echt = globalThis.fetch let rest = n globalThis.fetch = async (url, options) => { if (options?.method === 'PATCH' && rest > 0) { rest -= 1 return new Response(JSON.stringify({ message: 'Nachbau: Speichern gescheitert', code: 500 }), { status: 500 }) } return echt(url, options) } return () => (globalThis.fetch = echt) } test('Speichern nach erfolgreichem Versand scheitert einmal: zweiter Speicherversuch, nur eine Mail', async () => { const zurueck = patchSchlaegtFehl(1) try { const doc = await einreihen(eintrag(), JETZT) await warteAufVersand() assert.equal(tr.senke.sent.length, 1) assert.equal((await lies(doc.$id)).status, 'gesendet') } finally { zurueck() } }) test('Speichern scheitert dauerhaft: keine zweite Mail, der Status wird beim nächsten Lauf nachgetragen', async () => { const zurueck = patchSchlaegtFehl(2) let doc try { doc = await legeAn() assert.equal(await sendeEintrag(doc.$id, JETZT), 'gesendet') } finally { zurueck() } assert.equal(tr.senke.sent.length, 1) assert.equal((await lies(doc.$id)).status, 'wartet', 'Datenbank kennt den Versand noch nicht') assert.ok(log.error.mock.calls.length >= 1) await verarbeite(plus(MIN)) assert.equal(tr.senke.sent.length, 1, 'nicht erneut gesendet') const d = await lies(doc.$id) assert.equal(d.status, 'gesendet') assert.equal(d.an || '', '') assert.equal(ms(d.gesendetAt), JETZT.getTime(), 'Zeitpunkt des echten Versands') }) // --------------------------------------------------------------------------------------------------------------------- // Aufräumen und Zustellhinweis // --------------------------------------------------------------------------------------------------------------------- test('raeumeAuf: gesendet 7 Tage nach dem Versand, fehler 48 Stunden nach dem Fehler; wartet nie', async () => { const ok = await einreihen(eintrag({ bookingId: 'b_ok' }), JETZT) await warteAufVersand() tr.immer = smtp(550) const kaputt = await einreihen(eintrag({ bookingId: 'b_kaputt' }), JETZT) await warteAufVersand() const wartend = await legeAn({ bookingId: 'b_wartet', createdAt: plus(-30 * TAG).toISOString() }) assert.equal(await raeumeAuf(plus(48 * STUNDE - 1)), 0) assert.equal(await raeumeAuf(plus(48 * STUNDE)), 1) assert.equal(await lies(kaputt.$id), undefined) assert.ok(await lies(ok.$id)) assert.equal(await raeumeAuf(plus(7 * TAG - 1)), 0) assert.equal(await raeumeAuf(plus(7 * TAG)), 1) assert.equal(await lies(ok.$id), undefined) assert.equal(await raeumeAuf(plus(60 * TAG)), 0) assert.ok(await lies(wartend.$id), 'wartende Einträge löscht das Aufräumen nie') await assert.rejects(() => raeumeAuf(), TypeError) }) test('beendeFestgefahrene: wartet länger als 48 Stunden (createdAt) ⇒ fehler mit Löschtermin in 48 Stunden; Jüngere und andere Status bleiben', async () => { const alt = await legeAn({ bookingId: 'b_alt', createdAt: plus(-48 * STUNDE - 1).toISOString() }) const altVertagt = await legeAn({ bookingId: 'b_alt2', createdAt: plus(-5 * TAG).toISOString(), naechsterVersuch: plus(20 * MIN).toISOString() }) const grenze = await legeAn({ bookingId: 'b_grenze', createdAt: plus(-48 * STUNDE).toISOString() }) const jung = await legeAn({ bookingId: 'b_jung', createdAt: plus(-2 * STUNDE).toISOString() }) const fertig = await legeAn({ bookingId: 'b_fertig', status: 'gesendet', createdAt: plus(-5 * TAG).toISOString(), naechsterVersuch: plus(3 * TAG).toISOString() }) assert.equal(await beendeFestgefahrene(JETZT), 2) for (const eintragDoc of [alt, altVertagt]) { const e = await lies(eintragDoc.$id) assert.equal(e.status, 'fehler') assert.equal(ms(e.naechsterVersuch), JETZT.getTime() + 48 * STUNDE, 'Löschtermin = jetzt + 48 Stunden') assert.ok(e.fehler.length > 0 && e.fehler.length <= 300) assert.equal(e.versuche, 0) } for (const eintragDoc of [grenze, jung]) assert.equal((await lies(eintragDoc.$id)).status, 'wartet') assert.equal((await lies(fertig.$id)).status, 'gesendet') assert.deepEqual([...(await zustellFehler(['b_alt', 'b_alt2', 'b_jung']))].sort(), ['b_alt', 'b_alt2'], 'das Portal zeigt den Zustellfehler') assert.equal(await beendeFestgefahrene(JETZT), 0, 'zweiter Lauf ändert nichts') assert.equal(await raeumeAuf(JETZT), 0, 'nicht sofort gelöscht') assert.equal(await raeumeAuf(plus(48 * STUNDE - 1)), 0) assert.equal(await raeumeAuf(plus(48 * STUNDE)), 2, 'erst 48 Stunden später') await assert.rejects(() => beendeFestgefahrene(), TypeError) }) test('beendeFestgefahrene: ein Eintrag, der inzwischen gesendet wurde, bleibt gesendet', async () => { const doc = await legeAn({ bookingId: 'b_race', createdAt: plus(-3 * TAG).toISOString() }) const echt = globalThis.fetch let einmal = true // zwischen Auflisten und Sperre sendet ein anderer Lauf den Eintrag globalThis.fetch = async (url, optionen) => { const antwort = await echt(url, optionen) if (einmal && (optionen?.method ?? 'GET') === 'GET' && String(url).includes('/documents?')) { einmal = false await sendeEintrag(doc.$id, JETZT) } return antwort } try { assert.equal(await beendeFestgefahrene(JETZT), 0) } finally { globalThis.fetch = echt } assert.equal(einmal, false, 'der Versand lief zwischen Auflisten und Sperre') assert.equal((await lies(doc.$id)).status, 'gesendet') }) test('verwerfeWartende: löscht nur die wartenden Einträge dieser Buchung (auch über mehrere Seiten); gesendete, fehlgeschlagene und fremde bleiben', async () => { for (let i = 0; i < 30; i++) await legeAn({ bookingId: 'b_weg', art: 'G05' }) const gesendet = await legeAn({ bookingId: 'b_weg', status: 'gesendet', an: '', html: '', text: '' }) const fehler = await legeAn({ bookingId: 'b_weg', status: 'fehler' }) const fremd = await legeAn({ bookingId: 'b_fremd' }) assert.equal(await verwerfeWartende('b_weg'), 30) assert.deepEqual(alle().map((d) => d.$id).sort(), [gesendet.$id, fehler.$id, fremd.$id].sort()) assert.equal(await verwerfeWartende('b_weg'), 0, 'zweiter Aufruf findet nichts') assert.equal(await verwerfeWartende('b_gibtsnicht'), 0) for (const ungueltig of [undefined, null, '', 5]) await assert.rejects(() => verwerfeWartende(ungueltig), TypeError) }) test('verwerfeWartende: ein Eintrag, der inzwischen gesendet wurde, bleibt (Sperre, neu lesen); ein Eintrag, der inzwischen weg ist, stört nicht', async () => { const doc = await legeAn({ bookingId: 'b_race' }) const weg = await legeAn({ bookingId: 'b_race' }) const echt = globalThis.fetch let einmal = true // zwischen Auflisten und Sperre sendet ein anderer Lauf den ersten Eintrag und ein anderer Lauf loescht den zweiten globalThis.fetch = async (url, optionen) => { const antwort = await echt(url, optionen) if (einmal && (optionen?.method ?? 'GET') === 'GET' && String(url).includes('/documents?')) { einmal = false await sendeEintrag(doc.$id, JETZT) await deleteDocument(col(), weg.$id) } return antwort } try { assert.equal(await verwerfeWartende('b_race'), 0) } finally { globalThis.fetch = echt } assert.equal(einmal, false, 'der Versand lief zwischen Auflisten und Sperre') assert.equal((await lies(doc.$id)).status, 'gesendet') assert.equal(await lies(weg.$id), undefined) }) test('raeumeAuf löscht mehr als eine Seite (über 25 Einträge)', async () => { for (let i = 0; i < 40; i++) await legeAn({ bookingId: `b_${i}`, status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: JETZT.toISOString() }) assert.equal(await raeumeAuf(JETZT), 40) assert.equal(alle().length, 0) }) test('zustellFehler: Menge der Buchungen mit endgültig gescheiterter Mail (nur fehler, auch über mehrere Blöcke)', async () => { await legeAn({ bookingId: 'b_fehler', status: 'fehler' }) await legeAn({ bookingId: 'b_fehler', status: 'fehler' }) await legeAn({ bookingId: 'b_spaet', status: 'fehler' }) await legeAn({ bookingId: 'b_gesendet', status: 'gesendet' }) await legeAn({ bookingId: 'b_wartet', status: 'wartet' }) await legeAn({ bookingId: 'b_fremd', status: 'fehler' }) const treffer = await zustellFehler(['b_fehler', 'b_gesendet', 'b_wartet', 'b_unbekannt']) assert.ok(treffer instanceof Set) assert.deepEqual([...treffer], ['b_fehler']) const viele = Array.from({ length: 130 }, (_, i) => `b_x${i}`) viele.splice(120, 0, 'b_spaet') assert.deepEqual([...(await zustellFehler(viele))], ['b_spaet']) assert.deepEqual([...(await zustellFehler([]))], []) assert.deepEqual([...(await zustellFehler(undefined))], []) }) test('zustellFehler: auch eine gescheiterte Mail an den Betrieb (K20) zählt; erfolgreiche Mails derselben Buchung ändern daran nichts', async () => { tr.immer = smtp(550) await einreihen(eintrag({ bookingId: 'b_1', template: 'K20', art: 'K20', values: werte('K20', { betreff: 'Neue Buchung' }), blocks: {} }), JETZT) await warteAufVersand() tr.immer = null await einreihen(eintrag({ bookingId: 'b_1' }), JETZT) await warteAufVersand() assert.deepEqual([...(await zustellFehler(['b_1']))], ['b_1']) })