Öffnungszeiten: Einbau-Ticket und Ersatz-HTML für das Team

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 01:48:20 +00:00
1 parent 302b58cf72
commit 8d670ca5dc
4 files changed
+355 -7

No files matched your search

+32
View File
@@ -12,6 +12,9 @@ import {
listCustomerFeatureDocs, listCustomerFeatureDocs,
listCustomerFeatures, listCustomerFeatures,
} from '../../services/siteFeatures.js' } 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"). * 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 // Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
try { try {
+67 -5
View File
@@ -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 * Details des Einbau-Tickets von Oeffnungszeiten live (Spec 4.6, config.features.zeiten.ticket.details).
* Verweis auf die Anleitung im WOMS); Task 14 baut daraus die vollstaendige Anleitung. * Kopfzeilen und Verweis auf das Kundenpanel im WOMS (Notfall-Details des Registers), darunter die Einbau-Zeile mit
* ctx = { feature, doc, project, customer }. * 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) { 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 ?? '<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 <head> jeder HTML-Seite):',
`<script data-wk-zeiten src="${EMBED_URL}" data-site="${siteKey}" defer></script>`,
`Einbau: ${ergebnis}`,
'',
'Platzhalter (dort, wo die Zeiten stehen sollen; innen bleibt die statische Ersatz-Tabelle):',
...PLATZHALTER_MODI.map((modus) => `<div data-webklar-zeiten data-modus="${modus}" data-plan="${beispielKurz}">Ersatz-HTML</div>`),
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 })
} }
+3 -2
View File
@@ -145,15 +145,16 @@ test('Mails: Werte aus dem Register, Preis aus zeitenSetup', () => {
assert.equal(k15.values.portal_url, `${config.publicBaseUrl}/dashboard.html#zeiten`) 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 f = feature()
const project = { projectName: 'Salon Haarlinie', subdomain: 'salon' } const project = { projectName: 'Salon Haarlinie', subdomain: 'salon' }
assert.equal(f.ticket.topic(project), 'Öffnungszeiten live einbauen – Salon Haarlinie') assert.equal(f.ticket.topic(project), 'Öffnungszeiten live einbauen – Salon Haarlinie')
assert.equal(f.ticket.topic({ subdomain: 'salon' }), 'Öffnungszeiten live einbauen – salon') 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 ctx = { feature: f, doc: { customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop' }, project, customer: { name: 'Kunde Eins' } }
const details = await f.ticket.details(ctx) 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, /^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) const texte = await reg.ticketTexte(ctx)
assert.equal(texte.topic, 'Öffnungszeiten live einbauen – Salon Haarlinie') assert.equal(texte.topic, 'Öffnungszeiten live einbauen – Salon Haarlinie')
assert.equal(texte.details, details) assert.equal(texte.details, details)
+253
View File
@@ -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(`<script data-wk-zeiten src="https://project.webklar.com/embed/zeiten.js" data-site="${SITE_KEY}" defer></script>`),
'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, /<div data-webklar-zeiten data-modus="tabelle" data-plan="standard">Ersatz-HTML<\/div>/)
assert.match(text, /<div data-webklar-zeiten data-modus="status" data-plan="standard">Ersatz-HTML<\/div>/)
assert.match(text, /<div data-webklar-zeiten data-modus="badge" data-plan="standard">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, /<div data-webklar-zeiten data-modus="tabelle" data-plan="<kurz>">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, /^<table class="wkz-ersatz">/)
assert.match(res.body.html, /<caption>Standard<\/caption>/)
assert.match(res.body.html, /<th scope="row">Montag<\/th><td>09:00&#8211;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, /<caption>Weihnachten<\/caption>/)
assert.match(zweiter.body.html, /<th scope="row">Dienstag<\/th><td>10:00&#8211;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, /<caption>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)
})