Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 10:03:39 +00:00
1 parent c35711caad
commit 4d96565240
5 files changed
+257 -18

No files matched your search

+6 -3
View File
@@ -566,7 +566,10 @@ test('Buchung: 201 mit maskierter Adresse und Haltezeit; Buchung gehalten, Mail
test('zu viele offene Termine: dieselbe 201-Antwort, keine neue Buchung, statt der Bestaetigung geht G01 "zu viele"', async () => {
await speichere()
for (const zeit of ['10:00', '11:00', '12:00']) assert.equal((await buchen({ zeit })).status, 201)
// drei bestaetigte Termine belegen die drei offenen Plaetze; die Kappe der Adresse (3 Mails je Stunde) ist damit ausgeschoepft,
// darum kommt die vierte Anfrage erst eine Stunde spaeter bis zu "zu viele" (in der Stunde selbst bliebe sie still, siehe oben)
for (const zeit of ['10:00', '11:00', '12:00']) await gebucht({ zeit })
setzeUhrFuerTests(() => plus(JETZT, 61 * 60000))
await warteAufVersand()
const vorher = sink.sent.length
const vierte = await buchen({ zeit: '13:00' })
@@ -627,7 +630,7 @@ test('je siteKey 25 gehaltene Termine pro Stunde: danach 429, ohne dass eine Buc
assert.equal(buchungen().length, 1)
})
test('je Adresse und Funktion 5 Bestaetigungsmails pro Tag: die sechste Anfrage bekommt dieselbe Antwort, aber keine Mail', async () => {
test('je Adresse 3 Bestaetigungsmails pro Stunde: die vierte bis sechste Anfrage bekommt dieselbe Antwort, aber keine Haltung und keine Mail', async () => {
await speichere()
const zeiten = ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00']
const antworten = []
@@ -637,7 +640,7 @@ test('je Adresse und Funktion 5 Bestaetigungsmails pro Tag: die sechste Anfrage
assert.ok(antworten.every((a) => JSON.stringify(a.body) === JSON.stringify(antworten[0].body)), 'alle Antworten gleich')
assert.deepEqual(antworten[0].body, { ok: true, email: 'v***@example.com', minuten: 30 })
assert.equal(buchungen().length, 3, 'nur drei offene Termine')
assert.equal(mailsAn('viel@example.com').length, 5, '3 Bestaetigungen + 2 "zu viele", die sechste Anfrage bekommt nichts')
assert.equal(mailsAn('viel@example.com').length, 3, 'drei Bestaetigungen; die vierte bis sechste Anfrage bekommt nichts (auch keine "zu viele")')
// andere Adressen zaehlen getrennt; Fehler der Zeit kommen vor der Mailgrenze (kein Orakel, ob die Adresse an der Grenze ist)
const andere = await buchen({ zeit: '16:00', email: 'anna@example.com' })
+128 -1
View File
@@ -31,7 +31,8 @@ const { resetOutboxForTests, warteAufVersand } = await import('../server/service
const { hashToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { zellenFuer, zellenId } = await import('../server/services/termine/zellen.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { resetTermineLimitsForTests, darfEmpfaenger } = await import('../server/services/termine/limits.js')
const { anGast } = await import('../server/services/termine/mails.js')
const { halte, vorschau, bestaetige, kannWiderrufen, verschiebeGast } = await import('../server/services/termine/ablauf.js')
const { aktion, manuell, sperre, entsperre } = await import('../server/services/termine/ablaufBetrieb.js')
after(() => setMailTransportForTests(null))
@@ -395,6 +396,132 @@ test('absagen: eine Anfrage (nie eine Einladung erhalten) bekommt G06 ohne Kalen
assert.equal(err.code, 'nicht_moeglich')
})
// ---------------------------------------------------------------- Empfaenger-Kappe fuer Freitext (G04, G06)
const GAST = 'max@example.com'
/** Alle Warnungen und Fehler im Log als ein Text (die Adresse des Gastes darf dort nie stehen). */
const logText = () => JSON.stringify([...log.warn.mock.calls, ...log.error.mock.calls].map((c) => c.arguments.map((a) => String(a?.message ?? a))))
/** Verbraucht n Platz(e) der Empfaenger-Kappe einer Adresse (wie n Mails mit Freitext oder G01 davor). */
const fuelleKappe = (adresse, n, now = JETZT) => {
for (let i = 0; i < n; i += 1) assert.equal(darfEmpfaenger(adresse, now), true)
}
/** Reiht eine Gastmail direkt ein und liefert die versandte Mail. */
async function sende(art, ctx, buchungsDoc, extra, now = JETZT) {
const vorher = sink.sent.length
await anGast(art, ctx, buchungsDoc, extra, now)
await warteAufVersand()
assert.equal(sink.sent.length, vorher + 1)
return sink.sent[vorher]
}
test('ablehnen: ueber der Kappe (3 je Adresse und Stunde) geht G04 ohne die Nachricht des Betriebs raus, darunter mit; die Warnung im Log nennt keine Adresse', async () => {
const ctx = await speichere()
const a = await gebucht(ctx, { leistung: S_ANFRAGE })
const b = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00' })
fuelleKappe(GAST, 2) // zwei Mails mit Freitext sind schon raus: die dritte darf noch
const vorher = sink.sent.length
await aktion(ctx, a.id, { aktion: 'ablehnen', nachricht: 'Am Montag sind wir im Urlaub.' }, JETZT)
await warteAufVersand()
assert.match(sink.sent[vorher].text, /Am Montag sind wir im Urlaub\./, 'die dritte Mail der Stunde enthaelt den Text')
assert.doesNotMatch(logText(), /Kappe/)
await aktion(ctx, b.id, { aktion: 'ablehnen', nachricht: 'Bitte ruft uns einfach an.' }, JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, vorher + 2, 'die Mail geht trotzdem raus')
const mail = sink.sent[vorher + 1]
assert.equal(mail.to[0].address, GAST)
assert.equal(mail.subject, 'Ihre Terminanfrage bei Salon Müller – leider abgelehnt')
assert.match(mail.text, /leider kann Salon Müller Ihre Terminanfrage nicht annehmen/, 'der Hinweis auf die Ablehnung bleibt')
assert.doesNotMatch(mail.text, /Bitte ruft uns einfach an/, 'der Freitext faellt weg')
assert.doesNotMatch(mail.text, /Nachricht von/i)
assert.doesNotMatch(JSON.stringify(eintraege()), /Bitte ruft uns einfach an/, 'er steht auch nicht im Postausgang')
assert.equal(buchung(b.id).status, 'abgelehnt')
assert.match(logText(), /G04: Empfänger-Kappe erreicht/)
assert.doesNotMatch(logText(), /max@example\.com|example\.com/, 'das Log nennt keine Adresse')
})
test('absagen: ueber der Kappe geht G06 des Betriebs ohne Grund und Nachricht raus (Kalenderabsage und Hinweis bleiben); das Log nennt keine Adresse', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
fuelleKappe(GAST, 3)
const vorher = sink.sent.length
assert.deepEqual(
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit', nachricht: 'Gute Besserung wuenschen wir.' }, JETZT),
{ status: 'abgesagt' },
)
await warteAufVersand()
assert.equal(sink.sent.length, vorher + 1)
const g06 = sink.sent[vorher]
assert.equal(g06.to[0].address, GAST)
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
assert.match(g06.text, /Salon Müller/)
assert.doesNotMatch(g06.text, /Krankheit/)
assert.doesNotMatch(g06.text, /Gute Besserung/)
assert.equal(g06.icalEvent.method, 'CANCEL', 'die Kalenderabsage bleibt')
assert.doesNotMatch(JSON.stringify(eintraege()), /Krankheit|Gute Besserung/)
assert.equal(buchung(id).absageGrund, 'Krankheit', 'der Betrieb behaelt seinen Grund in der Buchung')
assert.match(logText(), /G06: Empfänger-Kappe erreicht/)
assert.doesNotMatch(logText(), /example\.com/)
})
test('anGast: Freitext in G04/G06 des Betriebs geht bis zur dritten Mail je Adresse und Stunde mit, danach ohne; nach 61 Minuten wieder mit', async () => {
const ctx = await speichere()
const { buchung: b } = await gebucht(ctx)
const g04 = (text, now) => sende('G04', ctx, b, { nachricht: text }, now)
assert.match((await g04('Eins', JETZT)).text, /Eins/)
assert.match((await sende('G06', ctx, b, { von: 'betrieb', grund: 'Zwei', ohneIcs: true })).text, /Zwei/, 'G06 des Betriebs teilt sich die Zaehler mit G04')
assert.match((await g04('Drei', JETZT)).text, /Drei/)
const vier = await g04('Vier', JETZT)
assert.doesNotMatch(vier.text, /Vier/)
const fuenf = await sende('G06', ctx, b, { von: 'betrieb', grund: 'Fuenf', nachricht: 'Sechs', ohneIcs: true })
assert.doesNotMatch(fuenf.text, /Fuenf|Sechs/, 'weder Grund noch Nachricht')
assert.match(fuenf.text, /Salon Müller/)
assert.match((await g04('Sieben', plus(JETZT, 61 * MIN))).text, /Sieben/, 'nach 61 Minuten wieder')
assert.doesNotMatch(logText(), /example\.com/)
})
test('anGast: ohne Freitext zaehlt nichts - G04/G06 ohne Text, G06 des Gastes oder des Systems, G05 und die uebrigen Arten', async () => {
const ctx = await speichere()
const { buchung: b } = await gebucht(ctx)
const vorher = sink.sent.length
await sende('G04', ctx, b, {})
await sende('G04', ctx, b, { nachricht: ' ' })
await sende('G06', ctx, b, { von: 'betrieb', ohneIcs: true })
await sende('G06', ctx, b, { von: 'betrieb', grund: ' ', nachricht: '', ohneIcs: true })
const gast = await sende('G06', ctx, b, { von: 'gast', grund: 'Gastgrund', nachricht: 'Gastnachricht', ohneIcs: true })
await sende('G06', ctx, b, { von: 'system', ohneIcs: true })
await sende('G05', ctx, b, {})
await sende('G07', ctx, b, { altStart: b.startsAt, ohneIcs: true })
assert.equal(sink.sent.length, vorher + 8)
assert.match(gast.text, /Sie selbst/)
assert.doesNotMatch(gast.text, /Gastgrund|Gastnachricht/, 'bei einer Absage des Gastes gibt es weder Grund noch Nachricht')
assert.equal(log.warn.mock.callCount(), 0)
// die Kappe ist unberuehrt: alle drei Plaetze der Stunde sind frei
assert.deepEqual([1, 2, 3, 4].map(() => darfEmpfaenger(GAST, JETZT)), [true, true, true, false])
})
test('anGast: die Kappe gilt je Adresse (extra.an zaehlt klein geschrieben); eine andere Adresse ist nicht betroffen', async () => {
const ctx = await speichere()
const { buchung: b } = await gebucht(ctx)
fuelleKappe(GAST, 3)
fuelleKappe('weitere@example.com', 2)
const voll = await sende('G04', ctx, b, { nachricht: 'Nur fuer Max' })
assert.doesNotMatch(voll.text, /Nur fuer Max/)
const umgeleitet = await sende('G04', ctx, b, { nachricht: 'An eine andere Adresse', an: 'Weitere@Example.com' })
assert.match(umgeleitet.text, /An eine andere Adresse/, 'die Adresse in extra.an zaehlt (klein geschrieben): dort war noch ein Platz')
const nochmal = await sende('G04', ctx, b, { nachricht: 'Und noch eine', an: 'weitere@example.com' })
assert.doesNotMatch(nochmal.text, /Und noch eine/, 'die Zaehler sind mit der Schreibweise der Adresse nicht zu umgehen')
})
// ---------------------------------------------------------------- verschieben
test('verschieben: wie beim Gast (10:00 -> 10:30 ueberlappend), Sequenz + 1, neue Zellen, G07 mit Einladung, kein K21', async () => {
+71 -8
View File
@@ -11,6 +11,7 @@ import {
darfHaltung,
darfWiderrufsversuch,
darfG01,
darfEmpfaenger,
ipSchluessel,
resetTermineLimitsForTests,
} from '../server/services/termine/limits.js'
@@ -159,13 +160,75 @@ test('darfWiderrufsversuch: 10 je IP und Stunde', () => {
assert.equal(darfWiderrufsversuch('203.0.113.7', plus(STUNDE)), true)
})
test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren sich nicht', () => {
assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, true, true, false, false])
assert.equal(darfG01('f2', 'max@example.com', T0), true, 'andere Funktion')
test('darfG01: 3 je Adresse und Stunde ueber alle Funktionen; die Zaehler der Arten stoeren sich nicht', () => {
assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, false, false, false, false])
assert.equal(darfG01('f2', 'max@example.com', T0), false, 'die Kappe gilt ueber Betriebe (Funktionen) hinweg')
assert.equal(darfG01('f1', 'anna@example.com', T0), true, 'andere Adresse')
assert.equal(darfG01('f1', 'max@example.com', plus(TAG - 1)), false)
assert.equal(darfG01('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder')
assert.equal(darfG08('f1', 'max@example.com', T0), true, 'G08 zaehlt getrennt von G01')
assert.equal(darfG01('f1', 'max@example.com', plus(STUNDE - 1)), false, 'bis zur letzten Millisekunde der Stunde')
assert.deepEqual(
Array.from({ length: 4 }, () => darfG01('f2', 'max@example.com', plus(61 * MINUTE))),
[true, true, true, false],
'nach 61 Minuten wieder drei',
)
assert.deepEqual(
Array.from({ length: 4 }, () => darfG08('f1', 'max@example.com', T0)),
[true, true, true, false],
'G08 zaehlt getrennt von G01 und hat ihre eigene Grenze von drei je Tag',
)
})
test('darfG01: je Adresse hoechstens 10 Empfaenger-Mails pro Tag (3 + 3 + 3 + 1 ueber vier Stunden), die 11. nicht', () => {
// je Funktion gelten weiter hoechstens 5 G01 pro Tag; darum verteilen sich die zehn auf vier Funktionen
const adresse = 'tag@example.com'
const stunde = (n, funktion, anzahl) => Array.from({ length: anzahl }, () => darfG01(funktion, adresse, plus(n * 61 * MINUTE)))
assert.deepEqual(stunde(0, 'f1', 3), [true, true, true])
assert.deepEqual(stunde(1, 'f2', 3), [true, true, true])
assert.deepEqual(stunde(2, 'f3', 3), [true, true, true])
assert.deepEqual(stunde(3, 'f4', 2), [true, false], 'die zehnte geht, die elfte nicht (der Stundenzaehler hat dann erst eine)')
assert.equal(darfG01('f5', adresse, plus(5 * 61 * MINUTE)), false, 'auch in einer neuen Stunde und mit unberuehrter Funktion')
assert.equal(darfG01('f5', 'andere@example.com', plus(5 * 61 * MINUTE)), true, 'andere Adresse')
assert.equal(darfG01('f5', adresse, plus(TAG)), true, 'am naechsten Tag wieder')
})
test('darfG01: je Funktion hoechstens 5 pro Adresse und Tag (die Regel der Funktion bleibt neben der Kappe ueber Betriebe)', () => {
const adresse = 'funktion@example.com'
assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, T0)), [true, true, true])
assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, plus(61 * MINUTE))), [true, true, false], 'die sechste an dieselbe Funktion nicht')
assert.equal(darfG01('f2', adresse, plus(61 * MINUTE)), true, 'eine andere Funktion schon (noch ein Platz in der Stunde)')
assert.equal(darfG01('f1', adresse, plus(TAG)), true, 'am naechsten Tag wieder')
})
test('darfG01: alles oder nichts - eine abgelehnte Anfrage erhoeht keinen Zaehler, auch nicht den der Funktion', () => {
const adresse = 'nichts@example.com'
assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, T0)), [true, true, true])
// von der Stundenkappe abgelehnt: der Zaehler der Funktion (3 von 5) bleibt bei 3
for (let i = 0; i < 6; i += 1) assert.equal(darfG01('f1', adresse, T0), false)
assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f1', adresse, plus(61 * MINUTE))), [true, true, false], 'danach noch genau 2 von 5 frei')
// von der Funktionsregel abgelehnt: Stunden- und Tageszaehler (2 und 5) bleiben unveraendert
for (let i = 0; i < 4; i += 1) assert.equal(darfG01('f1', adresse, plus(62 * MINUTE)), false)
assert.deepEqual(Array.from({ length: 2 }, () => darfG01('f2', adresse, plus(62 * MINUTE))), [true, false], 'in der Stunde ist genau 1 von 3 frei')
// bisher 6 am Tag gezaehlt (3 + 2 + 1): drei weitere und dann eine ergeben genau die zehn des Tages
assert.deepEqual(Array.from({ length: 3 }, () => darfG01('f3', adresse, plus(123 * MINUTE))), [true, true, true])
assert.deepEqual(Array.from({ length: 2 }, () => darfG01('f4', adresse, plus(184 * MINUTE))), [true, false], 'die zehnte war die letzte des Tages')
})
test('darfEmpfaenger: teilt Stunden- und Tageszaehler mit darfG01, ohne die Regel der Funktion', () => {
const adresse = 'frei@example.com'
assert.deepEqual(
[darfEmpfaenger(adresse, T0), darfEmpfaenger(adresse, T0), darfG01('f1', adresse, T0), darfG01('f1', adresse, T0)],
[true, true, true, false],
'zwei Freitext-Mails + zwei G01: die vierte Mail an dieselbe Adresse in der Stunde nicht',
)
assert.equal(darfEmpfaenger(adresse, T0), false)
assert.equal(darfEmpfaenger('anna@example.com', T0), true, 'andere Adresse')
assert.equal(darfEmpfaenger(adresse, plus(61 * MINUTE)), true, 'nach 61 Minuten wieder')
// die G01-Regel der Funktion (5 je Tag) zaehlt nur bei darfG01: fuenf G01 sind verbraucht, ein Freitext geht trotzdem
const andere = 'regel@example.com'
for (let i = 0; i < 3; i += 1) assert.equal(darfG01('f1', andere, plus(i * 61 * MINUTE)), true)
for (let i = 0; i < 2; i += 1) assert.equal(darfG01('f1', andere, plus(3 * 61 * MINUTE)), true)
assert.equal(darfG01('f1', andere, plus(3 * 61 * MINUTE)), false, 'sechste G01 an dieselbe Funktion')
assert.equal(darfEmpfaenger(andere, plus(3 * 61 * MINUTE)), true, 'der Freitext kennt die Regel der Funktion nicht')
})
// ---------------------------------------------------------------- Speicher: IP-Zaehler getrennt, Abgelaufene zuerst
@@ -174,8 +237,8 @@ test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren
const ipNr = (i) => `10.${(i >> 16) & 255}.${(i >> 8) & 255}.${i & 255}`
test('Speicher: 10 000 und mehr verschiedene IPs verdraengen keinen laufenden Zaehler je Adresse, Funktion oder siteKey', () => {
// G01 einer Adresse ausgeschoepft, eine Haltungsgrenze angebrochen
for (let i = 0; i < 5; i += 1) assert.equal(darfG01('f1', 'opfer@example.com', T0), true)
// G01 einer Adresse ausgeschoepft (3 je Stunde), eine Haltungsgrenze angebrochen
for (let i = 0; i < 3; i += 1) assert.equal(darfG01('f1', 'opfer@example.com', T0), true)
assert.equal(darfG01('f1', 'opfer@example.com', T0), false)
for (let i = 0; i < 25; i += 1) assert.equal(darfHaltung('abcdefghijklmnop', T0), true)
assert.equal(darfG08('f1', 'opfer@example.com', T0), true)