Ö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:
1 parent
ea4457e6a4
commit
c7cd436ec2
34 files changed
+359
-354
No files matched your search
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user