Öffnungszeiten: Team-Aktionen und Ersatz-Ticket beim Abschalten

Team-Route für zeiten: Aktivieren läuft über den Gratis-Pfad (einschaltenZeiten, setup,
unlockedBy team, Neubau, Einbau-Ticket, keine K24), Abschalten setzt disabled mit Grund team
ohne Löschfrist und legt über nachAbschaltung bei geändertem Wochenplan das Ticket
"Ersatz-Tabelle aktualisieren" an. PATCH /:customerId/zeiten/team speichert die
Team-Einstellungen mit teamVersion. Die Team-Ansicht liefert für zeiten zusätzlich plaene,
team, teamVersion und hindernis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 02:39:43 +00:00
1 parent 11dc4b2c9f
commit 97e6971276
3 files changed
+780 -13

No files matched your search

+560
View File
@@ -0,0 +1,560 @@
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'
// Team-Aktionen fuer Oeffnungszeiten live (Aufgabe 19, Spec 4.5, 5 und 8): POST /api/team/funktionen/:customerId/zeiten
// (activate ueber den Gratis-Pfad einschaltenZeiten, disable + nachAbschaltung), PATCH /:customerId/zeiten/team und die
// erweiterte Ansicht (plaene, team, teamVersion, hindernis) hinter requireStaffJwt.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
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 { startTestApp, send, post } = await import('./support/testApp.js')
const { seedFixtures } = await import('./support/fixtures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { speichereZeiten, speichereTeam, setzeErsatzHash, ladeZeiten } = await import('../server/services/zeiten/speicher.js')
const { ersatzHtml, wochenplanHash } = await import('../server/services/zeiten/ersatz.js')
const { hauptplan } = await import('../server/services/zeiten/zeitlogik.js')
const { nachAbschaltung } = await import('../server/services/zeiten/abschalten.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const app = await startTestApp({ '/api/team': teamRoutes })
after(() => Promise.all([app.close(), aw.close()]))
after(() => setMailTransportForTests(null))
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const SITE_KEY = 'abcdefghijklmnop'
const P1 = 'p_aaaaaaa1'
const P2 = 'p_aaaaaaa2'
const projekt = (id, over = {}) => ({ $id: id, customerId: 'c1', projectName: id === 'p1' ? 'Salon Haarlinie' : `Projekt ${id}`, subdomain: `sub-${id}`, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
const featureDoc = (projectId, over = {}) => ({
$id: `f_${projectId}_zeiten`,
customerId: 'c1',
projectId,
featureKey: 'zeiten',
state: 'setup',
enabled: false,
unlockedBy: 'team',
unlockedByPurchase: false,
siteKey: SITE_KEY,
...over,
})
const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`)
const tickets = () => [...aw.docs('workorders').values()]
function tage(woche = {}) {
const t = {}
for (let d = 1; d <= 7; d += 1) {
const w = woche[d]
t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] }
}
return t
}
const planDaten = (id, name, extra = {}) => ({ id, name, haupt: false, tage: tage({ 1: ['09:00', '18:00'] }), ...extra })
const eingabe = (plaene) => ({ land: 'RP', plaene, feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
const EIN_PLAN = () => eingabe([planDaten(P1, 'Standard', { haupt: true })])
const ZWEI_PLAENE = () => eingabe([planDaten(P1, 'Standard', { haupt: true }), planDaten(P2, 'Weihnachten', { tage: tage({ 2: ['10:00', '14:00'] }) })])
/** Wie EIN_PLAN, aber mit anderen Oeffnungszeiten des Hauptplans (neue Version 1 noetig, wenn EIN_PLAN schon gespeichert ist). */
const GEAENDERT = () => eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage({ 1: ['08:00', '12:00'], 5: ['08:00', '12:00'] }) })])
/** Speichert Zeiten wie der Kunde (die Normalisierung vergibt Kurznamen). */
const mitZeiten = (doc, daten, version = 0) => speichereZeiten({ feature: doc, eingabe: daten, version, now: JETZT })
const aktion = (action, extra = {}, { key = 'zeiten', projectId = 'p1', opts = STAFF } = {}) =>
post(`/api/team/funktionen/c1/${key}`, { projectId, action, ...extra }, opts)
const abschalten = (extra, opts) => aktion('disable', extra, opts)
const einstellungen = (body, opts = STAFF) => send('PATCH', '/api/team/funktionen/c1/zeiten/team', { ...opts, body })
const ansicht = async () => (await send('GET', '/api/team/funktionen/c1', STAFF)).body.projects
const zeitenSicht = async (projectId = 'p1') => (await ansicht()).find((p) => p.projectId === projectId).features.zeiten
let sink
let rebuilds
let logs
const originalRebuild = zeitenDeps.rebuild
beforeEach(async () => {
for (const col of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'workorders', 'websiteProjects']) {
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('websiteProjects', projekt('p1'))
aw.seed('websiteProjects', projekt('p2'))
aw.seed('websiteProjects', projekt('p3', { customerId: 'c2' }))
sink = mailSink()
setMailTransportForTests(sink)
rebuilds = []
zeitenDeps.rebuild = async (p) => {
rebuilds.push({ projektId: p.$id, state: zeitenDoc(p.$id)?.state })
return { queued: true }
}
logs = []
const merke = (...args) => logs.push(args.map((a) => (a instanceof Error ? a.message : String(a))).join(' '))
mock.method(console, 'error', merke)
mock.method(console, 'warn', merke)
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
})
// ------------------------------------------------------------------------------------------------ Aktivieren
test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24', async () => {
const res = await aktion('activate', { note: 'Pilot' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['p1'])
const { zeiten, news, termine } = res.body.projects[0].features
assert.equal(zeiten.state, 'setup', 'nicht direkt active (das Einbau-Ticket gibt frei)')
assert.equal(zeiten.unlockedBy, 'team')
assert.equal(zeiten.einbau, 'angestossen')
assert.equal(typeof zeiten.ticketWoid, 'string')
assert.equal(zeiten.ticketMissing, false)
assert.match(zeiten.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/)
assert.equal(news.state, 'off')
assert.equal(termine.state, 'off')
const doc = zeitenDoc()
assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase], ['zeiten', 'setup', false, 'team', false])
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
assert.equal(doc.einbau, 'angestossen')
assert.ok(doc.ticketId)
assert.ok(!doc.k24At, 'keine K24 an den Kunden')
assert.equal(aw.docs('portalFeatures').size, 1, 'kein Dokument fuer News oder Terminplaner')
assert.equal(aw.docs('featureConsents').size, 0, 'kein Zustimmungsnachweis fuer das Team')
// genau ein Neubau (nach dem Speichern des Zustands) und genau ein Einbau-Ticket
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }])
assert.equal(tickets().length, 1)
assert.equal(tickets()[0].type, 'Webpage')
assert.equal(tickets()[0].customerId, 'c1')
assert.match(tickets()[0].details, /data-wk-zeiten/)
assert.equal(sink.sent.length, 0, 'keine Mail')
})
test('Team aktiviert zeiten zweimal: idempotent, kein zweiter Neubau, kein zweites Ticket, keine zweite Notiz', async () => {
const erste = await aktion('activate', { note: 'Pilot' })
const notiz = erste.body.projects[0].features.zeiten.adminNote
const zweite = await aktion('activate', { note: 'nochmal' })
assert.equal(zweite.status, 200, JSON.stringify(zweite.body))
assert.equal(zweite.body.projects[0].features.zeiten.state, 'setup')
assert.equal(zweite.body.projects[0].features.zeiten.adminNote, notiz)
assert.equal(rebuilds.length, 1)
assert.equal(tickets().length, 1)
assert.equal(sink.sent.length, 0)
})
test('Team aktiviert eine abgeschaltete Funktion wieder: setup, Abschaltgrund geräumt, vorhandenes Ticket bleibt', async () => {
aw.seed('workorders', { $id: 't_alt', woid: '10001', topic: 'alt', status: 'Closed', customerId: 'c1' })
aw.seed('portalFeatures', featureDoc('p1', { state: 'disabled', disabledAt: '2026-09-01T10:00:00.000Z', disabledReason: 'team', ticketId: 't_alt', einbau: 'angestossen', adminNote: 'alte Notiz' }))
const res = await aktion('activate')
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.disabledAt ?? null, doc.disabledReason ?? null, doc.unlockedBy], ['setup', null, null, 'team'])
assert.equal(doc.ticketId, 't_alt', 'kein zweites Ticket')
assert.equal(tickets().length, 1)
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }], 'der Neubau setzt einbau neu')
assert.match(doc.adminNote, / Andrej: activate \| alte Notiz$/)
})
test('Aktivieren: fremdes oder unbekanntes Projekt 403, unbekannte Aktion 400, ohne Anmeldung 401, nichts entsteht', async () => {
assert.equal((await aktion('activate', {}, { projectId: 'p3' })).status, 403)
assert.equal((await aktion('activate', {}, { projectId: 'gibtsnicht' })).status, 403)
assert.equal((await aktion('activate', {}, { opts: {} })).status, 401)
assert.equal((await aktion('einschalten')).status, 400)
assert.equal((await post('/api/team/funktionen/c1/zeiten', { action: 'activate' }, STAFF)).status, 400)
assert.equal(aw.docs('portalFeatures').size, 0)
assert.equal(tickets().length, 0)
assert.deepEqual(rebuilds, [])
})
test('Aktivieren: Ticket nicht anlegbar ⇒ 503, Zustand setup gespeichert, ein zweiter Klick holt nach', async () => {
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' })
const kaputt = await aktion('activate')
assert.equal(kaputt.status, 503, JSON.stringify(kaputt.body))
assert.equal(zeitenDoc().state, 'setup')
assert.ok(!zeitenDoc().ticketId)
assert.equal(tickets().length, 0)
const heil = await aktion('activate')
assert.equal(heil.status, 200, JSON.stringify(heil.body))
assert.ok(zeitenDoc().ticketId)
assert.equal(tickets().length, 1)
assert.equal(rebuilds.length, 1, 'der Neubau war schon angestossen')
})
test('Aktivieren: Neubau und Ticket laufen ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => {
let frei = null
zeitenDeps.rebuild = async () => {
const start = Date.now()
// waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort
await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 })
frei = Date.now() - start < 250
return { queued: true }
}
assert.equal((await aktion('activate')).status, 200)
assert.equal(frei, true)
})
test('News und Terminplaner laufen weiter über den alten Pfad (active, kein Ticket)', async () => {
const res = await aktion('activate', { note: 'Kulanz' }, { key: 'news' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.projects[0].features.news.state, 'active')
assert.equal(res.body.projects[0].features.zeiten.state, 'off')
assert.equal(tickets().length, 0)
assert.deepEqual(rebuilds, [])
})
// ------------------------------------------------------------------------------------------------ Einstellungen
test('Team-Einstellungen mit Version: gespeichert, Kundendaten unberührt, zweiter Versuch mit alter Version 409', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const gespeichert = await mitZeiten(doc, EIN_PLAN())
const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false }
const res = await einstellungen(body)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body, { team: { navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 1 })
const nachher = await ladeZeiten(doc.$id)
assert.deepEqual(nachher.team, res.body.team)
assert.equal(nachher.teamVersion, 1)
assert.deepEqual([nachher.version, nachher.daten], [gespeichert.version, gespeichert.daten], 'Daten und Version des Kunden bleiben')
assert.equal(aw.docs('oeffnungszeiten').get(doc.$id).updatedBy, 'team:u_staff2')
// mit der alten Version: 409, nichts geaendert
const veraltet = await einstellungen({ ...body, navSelektor: 'nav', kontaktleisteAus: false })
assert.equal(veraltet.status, 409)
assert.equal(veraltet.body.code, 'ZEITEN_VERALTET')
assert.equal(typeof veraltet.body.error, 'string')
assert.deepEqual((await ladeZeiten(doc.$id)).team, res.body.team)
// ohne Version gilt dasselbe (nie blind schreiben)
const { teamVersion: _weg, ...ohneVersion } = body
assert.equal((await einstellungen(ohneVersion)).status, 409)
// mit der neuen Version geht es weiter; leerer Selektor loescht ihn
const weiter = await einstellungen({ ...body, teamVersion: 1, navSelektor: '' })
assert.equal(weiter.status, 200)
assert.deepEqual(weiter.body, { team: { navSelektor: null, kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 2 })
})
test('Team-Einstellungen: ungültiger Selektor ⇒ 400 mit Feldfehler, nichts gespeichert', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
for (const navSelektor of ['<script>alert(1)</script>', 'a'.repeat(121), 'div{color:red}', 42]) {
const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor, kontaktleisteAus: false, hinweisleisteAus: false })
assert.equal(res.status, 400, String(navSelektor))
assert.equal(typeof res.body.error, 'string')
assert.equal(typeof res.body.fehler?.navSelektor, 'string', String(navSelektor))
}
const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header', kontaktleisteAus: 'ja', hinweisleisteAus: false })
assert.equal(res.status, 400)
assert.equal(typeof res.body.fehler?.kontaktleisteAus, 'string')
assert.equal(await ladeZeiten(doc.$id), null, 'kein Dokument angelegt')
// ein gueltiger Aufruf nach den Fehlern hat noch Version 0
assert.equal((await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header' })).status, 200)
})
test('Team-Einstellungen: Anmeldung, Projekt, Funktion und Zustand werden geprüft', async () => {
const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header' }
assert.equal((await einstellungen(body, {})).status, 401)
assert.equal((await einstellungen({ ...body, projectId: undefined })).status, 400)
assert.equal((await einstellungen({ ...body, projectId: 'p3' })).status, 403)
assert.equal((await einstellungen({ ...body, projectId: 'gibtsnicht' })).status, 403)
// eigenes Projekt ohne Funktionsdokument
const keine = await einstellungen(body)
assert.equal(keine.status, 404)
assert.match(keine.body.error, /Öffnungszeiten live/)
// Funktion aus (zum Beispiel nach der Datenloeschung): keine verwaisten Einstellungen
aw.seed('portalFeatures', featureDoc('p1', { state: 'off' }))
const aus = await einstellungen(body)
assert.equal(aus.status, 409)
assert.equal(aw.docs('oeffnungszeiten').size, 0)
// abgeschaltet: Einstellungen duerfen noch bearbeitet werden (die Daten sind da)
await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'disabled', disabledReason: 'team', disabledAt: JETZT.toISOString() })
assert.equal((await einstellungen(body)).status, 200)
})
// ------------------------------------------------------------------------------------------------ Abschalten
test('Abschalten durch Team: disabled, Grund team, keine Löschfrist, Daten bleiben, keine Mail', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true, activatedAt: JETZT.toISOString() })
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
const vorher = await ladeZeiten(doc.$id)
await setzeErsatzHash(doc, wochenplanHash(vorher.daten))
const res = await abschalten({ note: 'Kunde wünscht' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const sicht = res.body.projects[0].features.zeiten
assert.equal(sicht.state, 'disabled')
assert.equal(sicht.disabledReason, 'team')
assert.ok(sicht.disabledAt)
assert.equal(sicht.loeschungAm, null, 'bei Grund team gibt es keine Loeschfrist')
assert.match(sicht.adminNote, /Andrej: disable – Kunde wünscht$/)
const nach = zeitenDoc()
assert.deepEqual([nach.state, nach.enabled, nach.disabledReason], ['disabled', false, 'team'])
assert.deepEqual((await ladeZeiten(doc.$id)).daten, vorher.daten, 'die Zeiten bleiben')
assert.equal(sink.sent.length, 0)
assert.equal(res.body.ersatzTicket, 'aktuell')
assert.equal(tickets().length, 0)
})
test('Abschalten ohne Funktionsdokument: 409; zweites Abschalten verschiebt disabledAt nicht', async () => {
const keine = await abschalten()
assert.equal(keine.status, 409)
assert.match(keine.body.error, /Öffnungszeiten live/)
assert.equal(aw.docs('portalFeatures').size, 0)
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
assert.equal((await abschalten()).status, 200)
const erstes = zeitenDoc().disabledAt
assert.ok(erstes)
assert.equal((await abschalten()).status, 200)
assert.equal(zeitenDoc().disabledAt, erstes)
assert.equal(zeitenDoc().disabledReason, 'team')
})
test('Abschalten mit geändertem Wochenplan ⇒ Ersatz-Ticket mit aktuellem Ersatz-HTML', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
// der Kunde aendert nach dem Einbau den Wochenplan
const jetzt = await mitZeiten(doc, GEAENDERT(), erst.version)
assert.notEqual(wochenplanHash(jetzt.daten), wochenplanHash(erst.daten))
const res = await abschalten({ note: 'Abo gekündigt' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.ersatzTicket, 'angelegt')
assert.equal(res.body.projects[0].features.zeiten.state, 'disabled')
assert.equal(tickets().length, 1)
const [ticket] = tickets()
assert.equal(ticket.topic, 'Ersatz-Tabelle aktualisieren – Salon Haarlinie')
assert.equal(ticket.type, 'Webpage')
assert.equal(ticket.status, 'Open')
assert.equal(ticket.customerId, 'c1')
assert.ok(ticket.details.includes(ersatzHtml(jetzt.daten, hauptplan(jetzt.daten))), 'aktuelles Ersatz-HTML im Ticket')
assert.ok(!ticket.details.includes(ersatzHtml(erst.daten, hauptplan(erst.daten))), 'nicht das alte')
assert.match(ticket.details, /Salon Haarlinie/)
assert.match(ticket.details, new RegExp(`siteKey: ${SITE_KEY}`))
assert.match(ticket.details, /data-webklar-zeiten/)
assert.doesNotMatch(ticket.details, /24:00|24 Uhr/)
// ein zweiter Klick auf Abschalten legt kein zweites Ticket an (der Hash ist jetzt der des Tickets)
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, wochenplanHash(jetzt.daten))
const nochmal = await abschalten()
assert.equal(nochmal.status, 200)
assert.equal(nochmal.body.ersatzTicket, 'aktuell')
assert.equal(tickets().length, 1)
})
test('Abschalten: das Ersatz-Ticket entsteht ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
await mitZeiten(doc, GEAENDERT(), erst.version)
const echt = globalThis.fetch
let frei = null
mock.method(globalThis, 'fetch', async (url, init) => {
if (init?.method === 'POST' && String(url).includes('/collections/workorders/documents') && frei === null) {
const start = Date.now()
// waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort
await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 })
frei = Date.now() - start < 250
}
return echt(url, init)
})
const res = await abschalten()
assert.equal(res.body.ersatzTicket, 'angelegt', JSON.stringify(res.body))
assert.equal(frei, true)
})
test('Abschalten ohne Änderung des Wochenplans ⇒ kein Ersatz-Ticket', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
// Name und Ausnahmen aendern den Hash nicht (nur tage des Hauptplans)
const mitAusnahme = EIN_PLAN()
mitAusnahme.plaene[0].name = 'Hauptzeiten'
mitAusnahme.ausnahmen = [{ id: 'x_urlaub01', art: 'urlaub', von: '2026-12-21', bis: '2026-12-27', text: 'Weihnachtsurlaub', modus: 'zu', fenster: [], plan: null }]
await mitZeiten(doc, mitAusnahme, erst.version)
const res = await abschalten()
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.ersatzTicket, 'aktuell')
assert.equal(tickets().length, 0)
})
test('Abschalten ohne gemerkten Ersatz-Hash, aber mit Zeiten ⇒ Ersatz-Ticket (R23: unbekannt gilt als geändert)', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, '')
const res = await abschalten()
assert.equal(res.body.ersatzTicket, 'angelegt')
assert.equal(tickets().length, 1)
})
test('Abschalten: Ticket nicht anlegbar ⇒ trotzdem disabled, ersatzTicket fehler, Hash bleibt, nächster Klick versucht es erneut', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
await mitZeiten(doc, GEAENDERT(), erst.version)
const alterHash = wochenplanHash(erst.daten)
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' })
const kaputt = await abschalten()
assert.equal(kaputt.status, 200, JSON.stringify(kaputt.body))
assert.equal(kaputt.body.ersatzTicket, 'fehler')
assert.equal(kaputt.body.projects[0].features.zeiten.state, 'disabled')
assert.equal(tickets().length, 0)
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, alterHash, 'Hash unveraendert: der naechste Versuch legt das Ticket an')
assert.ok(logs.some((zeile) => zeile.includes('Ersatz')), 'steht im Log')
assert.ok(logs.every((zeile) => !/Salon Haarlinie|Standard|Kunde Eins|k@example/.test(zeile)), 'keine Kundendaten im Log')
const heil = await abschalten()
assert.equal(heil.body.ersatzTicket, 'angelegt')
assert.equal(tickets().length, 1)
})
// ------------------------------------------------------------------------------------------------ nachAbschaltung
test('nachAbschaltung: keine Zeiten (kein Dokument, nur Team, nur Nebenplan) ⇒ aktuell, kein Ticket', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'ohne oeffnungszeiten-Dokument')
await speichereTeam({ feature: doc, eingabe: { navSelektor: 'header' }, teamVersion: 0, by: 'team:u_staff2' })
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'nur Team-Einstellungen')
const neben = featureDoc('p2')
aw.seed('portalFeatures', neben)
aw.seed('oeffnungszeiten', {
$id: neben.$id, customerId: 'c1', projectId: 'p2', featureId: neben.$id, version: 1, teamVersion: 0,
daten: JSON.stringify({ land: 'RP', plaene: [{ id: P2, kurz: 'nebenan', name: 'Nebenplan', haupt: false, tage: tage({ 1: ['09:00', '18:00'] }) }] }),
})
assert.equal(await nachAbschaltung(neben, { now: JETZT }), 'aktuell', 'nur ein Nebenplan')
assert.equal(tickets().length, 0)
})
test('nachAbschaltung: wirft nie (Lesefehler, kaputte Eingabe) und liefert fehler', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'Lesen kaputt' })
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'fehler')
assert.equal(await nachAbschaltung(null, { now: JETZT }), 'fehler')
assert.equal(await nachAbschaltung({}, { now: JETZT }), 'fehler')
assert.equal(await nachAbschaltung(undefined), 'fehler')
assert.equal(tickets().length, 0)
assert.ok(logs.every((zeile) => !/Standard|Salon Haarlinie/.test(zeile)))
})
test('nachAbschaltung: Hash gleich ⇒ aktuell, Hash fehlt oder weicht ab ⇒ angelegt (einmal je Wochenplan-Stand)', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell')
await setzeErsatzHash(doc, 'ffffffffffffffff')
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt')
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'der Hash ist jetzt der des Tickets')
assert.equal(tickets().length, 1)
})
test('nachAbschaltung: Projekt nicht mehr da ⇒ Ticket ohne Projektnamen, kein Fehler', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
await deleteDocument('websiteProjects', 'p1')
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt')
assert.equal(tickets()[0].topic, 'Ersatz-Tabelle aktualisieren')
})
// ------------------------------------------------------------------------------------------------ Ansicht
test('adminView zeigt Pläne, team und hindernis (nur für zeiten)', async () => {
// p1: eingerichtet mit zwei Plaenen und Team-Einstellungen, p2: setup ohne Zeiten
const eins = featureDoc('p1')
aw.seed('portalFeatures', eins)
await mitZeiten(eins, ZWEI_PLAENE())
await speichereTeam({ feature: eins, eingabe: { navSelektor: 'header', kontaktleisteAus: true }, teamVersion: 0, by: 'team:u_staff2' })
aw.seed('portalFeatures', featureDoc('p2', { siteKey: 'bbbbbbbbbbbbbbbb' }))
const projekte = await ansicht()
const z1 = projekte.find((p) => p.projectId === 'p1').features.zeiten
assert.deepEqual(z1.plaene, [
{ kurz: 'standard', name: 'Standard', haupt: true },
{ kurz: 'weihnachten', name: 'Weihnachten', haupt: false },
])
assert.deepEqual(z1.team, { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false })
assert.equal(z1.teamVersion, 1)
assert.equal(z1.hindernis, null)
const z2 = projekte.find((p) => p.projectId === 'p2').features.zeiten
assert.deepEqual(z2.plaene, [])
assert.deepEqual(z2.team, { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
assert.equal(z2.teamVersion, 0)
assert.equal(z2.hindernis, 'zeiten_fehlen')
// andere Funktionen bekommen die Felder nicht
for (const key of ['news', 'termine']) {
for (const feld of ['plaene', 'team', 'teamVersion', 'hindernis']) assert.equal(feld in projekte[0].features[key], false, `${key}.${feld}`)
}
})
test('adminView: hindernis nur in setup und nur wenn der Haken freischaltbar nicht erfüllt ist; Hauptplan steht zuerst', async () => {
// Hauptplan nicht an erster Stelle gespeichert
const nebenZuerst = featureDoc('p1')
aw.seed('portalFeatures', nebenZuerst)
await mitZeiten(nebenZuerst, eingabe([planDaten(P2, 'Weihnachten'), planDaten(P1, 'Standard', { haupt: true })]))
assert.deepEqual((await zeitenSicht('p1')).plaene.map((p) => p.kurz), ['standard', 'weihnachten'])
assert.equal((await zeitenSicht('p1')).hindernis, null)
// alle Tage zu: bleibt in setup haengen
await deleteDocument('oeffnungszeiten', nebenZuerst.$id)
await mitZeiten(nebenZuerst, eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage() })]))
assert.equal((await zeitenSicht('p1')).hindernis, 'zeiten_fehlen')
// gleicher Zustand, aber aktiv oder abgeschaltet: kein Hindernis (es gibt nichts mehr freizuschalten)
await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'active', enabled: true })
assert.equal((await zeitenSicht('p1')).hindernis, null)
await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: JETZT.toISOString() })
assert.equal((await zeitenSicht('p1')).hindernis, null)
})
test('adminView ohne Funktionsdokument: leere Pläne, Standard-Team, Version 0, kein Hindernis', async () => {
const z = await zeitenSicht('p1')
assert.equal(z.state, 'off')
assert.deepEqual([z.plaene, z.team, z.teamVersion, z.hindernis], [[], { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }, 0, null])
})