From 8d670ca5dcdafd4ebcd3d1bb44aa29198d6178e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:48:20 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Einbau-Ticket=20und=20Er?= =?UTF-8?q?satz-HTML=20f=C3=BCr=20das=20Team?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/admin/funktionenAdmin.js | 32 +++ server/services/zeiten/ticketDetails.js | 72 ++++++- test/zeitenRegister.test.js | 5 +- test/zeitenTicket.test.js | 253 ++++++++++++++++++++++++ 4 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 test/zeitenTicket.test.js diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 065145e..4e4b4f4 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -12,6 +12,9 @@ import { listCustomerFeatureDocs, listCustomerFeatures, } from '../../services/siteFeatures.js' +import { ersatzHtml } from '../../services/zeiten/ersatz.js' +import { ladeZeiten } from '../../services/zeiten/speicher.js' +import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js' /** * Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen"). @@ -69,6 +72,35 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => { } }) +/** + * Ersatz-HTML (statische Wochentabelle) eines Plans von Oeffnungszeiten live fuer den Einbau (Spec 4.5): { html }. + * Ohne Parameter plan gilt der Hauptplan; ist plan angegeben (auch leer), muss es diesen Kurznamen geben, sonst 404 und + * kein Rueckfall auf den Hauptplan (Ruling R9). Noch keine gespeicherten Zeiten -> 409. Nur lesend, ohne Sperre. + */ +funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => { + try { + const { customerId } = req.params + const { projectId, plan } = req.query + if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' }) + await assertOwnProject(customerId, projectId) + const doc = await getFeatureDoc(customerId, projectId, 'zeiten') + if (!doc) return res.status(404).json({ error: `Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}` }) + const daten = (await ladeZeiten(doc.$id))?.daten + const keineZeiten = () => res.status(409).json({ error: 'Der Kunde hat noch keine Zeiten eingetragen.' }) + if (!Array.isArray(daten?.plaene) || daten.plaene.length === 0) return keineZeiten() + if (plan !== undefined) { + const gewaehlt = planNachKurz(daten, plan) + if (!gewaehlt) return res.status(404).json({ error: 'Plan nicht gefunden' }) + return res.json({ html: ersatzHtml(daten, gewaehlt) }) + } + const haupt = hauptplan(daten) + if (!haupt) return keineZeiten() + res.json({ html: ersatzHtml(daten, haupt) }) + } catch (err) { + sendError(res, err) + } +}) + // Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404 funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { try { diff --git a/server/services/zeiten/ticketDetails.js b/server/services/zeiten/ticketDetails.js index c5f2cdc..e41c691 100644 --- a/server/services/zeiten/ticketDetails.js +++ b/server/services/zeiten/ticketDetails.js @@ -1,10 +1,72 @@ -import { minimaleTicketDetails } from '../featureRegistry.js' +import { kuerze, minimaleTicketDetails, TICKET_DETAILS_MAX, TICKET_GEKUERZT } from '../featureRegistry.js' +import { ladeZeiten } from './speicher.js' /** - * Details des Einrichtungs-Tickets von Oeffnungszeiten live. Vorlaeufig die Notfall-Details des Registers (Kopfzeilen und - * Verweis auf die Anleitung im WOMS); Task 14 baut daraus die vollstaendige Anleitung. - * ctx = { feature, doc, project, customer }. + * Details des Einbau-Tickets von Oeffnungszeiten live (Spec 4.6, config.features.zeiten.ticket.details). + * Kopfzeilen und Verweis auf das Kundenpanel im WOMS (Notfall-Details des Registers), darunter die Einbau-Zeile mit + * siteKey, das Ergebnis des Neubau-Anstosses (doc.einbau), Beispiele der Platzhalter mit den Kurznamen der Plaene, + * der Hinweis auf das Ersatz-HTML und die Pruefliste. ctx = { feature, doc, project, customer }. + * Wirft nie wegen der gespeicherten Zeiten: ohne Dokument-ID, ohne Zeiten oder bei einem Lesefehler heisst es + * "Plaene folgen". Der Text bleibt hoechstens TICKET_DETAILS_MAX Zeichen lang. */ + +const EMBED_URL = 'https://project.webklar.com/embed/zeiten.js' +const PLATZHALTER_MODI = Object.freeze(['tabelle', 'status', 'badge']) + +const EINBAU_ERGEBNIS = Object.freeze({ + angestossen: 'automatisch eingesetzt (nach dem nächsten Veröffentlichen prüfen)', + manuell: 'bitte von Hand einsetzen', +}) + +const PRUEFLISTE = Object.freeze([ + 'Feste Zeiten im Repo suchen und ersetzen (grep -ril "öffnungszeit" u. Ä.), auch Footer, Kontaktseite und eigene Status-Skripte.', + 'Header: Überlappt die Hinweis-Leiste den Kopf der Seite? Sonst im WOMS den navSelektor setzen.', + 'Hat die Seite schon eine eigene Anruf-Leiste? Dann nicht doppelt.', + 'Vorhandenes JSON-LD (Öffnungszeiten der Seite) prüfen, damit nichts doppelt oder widersprüchlich steht.', + 'Datenschutz-Absatz zu Öffnungszeiten live auf der Website ergänzt?', +]) + +/** Kurznamen der gespeicherten Plaene, Hauptplan zuerst: [{ kurz, haupt }]; leer ohne Zeiten oder bei jedem Fehler. */ +async function planKurznamen(doc) { + if (typeof doc?.$id !== 'string' || doc.$id === '') return [] + try { + const gespeichert = await ladeZeiten(doc.$id) + const plaene = Array.isArray(gespeichert?.daten?.plaene) ? gespeichert.daten.plaene : [] + const liste = plaene + .filter((p) => p && typeof p.kurz === 'string' && p.kurz !== '') + .map((p) => ({ kurz: p.kurz, haupt: p.haupt === true })) + return [...liste.filter((p) => p.haupt), ...liste.filter((p) => !p.haupt)] + } catch (err) { + console.warn(`[zeiten] Pläne für das Ticket von ${doc.$id} nicht lesbar: ${err?.message}`) + return [] + } +} + export async function zeitenTicketDetails(ctx) { - return minimaleTicketDetails(ctx) + const doc = ctx?.doc || {} + const siteKey = typeof doc.siteKey === 'string' ? doc.siteKey : '' + const plaene = await planKurznamen(doc) + const beispielKurz = plaene[0]?.kurz ?? '' + // einbau fehlt oder ist unbekannt: nichts wurde automatisch eingesetzt + const ergebnis = EINBAU_ERGEBNIS[doc.einbau] ?? EINBAU_ERGEBNIS.manuell + + const text = [ + minimaleTicketDetails(ctx), + '', + 'Einbau-Zeile (im jeder HTML-Seite):', + ``, + `Einbau: ${ergebnis}`, + '', + 'Platzhalter (dort, wo die Zeiten stehen sollen; innen bleibt die statische Ersatz-Tabelle):', + ...PLATZHALTER_MODI.map((modus) => `
Ersatz-HTML
`), + plaene.length > 0 + ? `Pläne (Kurznamen für data-plan): ${plaene.map((p) => (p.haupt ? `${p.kurz} (Hauptplan)` : p.kurz)).join(', ')}` + : 'Pläne folgen, sobald der Kunde gespeichert hat', + 'Ersatz-HTML: WOMS → Funktionen → Ersatz-HTML kopieren (erst nach dem ersten Speichern des Kunden)', + '', + 'Prüfliste:', + ...PRUEFLISTE.map((punkt) => `- ${punkt}`), + ].join('\n') + + return kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) } diff --git a/test/zeitenRegister.test.js b/test/zeitenRegister.test.js index dd8a7b1..cf08ee4 100644 --- a/test/zeitenRegister.test.js +++ b/test/zeitenRegister.test.js @@ -145,15 +145,16 @@ test('Mails: Werte aus dem Register, Preis aus zeitenSetup', () => { assert.equal(k15.values.portal_url, `${config.publicBaseUrl}/dashboard.html#zeiten`) }) -test('Ticket: Betreff mit Projektname, Details wie die Notfall-Details', async () => { +test('Ticket: Betreff mit Projektname, Details beginnen mit den Notfall-Details (Einbau-Text: test/zeitenTicket.test.js)', async () => { const f = feature() const project = { projectName: 'Salon Haarlinie', subdomain: 'salon' } assert.equal(f.ticket.topic(project), 'Öffnungszeiten live einbauen – Salon Haarlinie') assert.equal(f.ticket.topic({ subdomain: 'salon' }), 'Öffnungszeiten live einbauen – salon') const ctx = { feature: f, doc: { customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop' }, project, customer: { name: 'Kunde Eins' } } const details = await f.ticket.details(ctx) - assert.equal(details, reg.minimaleTicketDetails(ctx)) + assert.ok(details.startsWith(reg.minimaleTicketDetails(ctx))) assert.match(details, /^Funktion: zeiten$/m) + assert.match(details, /data-wk-zeiten src="https:\/\/project\.webklar\.com\/embed\/zeiten\.js" data-site="abcdefghijklmnop"/) const texte = await reg.ticketTexte(ctx) assert.equal(texte.topic, 'Öffnungszeiten live einbauen – Salon Haarlinie') assert.equal(texte.details, details) diff --git a/test/zeitenTicket.test.js b/test/zeitenTicket.test.js new file mode 100644 index 0000000..6624477 --- /dev/null +++ b/test/zeitenTicket.test.js @@ -0,0 +1,253 @@ +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) +})