Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt

K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:00:17 +00:00
1 parent ea4457e6a4
commit c7cd436ec2
34 files changed
+359 -354

No files matched your search

+39 -39
View File
@@ -9,7 +9,7 @@ import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
// Freischalten von Oeffnungszeiten live (Aufgabe 18, Spec 3.2 Selbstheilung und 4.4): der 5-Minuten-Job (runActivation) setzt 'active',
// wenn das Einbau-Ticket geschlossen ist UND der Kunde Zeiten eingetragen hat (Haken freischaltbar: Hauptplan gespeichert, mindestens
// ein Tag nicht "zu"). Bei active: K16 und der Ersatz-Hash (Haken nachFreischaltung). Dazu heilt der Job Dokumente in setup, denen
// Ticket oder K24 fehlen (holeZeitenNach), je Dokument getrennt abgesichert.
// Ticket oder K27 fehlen (holeZeitenNach), je Dokument getrennt abgesichert.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
@@ -18,7 +18,7 @@ const aw = await startAppwriteMock({
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
},
defaultLimit: 25,
})
@@ -39,13 +39,13 @@ after(() => setMailTransportForTests(null))
const Z = config.features.zeiten
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const K16_BETREFF = /bereit/
const K24_BETREFF = 'Öffnungszeiten live ist eingeschaltet'
const K27_BETREFF = 'Öffnungszeiten live ist eingeschaltet'
const feld = (id, name) => aw.docs('portalFeatures').get(id)[name]
const zustand = (id) => feld(id, 'state')
const tickets = () => [...aw.docs('workorders').values()]
const k16 = () => sink.sent.filter((m) => K16_BETREFF.test(m.subject))
const k24 = () => sink.sent.filter((m) => m.subject === K24_BETREFF)
const k27 = () => sink.sent.filter((m) => m.subject === K27_BETREFF)
const logText = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
function tage(modi) {
@@ -69,7 +69,7 @@ const eingabe = (modi = WOCHE) => ({
let nr = 0
/**
* Dokument der Zeiten im Zustand setup mit Projekt, Kunde und Ticket (Standard: geschlossen), optional mit gespeicherten Zeiten
* (zeiten: Wochenplan wie in tage()). Standard: abo, einbau gesetzt und k24At gesetzt, damit nur die Freischaltung etwas bewegt.
* (zeiten: Wochenplan wie in tage()). Standard: abo, einbau gesetzt und einschaltMailAt gesetzt, damit nur die Freischaltung etwas bewegt.
*/
async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null, ...over } = {}) {
nr += 1
@@ -87,7 +87,7 @@ async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null
siteKey: 'abcdefghijklmnop',
unlockedBy: 'abo',
einbau: 'angestossen',
k24At: JETZT.toISOString(),
einschaltMailAt: JETZT.toISOString(),
...(ticket ? { ticketId: `t_${projectId}` } : {}),
...over,
})
@@ -174,7 +174,7 @@ test('Ticket zu und Zeiten gespeichert ⇒ active, genau eine K16, ersatzHash ge
assert.equal(Date.parse(feld(id, 'activatedAt')), JETZT.getTime())
assert.equal(k16().length, 1)
assert.deepEqual(k16()[0].to.map((a) => a.address), ['k@example.invalid'])
assert.equal(k24().length, 0)
assert.equal(k27().length, 0)
const nachher = await ladeZeiten(id)
assert.match(nachher.ersatzHash, /^[0-9a-f]{16}$/)
@@ -306,8 +306,8 @@ test('Abnahme der Zeiten: mit eingetragenen Zeiten active, K16, Ersatz-Hash (nac
// ------------------------------------------------------------------------------------------- Selbstheilung
test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K24)', async () => {
const id = await einrichtung({ ticket: null, einbau: '', k24At: null })
test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K27)', async () => {
const id = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(tickets().length, 1)
@@ -315,49 +315,49 @@ test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sen
assert.match(tickets()[0].topic, /^Öffnungszeiten live einbauen – Projekt \d+$/)
assert.equal(feld(id, 'einbau'), 'angestossen')
assert.equal(rebuilds.length, 1, 'einbau war leer: ein Neubau')
assert.equal(k24().length, 1)
assert.ok(feld(id, 'k24At'))
assert.equal(k27().length, 1)
assert.ok(feld(id, 'einschaltMailAt'))
// kein zweites Ticket, kein zweiter Neubau, keine zweite Mail
await sf.runActivation({ now: JETZT })
assert.deepEqual([tickets().length, rebuilds.length, k24().length], [1, 1, 1])
assert.deepEqual([tickets().length, rebuilds.length, k27().length], [1, 1, 1])
})
test('Selbstheilung: unlockedBy abo ohne k24At ⇒ der Job sendet die K24 genau einmal', async () => {
const id = await einrichtung({ ticket: 'Open', k24At: null })
test('Selbstheilung: unlockedBy abo ohne einschaltMailAt ⇒ der Job sendet die K27 genau einmal', async () => {
const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null })
await sf.runActivation({ now: JETZT })
await sf.runActivation({ now: JETZT })
await sf.runActivation({ now: JETZT })
assert.equal(k24().length, 1)
assert.deepEqual(k24()[0].to.map((a) => a.address), ['k@example.invalid'])
assert.equal(Date.parse(feld(id, 'k24At')), JETZT.getTime())
assert.equal(k27().length, 1)
assert.deepEqual(k27()[0].to.map((a) => a.address), ['k@example.invalid'])
assert.equal(Date.parse(feld(id, 'einschaltMailAt')), JETZT.getTime())
assert.equal(tickets().length, 1, 'das Ticket war da: kein neues')
assert.deepEqual(rebuilds, [])
assert.equal(zustand(id), 'setup')
})
test('Selbstheilung: K24 nicht gesendet (Mail scheitert) ⇒ k24At bleibt leer, der nächste Lauf sendet nach', async () => {
const id = await einrichtung({ ticket: 'Open', k24At: null })
test('Selbstheilung: K27 nicht gesendet (Mail scheitert) ⇒ einschaltMailAt bleibt leer, der nächste Lauf sendet nach', async () => {
const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null })
setMailTransportForTests({ async sendMail() { throw new Error('SMTP down') } })
await sf.runActivation({ now: JETZT })
assert.ok(!feld(id, 'k24At'))
assert.ok(!feld(id, 'einschaltMailAt'))
setMailTransportForTests(sink)
await sf.runActivation({ now: JETZT })
assert.equal(k24().length, 1)
assert.ok(feld(id, 'k24At'))
assert.equal(k27().length, 1)
assert.ok(feld(id, 'einschaltMailAt'))
})
test('Selbstheilung nur für setup und nicht für kauf; Team bekommt Ticket, aber keine K24', async () => {
const kauf = await einrichtung({ ticket: null, einbau: '', k24At: null, unlockedBy: 'kauf', unlockedByPurchase: true })
const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, k24At: null })
const aus = await einrichtung({ ticket: null, state: 'off', k24At: null })
const team = await einrichtung({ ticket: null, einbau: '', k24At: null, unlockedBy: 'team' })
test('Selbstheilung nur für setup und nicht für kauf; Team bekommt Ticket, aber keine K27', async () => {
const kauf = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'kauf', unlockedByPurchase: true })
const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, einschaltMailAt: null })
const aus = await einrichtung({ ticket: null, state: 'off', einschaltMailAt: null })
const team = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'team' })
await sf.runActivation({ now: JETZT })
assert.equal(tickets().length, 1, 'nur das Team-Dokument bekam ein Ticket')
assert.equal(tickets()[0].$id, feld(team, 'ticketId'))
assert.equal(sink.sent.length, 0)
for (const id of [kauf, aktiv, aus]) assert.ok(!feld(id, 'ticketId'), id)
assert.ok(!feld(team, 'k24At'))
assert.ok(!feld(team, 'einschaltMailAt'))
})
test('Selbstheilung nur für Zeiten: ein Terminplaner in setup ohne Ticket bleibt unberührt', async () => {
@@ -369,15 +369,15 @@ test('Selbstheilung nur für Zeiten: ein Terminplaner in setup ohne Ticket bleib
})
test('Selbstheilung: ein Dokument scheitert (Ticketsystem down) ⇒ die übrigen werden geheilt, der Job wirft nicht, das Log nennt keine Kundendaten', async () => {
const kaputt = await einrichtung({ ticket: null, einbau: '', k24At: null })
const heil = await einrichtung({ ticket: null, einbau: '', k24At: null, customerId: 'c2' })
const kaputt = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
const heil = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, customerId: 'c2' })
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' })
const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done, [])
assert.ok(!feld(kaputt, 'ticketId'))
assert.ok(feld(heil, 'ticketId'), 'das zweite Dokument wurde trotzdem geheilt')
assert.equal(k24().length, 1)
assert.deepEqual(k24()[0].to.map((a) => a.address), ['k2@example.invalid'])
assert.equal(k27().length, 1)
assert.deepEqual(k27()[0].to.map((a) => a.address), ['k2@example.invalid'])
const log = logText()
assert.match(log, /Ticketsystem down/)
assert.doesNotMatch(log, /example\.invalid|Kunde Eins|Kunde Zwei/)
@@ -385,7 +385,7 @@ test('Selbstheilung: ein Dokument scheitert (Ticketsystem down) ⇒ die übrigen
// der nächste Lauf holt das gescheiterte nach
await sf.runActivation({ now: JETZT })
assert.ok(feld(kaputt, 'ticketId'))
assert.equal(k24().length, 2)
assert.equal(k27().length, 2)
})
test('Selbstheilung: Listenfehler beim Suchen der Dokumente ⇒ kein Wurf, K16 der schon freigeschalteten bleibt', async () => {
@@ -399,24 +399,24 @@ test('Selbstheilung: Listenfehler beim Suchen der Dokumente ⇒ kein Wurf, K16 d
})
test('Projektliste eines Kunden (runActivation mit customerId) schaltet nur frei: keine Selbstheilung, kein Neubau, kein Ticket, keine Mail', async () => {
const unvollstaendig = await einrichtung({ ticket: null, einbau: '', k24At: null })
const unvollstaendig = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
const fertig = await einrichtung({ zeiten: WOCHE })
const done = await sf.runActivation({ now: JETZT, customerId: 'c1' })
assert.deepEqual(done.map((d) => d.$id), [fertig], 'Freischalten gilt weiter für den Kunden')
assert.ok(!feld(unvollstaendig, 'ticketId'))
assert.deepEqual([tickets().length, rebuilds.length, k24().length], [1, 0, 0], 'nur das geschlossene Ticket der fertigen Funktion')
assert.deepEqual([tickets().length, rebuilds.length, k27().length], [1, 0, 0], 'nur das geschlossene Ticket der fertigen Funktion')
assert.equal(k16().length, 1)
// der Lauf des Jobs holt es dann nach
await sf.runActivation({ now: JETZT })
assert.ok(feld(unvollstaendig, 'ticketId'))
assert.deepEqual([tickets().length, rebuilds.length, k24().length], [2, 1, 1])
assert.deepEqual([tickets().length, rebuilds.length, k27().length], [2, 1, 1])
})
test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K24 mehr nachgeschoben', async () => {
const id = await einrichtung({ zeiten: WOCHE, k24At: null })
test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K27 mehr nachgeschoben', async () => {
const id = await einrichtung({ zeiten: WOCHE, einschaltMailAt: null })
const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done.map((d) => d.$id), [id])
assert.equal(zustand(id), 'active')
assert.equal(k24().length, 0)
assert.equal(k27().length, 0)
assert.equal(k16().length, 1)
})