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>
561 lines
29 KiB
JavaScript
561 lines
29 KiB
JavaScript
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])
|
||
})
|