Files
Webklar-Kundenbereich/test/zeitenTeam.test.js
T
ClaudeandClaude Opus 5.5 c7cd436ec2 Ö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>
2026-10-06 06:00:17 +00:00

621 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', 'einschaltMailAt'],
},
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 K27', 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.einschaltMailAt, 'keine K27 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$/)
})
// Merge mit TP2: die Aktionen abnahme und ticket gibt es fuer jede Funktion mit ticketFreischaltung; fuer die Zeiten darf
// abnahme weder abschalten noch den Gratis-Pfad nehmen, sondern gibt in setup frei (Haken freischaltbar, Ersatz-Hash)
test('Team „Freischalten (Abnahme)“ der Zeiten: ohne Zeiten 409 (bleibt setup), mit Zeiten active mit Ersatz-Hash, nie disabled', async () => {
const doc = featureDoc('p1', { ticketId: 't_offen', einbau: 'angestossen' })
aw.seed('workorders', { $id: 't_offen', woid: '10002', topic: 'Zeiten', status: 'Open', customerId: 'c1' })
aw.seed('portalFeatures', doc)
const zu = await aktion('abnahme')
assert.equal(zu.status, 409, JSON.stringify(zu.body))
assert.equal(zeitenDoc().state, 'setup')
assert.equal(sink.sent.length, 0)
await mitZeiten(doc, EIN_PLAN())
const ok = await aktion('abnahme')
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(ok.body.projects[0].features.zeiten.state, 'active')
assert.equal(zeitenDoc().state, 'active')
assert.match((await ladeZeiten(doc.$id)).ersatzHash, /^[0-9a-f]{16}$/)
assert.equal(sink.sent.length, 1, 'K16 an den Kunden')
assert.equal(ok.body.ersatzTicket, undefined, 'abnahme legt kein Ersatz-Ticket an')
})
// Review N8 / I1: „Ticket nachlegen“ lief für Abo und Team in den Zahlungsweg und scheiterte immer mit 409 „keine Zahlung vermerkt“
test('Team „Ticket nachlegen“ der Zeiten (Team, setup, ohne Ticket): kein 409, Neubau und Einbau-Ticket kommen über das Nachholen, keine K27', async () => {
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'team', einbau: '' }))
const res = await aktion('ticket')
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = zeitenDoc()
assert.ok(doc.ticketId, 'Ticket angelegt')
assert.equal(doc.einbau, 'angestossen')
assert.equal(tickets().length, 1)
assert.equal(rebuilds.length, 1)
assert.equal(res.body.projects[0].features.zeiten.ticketMissing, false)
assert.match(doc.adminNote, / Andrej: ticket$/)
assert.equal(sink.sent.length, 0, 'unlockedBy team: keine K27')
// zweiter Klick: das Ticket ist da, nichts entsteht neu
assert.equal((await aktion('ticket')).status, 200)
assert.equal(tickets().length, 1)
assert.equal(rebuilds.length, 1)
})
test('Team „Ticket nachlegen“ der Zeiten (Abo, setup, ohne Ticket): Ticket und K27 über das Nachholen, nicht über den Zahlungsweg', async () => {
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'abo', einbau: 'angestossen' }))
const res = await aktion('ticket')
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.ok(zeitenDoc().ticketId)
assert.equal(tickets().length, 1)
assert.equal(sink.sent.length, 1, 'K27 an den Kunden (unlockedBy abo)')
assert.ok(zeitenDoc().einschaltMailAt)
})
test('Team „Ticket nachlegen“ der Zeiten (gekauft, ohne Zahlungsvermerk): bleibt der Zahlungsweg (409)', async () => {
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true, einbau: 'angestossen' }))
const res = await aktion('ticket')
assert.equal(res.status, 409, JSON.stringify(res.body))
assert.match(res.body.error, /keine Zahlung vermerkt/)
assert.equal(tickets().length, 0)
})
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 Zeiten-Felder nicht; hindernis tragen seit TP2 alle (null ausser beim Terminplaner in setup)
for (const key of ['news', 'termine']) {
for (const feld of ['plaene', 'team', 'teamVersion']) assert.equal(feld in projekte[0].features[key], false, `${key}.${feld}`)
assert.equal(projekte[0].features[key].hindernis, null, `${key}.hindernis`)
}
})
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])
})