Öffnungszeiten: Freischalten erst mit eingetragenen Zeiten

Der 5-Minuten-Job setzt Öffnungszeiten live nach dem Schließen des Tickets
nur noch auf active, wenn der Kunde Zeiten eingetragen hat (Haken
freischaltbar: Hauptplan gespeichert, mindestens ein Tag nicht "zu";
Lesefehler ergeben false, das Dokument bleibt in setup). Neuer Register-Haken
nachFreischaltung: runActivation ruft ihn je freigeschaltetem Dokument vor der
K16 auf, Fehler werden nur geloggt; für die Zeiten merkt er den Ersatz-Hash
(wochenplanHash). Selbstheilung (Spec 3.2): im Lauf des Jobs (nicht in der
Projektliste mit customerId) holt holeZeitenNach für Zeiten in setup fehlendes
Ticket und K24 nach, je Dokument abgesichert und ohne Kundendaten im Log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 02:28:52 +00:00
1 parent 47ae572fff
commit 11dc4b2c9f
5 files changed
+476 -11

No files matched your search

+2 -2
View File
@@ -155,8 +155,8 @@ test('Haken überschreibbar: config.features.<key>.<haken> gewinnt gegen den Sta
}
assert.deepEqual(reg.featureHaken('termine'), vorherTermine)
// jeder der fünf Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht
const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
// jeder der sechs Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht
const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar', 'nachFreischaltung']
const news = config.features.news
try {
for (const name of namen) news[name] = () => name
+390
View File
@@ -0,0 +1,390 @@
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 { 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.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
},
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const reg = await import('../server/services/featureRegistry.js')
const sf = await import('../server/services/siteFeatures.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { ladeZeiten, speichereZeiten } = await import('../server/services/zeiten/speicher.js')
const { wochenplanHash } = await import('../server/services/zeiten/ersatz.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
after(() => aw.close())
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 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 logText = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
function tage(modi) {
const t = {}
for (let d = 1; d <= 7; d += 1) {
const modus = modi[d] ?? 'zu'
t[d] = { modus, fenster: modus === 'zeiten' ? [['09:00', '18:00']] : [] }
}
return t
}
const WOCHE = { 1: 'zeiten', 2: 'zeiten', 3: 'zeiten', 4: 'zeiten', 5: 'zeiten' }
const eingabe = (modi = WOCHE) => ({
land: 'RP',
plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage(modi) }],
feiertage: {},
ausnahmen: [],
hinweise: [],
kontakt: { an: false, telefon: '', adresse: '', whatsapp: null },
})
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.
*/
async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null, ...over } = {}) {
nr += 1
const projectId = `p${nr}`
const id = `f_${projectId}_zeiten`
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}`, subdomain: projectId, repoFullName: `JUSN/${projectId}`, previewMode: 'auto', previewDetected: 'static' })
if (ticket) aw.seed('workorders', { $id: `t_${projectId}`, woid: `WO-${nr}`, status: ticket, topic: 'x' })
aw.seed('portalFeatures', {
$id: id,
customerId,
projectId,
featureKey: 'zeiten',
state: 'setup',
enabled: false,
siteKey: 'abcdefghijklmnop',
unlockedBy: 'abo',
einbau: 'angestossen',
k24At: JETZT.toISOString(),
...(ticket ? { ticketId: `t_${projectId}` } : {}),
...over,
})
if (zeiten) await speichereZeiten({ feature: aw.docs('portalFeatures').get(id), eingabe: eingabe(zeiten), version: 0, now: JETZT })
return id
}
let sink
let rebuilds
const originalRebuild = zeitenDeps.rebuild
beforeEach(async () => {
for (const col of ['portalFeatures', 'workorders', 'websiteProjects', 'oeffnungszeiten', 'customers']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
resetZeitenCacheForTests()
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
aw.seed('customers', { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true })
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar', 'nachFreischaltung']) delete Z[name]
sink = mailSink()
setMailTransportForTests(sink)
rebuilds = []
zeitenDeps.rebuild = async (p) => {
rebuilds.push(p.$id)
return { queued: true }
}
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
})
// ------------------------------------------------------------------------------------------------ Freischalten
test('Ticket zu, aber keine Zeiten ⇒ bleibt setup, kein K16, kein Ersatz-Hash', async () => {
const id = await einrichtung()
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(feld(id, 'enabled'), false)
assert.equal(sink.sent.length, 0)
assert.equal(await ladeZeiten(id), null, 'ohne Zeiten entsteht auch kein Zeiten-Dokument')
// jeder weitere Lauf ändert nichts; die Wartenotiz steht genau einmal (kein Schreiben alle 5 Minuten)
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(String(feld(id, 'adminNote')).split(' | ').filter((e) => e.includes('Freischaltung wartet')).length, 1)
})
test('Ticket zu, Zeiten gespeichert, aber alle Tage „zu“ ⇒ bleibt setup', async () => {
const id = await einrichtung({ zeiten: {} })
assert.ok((await ladeZeiten(id)).daten, 'die Zeiten sind gespeichert')
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(sink.sent.length, 0)
assert.equal((await ladeZeiten(id)).ersatzHash, '')
})
test('Zeiten ohne Hauptplan (nur ein Nebenplan) oder ohne Pläne ⇒ bleibt setup', async () => {
const nebenplan = await einrichtung()
aw.seed('oeffnungszeiten', {
$id: nebenplan, customerId: 'c1', projectId: aw.docs('portalFeatures').get(nebenplan).projectId, featureId: nebenplan, version: 1, teamVersion: 0,
daten: JSON.stringify({ land: 'RP', plaene: [{ id: 'p_bbbbbbb1', name: 'Nebenplan', haupt: false, tage: tage(WOCHE) }] }),
})
const leer = await einrichtung()
aw.seed('oeffnungszeiten', {
$id: leer, customerId: 'c1', projectId: aw.docs('portalFeatures').get(leer).projectId, featureId: leer, version: 1, teamVersion: 0,
daten: JSON.stringify({ land: 'RP', plaene: [] }),
})
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.deepEqual([zustand(nebenplan), zustand(leer)], ['setup', 'setup'])
assert.equal(sink.sent.length, 0)
})
test('Ticket zu und Zeiten gespeichert ⇒ active, genau eine K16, ersatzHash gesetzt', async () => {
const id = await einrichtung({ zeiten: WOCHE })
const vorher = await ladeZeiten(id)
assert.equal(vorher.ersatzHash, '')
const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done.map((d) => d.$id), [id])
assert.equal(zustand(id), 'active')
assert.equal(feld(id, 'enabled'), true)
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)
const nachher = await ladeZeiten(id)
assert.match(nachher.ersatzHash, /^[0-9a-f]{16}$/)
assert.equal(nachher.ersatzHash, wochenplanHash(vorher.daten))
// der Ersatz-Hash fasst weder Daten noch Versionen an: ein offener Editor wird nicht veraltet
assert.deepEqual([nachher.version, nachher.teamVersion, nachher.daten], [vorher.version, vorher.teamVersion, vorher.daten])
// ein zweiter Lauf: nichts mehr zu tun, keine zweite Mail
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(k16().length, 1)
})
test('ein einziger Tag, der nicht „zu“ ist, reicht (auch „rund“ oder „nach Vereinbarung“)', async () => {
const rund = await einrichtung({ zeiten: { 7: 'rund' } })
const vereinbarung = await einrichtung({ zeiten: { 3: 'vereinbarung' } })
const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done.map((d) => d.$id).sort(), [rund, vereinbarung].sort())
assert.equal(k16().length, 2)
})
test('Ticket noch offen ⇒ bleibt setup, auch mit Zeiten (der Job schaltet nur nach dem Schließen frei)', async () => {
const id = await einrichtung({ ticket: 'Open', zeiten: WOCHE })
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(sink.sent.length, 0)
assert.equal((await ladeZeiten(id)).ersatzHash, '')
})
test('Zeiten kommen nach dem Schließen des Tickets ⇒ der nächste Lauf schaltet frei', async () => {
const id = await einrichtung()
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
await speichereZeiten({ feature: aw.docs('portalFeatures').get(id), eingabe: eingabe(), version: 0, now: JETZT })
assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id])
assert.equal(zustand(id), 'active')
assert.equal(k16().length, 1)
})
test('Lesefehler im Haken ⇒ bleibt setup, kein Wurf, kein K16; der nächste Lauf schaltet frei', async () => {
const id = await einrichtung({ zeiten: WOCHE })
aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'Appwrite down' })
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(sink.sent.length, 0)
assert.equal(feld(id, 'enabled'), false)
assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id])
assert.equal(zustand(id), 'active')
})
test('Haken freischaltbar der Zeiten: Hauptplan mit offenem Tag ⇒ true, sonst false, Lesefehler ⇒ false (fail-closed)', async () => {
const { freischaltbar } = reg.featureHaken('zeiten')
assert.equal(typeof freischaltbar, 'function')
const offen = await einrichtung({ zeiten: { 2: 'zeiten' } })
const zu = await einrichtung({ zeiten: {} })
const ohne = await einrichtung()
assert.equal(await freischaltbar(aw.docs('portalFeatures').get(offen)), true)
assert.equal(await freischaltbar(aw.docs('portalFeatures').get(zu)), false)
assert.equal(await freischaltbar(aw.docs('portalFeatures').get(ohne)), false)
assert.equal(await freischaltbar({ $id: 'gibt-es-nicht' }), false)
aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500 })
assert.equal(await freischaltbar(aw.docs('portalFeatures').get(offen)), false)
})
// ----------------------------------------------------------------------------------------------- Ersatz-Hash
test('nachFreischaltung scheitert (Ersatz-Hash nicht schreibbar) ⇒ trotzdem active, K16 geht raus, Fehler im Log', async () => {
const id = await einrichtung({ zeiten: WOCHE })
aw.failNext({ collection: 'oeffnungszeiten', method: 'PATCH', status: 500, message: 'Schreiben kaputt' })
const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done.map((d) => d.$id), [id])
assert.equal(zustand(id), 'active')
assert.equal(k16().length, 1)
assert.equal((await ladeZeiten(id)).ersatzHash, '', 'nicht gesetzt')
assert.match(logText(), /Schreiben kaputt/)
})
test('nachFreischaltung: nur Zeiten haben den Haken (News und Terminplaner nicht)', () => {
assert.equal(typeof reg.featureHaken('zeiten').nachFreischaltung, 'function')
assert.equal(reg.featureHaken('news').nachFreischaltung, undefined)
assert.equal(reg.featureHaken('termine').nachFreischaltung, undefined)
})
test('nachFreischaltung des Registers wird je freigeschaltetem Dokument mit dem aktiven Dokument gerufen; ein Wurf stoppt die übrigen nicht', async () => {
const a = await einrichtung({ zeiten: WOCHE })
const b = await einrichtung({ zeiten: WOCHE })
const gerufen = []
Z.nachFreischaltung = async (doc) => {
gerufen.push([doc.$id, doc.state])
if (doc.$id === a) throw new Error('Haken kaputt')
}
const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done.map((d) => d.$id).sort(), [a, b].sort())
assert.deepEqual(gerufen.sort(), [[a, 'active'], [b, 'active']].sort())
assert.equal(k16().length, 2, 'beide Kunden bekommen ihre K16')
assert.match(logText(), /Haken kaputt/)
})
// ------------------------------------------------------------------------------------------- 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 })
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(tickets().length, 1)
assert.equal(feld(id, 'ticketId'), tickets()[0].$id)
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'))
// 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])
})
test('Selbstheilung: unlockedBy abo ohne k24At ⇒ der Job sendet die K24 genau einmal', async () => {
const id = await einrichtung({ ticket: 'Open', k24At: 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(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 })
setMailTransportForTests({ async sendMail() { throw new Error('SMTP down') } })
await sf.runActivation({ now: JETZT })
assert.ok(!feld(id, 'k24At'))
setMailTransportForTests(sink)
await sf.runActivation({ now: JETZT })
assert.equal(k24().length, 1)
assert.ok(feld(id, 'k24At'))
})
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' })
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'))
})
test('Selbstheilung nur für Zeiten: ein Terminplaner in setup ohne Ticket bleibt unberührt', async () => {
aw.seed('websiteProjects', { $id: 'pt', customerId: 'c1', projectName: 'Termine' })
aw.seed('portalFeatures', { $id: 'f_pt_termine', customerId: 'c1', projectId: 'pt', featureKey: 'termine', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop' })
await sf.runActivation({ now: JETZT })
assert.equal(tickets().length, 0)
assert.equal(sink.sent.length, 0)
})
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' })
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'])
const log = logText()
assert.match(log, /Ticketsystem down/)
assert.doesNotMatch(log, /example\.invalid|Kunde Eins|Kunde Zwei/)
// der nächste Lauf holt das gescheiterte nach
await sf.runActivation({ now: JETZT })
assert.ok(feld(kaputt, 'ticketId'))
assert.equal(k24().length, 2)
})
test('Selbstheilung: Listenfehler beim Suchen der Dokumente ⇒ kein Wurf, K16 der schon freigeschalteten bleibt', async () => {
const id = await einrichtung({ zeiten: WOCHE })
// GET auf portalFeatures: 1 Liste der Freischaltung, 2 frisches Lesen unter der Sperre, 3 Liste der Selbstheilung
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, after: 2 })
const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done.map((d) => d.$id), [id])
assert.equal(k16().length, 1)
assert.match(logText(), /Selbstheilung Öffnungszeiten: Dokumente nicht lesbar/, 'der Fehler traf die Liste der Selbstheilung')
})
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 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.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])
})
test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K24 mehr nachgeschoben', async () => {
const id = await einrichtung({ zeiten: WOCHE, k24At: 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(k16().length, 1)
})
+4 -3
View File
@@ -104,14 +104,15 @@ test('verfuegbar folgt ZEITEN_ENABLED', () => {
}
})
test('Standard-Haken: verfuegbar, unterlagenBereit, belehrung, freischaltbar (keine Bestellprüfung)', async () => {
test('Standard-Haken: verfuegbar, unterlagenBereit, belehrung, freischaltbar, nachFreischaltung (keine Bestellprüfung)', async () => {
const haken = reg.featureHaken('zeiten')
assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'freischaltbar', 'unterlagenBereit', 'verfuegbar'])
assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'freischaltbar', 'nachFreischaltung', 'unterlagenBereit', 'verfuegbar'])
const termine = reg.featureHaken('termine')
assert.equal(await haken.unterlagenBereit(), true)
assert.equal(await haken.belehrung(), await termine.belehrung(), 'wie beim Terminplaner')
assert.equal(await haken.belehrung(), reg.BELEHRUNG_PLATZHALTER)
assert.equal(await haken.freischaltbar({ $id: 'x' }), true, 'Platzhalter bis Task 18')
// kein Platzhalter mehr (der alte Haken lieferte immer true): Verhalten und Nacharbeit prüft test/zeitenFreischalten.test.js
for (const name of ['freischaltbar', 'nachFreischaltung']) assert.equal(typeof haken[name], 'function', name)
})
test('oeffentlicheAnsicht(zeiten) hat dieselben Schlüssel wie news', () => {