import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' // Einbau-Ticket von Oeffnungszeiten live (Aufgabe 15, Spec 4.6) und Ersatz-HTML fuer das Team (Spec 4.5, Ruling R9): // zeitenTicketDetails(ctx) und GET /api/team/funktionen/:customerId/zeiten/ersatz?projectId=&plan= hinter requireStaffJwt. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), }, datetimes: ZEITEN_DATETIMES, defaultLimit: 25, }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { startTestApp, send } = await import('./support/testApp.js') const { seedFixtures } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { TICKET_DETAILS_MAX } = await import('../server/services/featureRegistry.js') const { config } = await import('../server/config.js') const { zeitenTicketDetails } = await import('../server/services/zeiten/ticketDetails.js') const { speichereZeiten, speichereTeam } = await import('../server/services/zeiten/speicher.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()])) 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 SITE_KEY = 'abcdefghijklmnop' const P1 = 'p_aaaaaaa1' const P2 = 'p_aaaaaaa2' const featureDoc = (projectId, over = {}) => ({ $id: `f_${projectId}_zeiten`, customerId: 'c1', projectId, featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: SITE_KEY, ...over, }) const projekt = (over = {}) => ({ $id: 'p1', customerId: 'c1', projectName: 'Salon Haarlinie', subdomain: 'salon', ...over }) const ctxVon = (doc, extra = {}) => ({ feature: config.features.zeiten, doc, project: projekt(), customer: { name: 'Kunde Eins' }, ...extra, }) 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'] }) })]) /** Speichert Zeiten wie der Kunde (Normalisierung vergibt die Kurznamen) und liefert die Funktionsdokument-Felder. */ async function mitZeiten(doc, daten) { await speichereZeiten({ feature: doc, eingabe: daten, version: 0, now: new Date('2026-10-05T08:00:00.000Z') }) } const ersatz = (customerId, query, opts = STAFF) => send('GET', `/api/team/funktionen/${customerId}/zeiten/ersatz${query}`, opts) beforeEach(async () => { for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'websiteProjects']) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } resetZeitenCacheForTests() aw.seed('websiteProjects', projekt()) aw.seed('websiteProjects', projekt({ $id: 'p2', projectName: 'Studio', subdomain: 'studio' })) aw.seed('websiteProjects', projekt({ $id: 'p3', customerId: 'c2', projectName: 'Fremder Salon', subdomain: 'fremd' })) }) // ------------------------------------------------------------------------------------------------ Ticket-Text test('Ticket nennt siteKey, Einbau-Zeile und Prüfliste', async () => { const doc = featureDoc('p1', { einbau: 'angestossen' }) aw.seed('portalFeatures', doc) await mitZeiten(doc, ZWEI_PLAENE()) const text = await zeitenTicketDetails(ctxVon(doc)) assert.equal(typeof text, 'string') assert.match(text, /^Funktion: zeiten$/m) assert.match(text, /^Projekt: Salon Haarlinie$/m) assert.match(text, new RegExp(`^siteKey: ${SITE_KEY}$`, 'm')) // Einbau-Zeile wortgleich assert.ok( text.split('\n').includes(``), 'Einbau-Zeile als eigene Zeile' ) assert.match(text, /automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/) assert.doesNotMatch(text, /bitte von Hand einsetzen/) // Platzhalter-Beispiele mit den Kurznamen der gespeicherten Pläne assert.match(text, /
Ersatz-HTML<\/div>/) assert.match(text, /
Ersatz-HTML<\/div>/) assert.match(text, /
Ersatz-HTML<\/div>/) assert.match(text, /Pläne \(Kurznamen für data-plan\): standard \(Hauptplan\), weihnachten/) assert.doesNotMatch(text, /Pläne folgen/) // Hinweis auf das Ersatz-HTML im WOMS assert.match(text, /Ersatz-HTML: WOMS → Funktionen → Ersatz-HTML kopieren \(erst nach dem ersten Speichern des Kunden\)/) // Prüfliste aus Spec 4.6 assert.match(text, /Prüfliste/) assert.match(text, /grep -ril "öffnungszeit"/) assert.match(text, /Footer/) assert.match(text, /Kontaktseite/) assert.match(text, /Status-Skript/) assert.match(text, /Header/) assert.match(text, /navSelektor/) assert.match(text, /Anruf-Leiste/) assert.match(text, /JSON-LD/) assert.match(text, /Datenschutz/) }) test('einbau manuell ⇒ „bitte von Hand einsetzen“', async () => { for (const einbau of ['manuell', undefined, '']) { const doc = featureDoc('p1', einbau === undefined ? {} : { einbau }) const text = await zeitenTicketDetails(ctxVon(doc)) assert.match(text, /bitte von Hand einsetzen/, `einbau=${einbau}`) assert.doesNotMatch(text, /automatisch eingesetzt/, `einbau=${einbau}`) assert.match(text, new RegExp(`data-site="${SITE_KEY}"`)) } }) test('Ticket ohne gespeicherte Zeiten: Pläne folgen, Kurzname als Platzhalter', async () => { const doc = featureDoc('p1', { einbau: 'manuell' }) aw.seed('portalFeatures', doc) const text = await zeitenTicketDetails(ctxVon(doc)) assert.match(text, /Pläne folgen, sobald der Kunde gespeichert hat/) assert.match(text, /
Ersatz-HTML<\/div>/) // ohne Dokument-ID (Notfall) und bei Lesefehlern bleibt das Ticket erhalten const ohneId = await zeitenTicketDetails(ctxVon({ customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY })) assert.match(ohneId, /Pläne folgen, sobald der Kunde gespeichert hat/) aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'kaputt' }) const mitFehler = await zeitenTicketDetails(ctxVon(doc)) assert.match(mitFehler, /Pläne folgen, sobald der Kunde gespeichert hat/) assert.match(mitFehler, /Prüfliste/) }) test('Ticket bleibt unter TICKET_DETAILS_MAX', async () => { const doc = featureDoc('p1', { einbau: 'angestossen' }) aw.seed('portalFeatures', doc) const fuenf = eingabe( ['Montag bis Freitag', 'Samstags im Sommerhalbjahr', 'Weihnachten', 'Ostern und Pfingsten', 'Betriebsferien'].map((name, i) => planDaten(`p_plan_${i + 1}`, `${name} ${'x'.repeat(30)}`, { haupt: i === 0 }) ) ) await mitZeiten(doc, fuenf) const normal = await zeitenTicketDetails(ctxVon(doc)) assert.ok(normal.length <= TICKET_DETAILS_MAX, `normal: ${normal.length}`) assert.match(normal, /Prüfliste/, 'nichts wird abgeschnitten') // ein riesiger Projektname schiebt den Text nie ueber den Deckel const riesig = await zeitenTicketDetails(ctxVon(doc, { project: projekt({ projectName: 'x'.repeat(20000) }) })) assert.ok(riesig.length <= TICKET_DETAILS_MAX, `riesig: ${riesig.length}`) }) test('Ticket über das Register: Zeiten-Details, nicht mehr die Notfall-Details allein', async () => { const doc = featureDoc('p1', { einbau: 'angestossen' }) const text = await config.features.zeiten.ticket.details(ctxVon(doc)) assert.match(text, /data-wk-zeiten/) }) // ------------------------------------------------------------------------------------------------ Ersatz-Endpunkt test('Ersatz-Endpunkt: Team-JWT nötig, {html} als JSON, 409 ohne gespeicherte Zeiten', async () => { const doc = featureDoc('p1') aw.seed('portalFeatures', doc) // ohne Anmeldung / falsche Anmeldung assert.equal((await ersatz('c1', `?projectId=p1`, {})).status, 401) assert.equal((await ersatz('c1', `?projectId=p1`, { headers: { Authorization: 'Bearer jwt-unbekannt' } })).status, 401) // Funktion da, Kunde hat noch nichts gespeichert const leer = await ersatz('c1', '?projectId=p1') assert.equal(leer.status, 409) assert.deepEqual(leer.body, { error: 'Der Kunde hat noch keine Zeiten eingetragen.' }) // nach dem Speichern: Tabelle des Hauptplans await mitZeiten(doc, ZWEI_PLAENE()) const res = await ersatz('c1', '?projectId=p1') assert.equal(res.status, 200) assert.match(res.headers.get('content-type'), /application\/json/) assert.deepEqual(Object.keys(res.body), ['html']) assert.match(res.body.html, /^/) assert.match(res.body.html, /
Standard<\/caption>/) assert.match(res.body.html, /
Montag<\/th>09:00–18:00<\/td>/) assert.doesNotMatch(res.body.html, /[^\x00-\x7f]/, 'reines ASCII') }) test('Ersatz-Endpunkt: Plan per Kurzname, unbekannter Plan ⇒ 404 ohne Rückfall auf den Hauptplan (R9)', async () => { const doc = featureDoc('p1') aw.seed('portalFeatures', doc) await mitZeiten(doc, ZWEI_PLAENE()) const zweiter = await ersatz('c1', '?projectId=p1&plan=weihnachten') assert.equal(zweiter.status, 200) assert.match(zweiter.body.html, /
Weihnachten<\/caption>/) assert.match(zweiter.body.html, /
Dienstag<\/th>10:00–14:00<\/td>/) // leerer Wert, falsche Schreibweise und doppelter Parameter zaehlen als angegeben und nicht gefunden for (const query of ['&plan=gibtsnicht', '&plan=', '&plan=Standard', '&plan=standard&plan=weihnachten']) { const res = await ersatz('c1', `?projectId=p1${query}`) assert.equal(res.status, 404, query) assert.deepEqual(res.body, { error: 'Plan nicht gefunden' }, query) } // ohne Parameter gilt der Hauptplan const haupt = await ersatz('c1', '?projectId=p1') assert.match(haupt.body.html, /
Standard<\/caption>/) }) test('Ersatz-Endpunkt: nur Team-Einstellungen gespeichert ⇒ 409, Plan angegeben ⇒ ebenfalls 409 (nichts zu suchen)', async () => { const doc = featureDoc('p1') aw.seed('portalFeatures', doc) await speichereTeam({ feature: doc, eingabe: { navSelektor: 'header' }, teamVersion: 0, by: 'team:u_staff2' }) const res = await ersatz('c1', '?projectId=p1') assert.equal(res.status, 409) assert.equal(res.body.error, 'Der Kunde hat noch keine Zeiten eingetragen.') assert.equal((await ersatz('c1', '?projectId=p1&plan=standard')).status, 409) }) test('Ersatz-Endpunkt: Projekt muss dem Kunden gehören, Funktionsdokument muss existieren', async () => { aw.seed('portalFeatures', featureDoc('p1')) aw.seed('portalFeatures', featureDoc('p3', { customerId: 'c2' })) await mitZeiten(featureDoc('p1'), EIN_PLAN()) // Projekt fehlt im Aufruf const ohne = await ersatz('c1', '') assert.equal(ohne.status, 400) assert.deepEqual(ohne.body, { error: 'Projekt fehlt' }) // fremdes Projekt / unbekanntes Projekt assert.equal((await ersatz('c1', '?projectId=p3')).status, 403) assert.equal((await ersatz('c1', '?projectId=gibtsnicht')).status, 403) // eigenes Projekt ohne Funktionsdokument const keine = await ersatz('c1', '?projectId=p2') assert.equal(keine.status, 404) assert.match(keine.body.error, /Öffnungszeiten live/) // der Pfad kollidiert nicht mit den Funktionen-Routen assert.equal((await send('GET', '/api/team/funktionen/c1', STAFF)).status, 200) })