From 302b58cf722a1e7e052fcfe65a923f8e67fa2540 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:41:10 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Neubau=20ansto=C3=9Fen?= =?UTF-8?q?=20und=20Feld=20im=20Runner-Auftrag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildZeiten (einbau.js) liefert dem Runner-Auftrag { siteKey } bei Funktion in setup/active, null bei Sperrliste und Fehlern. stosseNeubauAn (neubau.js) stößt den Neubau unter der Sperre zeiten-neubau an und meldet angestossen oder manuell. Co-Authored-By: Claude Opus 5.5 --- server/services/previewDeploy.js | 4 +- server/services/zeiten/einbau.js | 33 ++++++ server/services/zeiten/neubau.js | 27 +++++ test/previewRunner.test.js | 51 +++++++++- test/zeitenNeubau.test.js | 166 +++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 server/services/zeiten/einbau.js create mode 100644 server/services/zeiten/neubau.js create mode 100644 test/zeitenNeubau.test.js diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 2d83d5f..2011828 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -11,6 +11,7 @@ import { envKey, decryptEnv, maskLog } from './previewEnv.js' import { saveRunLog } from './previewLogs.js' import * as runnerClient from './previewRunnerClient.js' import { analysePrivacyData } from './analysePrivacy.js' +import { buildZeiten } from './zeiten/einbau.js' const execFileAsync = promisify(execFile) @@ -688,7 +689,7 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job, } if (run.result === 'dev') { await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' }) - try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined, analytics: undefined }) } catch (err) { + try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined, analytics: undefined, zeiten: undefined }) } catch (err) { console.error('[previewDeploy] Dev-Registrierung fehlgeschlagen:', err.message) } } else { @@ -786,6 +787,7 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project ...(displayName ? { displayName } : {}), env, analytics: await buildAnalytics(doc), + zeiten: await buildZeiten(doc), } const { runId } = await runnerClient.submitBuild(job) if (current()) await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning }) diff --git a/server/services/zeiten/einbau.js b/server/services/zeiten/einbau.js new file mode 100644 index 0000000..2fca8a0 --- /dev/null +++ b/server/services/zeiten/einbau.js @@ -0,0 +1,33 @@ +import { config } from '../../config.js' +import { getFeatureDoc } from '../siteFeatures.js' + +/** + * Einbau des Zeiten-Skripts beim Deploy (Spec 4.6, Runner-Feld `zeiten`). + * Darf weder neubau.js noch previewRebuild.js importieren: previewDeploy laedt diese Datei, previewRebuild laedt + * previewDeploy (Zyklus). + */ + +/** Zustaende, in denen die Leisten ausgespielt werden (wie teilweiseLive im Register). */ +const AUSGEBEND = ['setup', 'active'] + +/** Subdomain auf der Sperrliste ZEITEN_KEIN_NEUBAU (Einbau von Hand, weder Neubau noch Einsetzen)? */ +export function aufSperrliste(project) { + const subdomain = String(project?.subdomain || '').trim().toLowerCase() + return subdomain !== '' && config.zeiten.keinNeubau.includes(subdomain) +} + +/** + * Zeiten-Feld fuer den Build-Auftrag: { siteKey } nur bei Funktion in setup/active und Projekt nicht auf der + * Sperrliste, sonst null. isPublic ist nicht noetig. Wirft nie: der Build darf an den Zeiten nicht scheitern. + */ +export async function buildZeiten(project) { + try { + if (!project?.$id || !project.customerId || aufSperrliste(project)) return null + const feature = await getFeatureDoc(project.customerId, project.$id, 'zeiten') + if (!feature || !AUSGEBEND.includes(feature.state) || !feature.siteKey) return null + return { siteKey: feature.siteKey } + } catch (err) { + console.warn(`[zeiten] Einbau-Abfrage für Projekt ${project?.$id} fehlgeschlagen: ${err?.message}`) + return null + } +} diff --git a/server/services/zeiten/neubau.js b/server/services/zeiten/neubau.js new file mode 100644 index 0000000..dbcf5a8 --- /dev/null +++ b/server/services/zeiten/neubau.js @@ -0,0 +1,27 @@ +import { withLocks } from '../keyedLock.js' +import { triggerRebuild } from '../previewRebuild.js' +import { isDevPreview } from '../analyse.js' +import { aufSperrliste } from './einbau.js' + +/** Austauschbar fuer Tests: Neubau (Tests ersetzen das Feld, nie destrukturieren). */ +export const zeitenDeps = { rebuild: triggerRebuild } + +/** + * Neubau fuer den Einbau des Zeiten-Skripts anstossen (Spec 4.6), wenn die Funktion in einen ausgebenden Zustand + * (setup/active) wechselt. Hoechstens ein angestossener Neubau gleichzeitig (Portal-weite Sperre 'zeiten-neubau'; + * nicht wiederbetretbar, nie innerhalb dieser Sperre aufrufen). + * 'angestossen': der Runner hat den Auftrag angenommen (result.queued === true). + * 'manuell': kein Repo, Preview aus, Dev-Modus, Sperrliste, Runner ausgelastet (503) oder Fehler; das Team baut ein. + * Wirft nie. + */ +export async function stosseNeubauAn(project) { + try { + if (!project?.repoFullName || project.previewMode === 'off' || isDevPreview(project) || aufSperrliste(project)) return 'manuell' + const result = await withLocks(['zeiten-neubau'], () => zeitenDeps.rebuild(project)) + return result?.queued === true ? 'angestossen' : 'manuell' + } catch (err) { + // Zustand bleibt gespeichert, das Team setzt die Zeile von Hand ein + console.warn(`[zeiten] Neubau für Projekt ${project?.$id} nicht angestoßen: ${err?.message}`) + return 'manuell' + } +} diff --git a/test/previewRunner.test.js b/test/previewRunner.test.js index 29db6bd..d3c2d06 100644 --- a/test/previewRunner.test.js +++ b/test/previewRunner.test.js @@ -112,7 +112,7 @@ test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis assert.equal(aw.get('websiteProjects', id).previewState, 'building') const [sub] = submits() assert.equal(sub.auth, 'Bearer sekret') - assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', displayName: 'Mein Projekt', env: { API_URL: 'https://x.test' }, analytics: null }) + assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', displayName: 'Mein Projekt', env: { API_URL: 'https://x.test' }, analytics: null, zeiten: null }) await pd.waitForRunFollowers() const d = aw.get('websiteProjects', id) assert.equal(d.previewState, 'online') @@ -449,6 +449,55 @@ test('Analyse: Dev-PUT nach erfolgreichem Build (Modus dev) enthält kein analyt for (const c of puts) assert.equal('analytics' in c.body, false) }) +// ---- Zeiten-Feld im Build-Auftrag (Oeffnungszeiten live, Spec 4.6) ---- +async function zeitenProject({ state = 'active', project: pextra = {}, noFeature = false } = {}) { + const { createDocument } = await import('../server/services/appwriteAdmin.js') + const cid = `cust-ze-${n + 1}` + const p = await project({ customerId: cid, subdomain: `ze-${n + 1}`, ...pextra }) + if (!noFeature) { + await createDocument('portalFeatures', { customerId: cid, projectId: p.id, featureKey: 'zeiten', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop' }, `f_${p.id}_zeiten`) + } + return p +} + +test('Zeiten: setup und active ⇒ zeiten mit siteKey, auch ohne isPublic', async () => { + for (const state of ['setup', 'active']) { + assert.deepEqual((await submitFor(await zeitenProject({ state }))).zeiten, { siteKey: 'abcdefghijklmnop' }, state) + } +}) + +test('Zeiten: off, ordered, disabled und ohne Funktion ⇒ null', async () => { + for (const state of ['off', 'ordered', 'disabled']) { + assert.equal((await submitFor(await zeitenProject({ state }))).zeiten, null, state) + } + assert.equal((await submitFor(await zeitenProject({ noFeature: true }))).zeiten, null) +}) + +test('Zeiten: Sperrliste ⇒ null, Analyse bleibt unberührt', async () => { + const body = await submitFor(await zeitenProject({ project: { subdomain: 'emmas-schatzkiste' } })) + assert.equal(body.zeiten, null) + assert.equal(body.analytics, null) +}) + +test('Zeiten: Fehler bei der Funktions-Abfrage ⇒ null, Auftrag wird trotzdem gesendet', async (t) => { + t.mock.method(console, 'warn', () => {}) + const p = await zeitenProject() + aw.failNext({ method: 'GET', pathIncludes: 'portalFeatures', status: 500 }) + const before = submits().length + const body = await submitFor(p) + assert.equal(submits().length, before + 1) + assert.equal(body.zeiten, null) +}) + +test('Zeiten: Dev-PUT nach erfolgreichem Build (Modus dev) enthält kein zeiten', async () => { + rs.script = done({ result: 'dev' }) + const body = await submitFor(await zeitenProject({ project: { previewMode: 'dev' } })) + assert.deepEqual(body.zeiten, { siteKey: 'abcdefghijklmnop' }) + const puts = rs.calls.filter((c) => c.method === 'PUT' && c.url.startsWith('/dev/')) + assert.ok(puts.length >= 1) + for (const c of puts) assert.equal('zeiten' in c.body, false) +}) + test('Analyse: überlange Angaben, Steuerzeichen, schlechte Farbe ⇒ Grenzen eingehalten, Auftrag geht raus', async (t) => { t.mock.method(console, 'warn', () => {}) const p = await analyseProject({ diff --git a/test/zeitenNeubau.test.js b/test/zeitenNeubau.test.js new file mode 100644 index 0000000..3e8c70d --- /dev/null +++ b/test/zeitenNeubau.test.js @@ -0,0 +1,166 @@ +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' + +// Einbau des Zeiten-Skripts (Spec 4.6): buildZeiten entscheidet fuers Runner-Feld `zeiten`, stosseNeubauAn loest beim +// Uebergang in einen ausgebenden Zustand genau einen Neubau aus (Portal-weit nacheinander) und meldet angestossen/manuell. +const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') }, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { buildZeiten } = await import('../server/services/zeiten/einbau.js') +const { stosseNeubauAn, zeitenDeps } = await import('../server/services/zeiten/neubau.js') + +const KEY = 'abcdefghijklmnop' +const feature = (state, projectId = 'p1', extra = {}) => + aw.seed('portalFeatures', { $id: `f_${projectId}_zeiten`, customerId: 'c1', projectId, featureKey: 'zeiten', state, enabled: state === 'active', siteKey: KEY, ...extra }) +const project = (extra = {}) => ({ $id: 'p1', customerId: 'c1', subdomain: 'bar-und-grill', repoFullName: 'JUSN/bar-und-grill', previewMode: 'auto', previewDetected: 'static', ...extra }) + +const original = zeitenDeps.rebuild +const warnings = [] +const warn = console.warn +beforeEach(() => { + zeitenDeps.rebuild = original + warnings.length = 0 + console.warn = (...args) => warnings.push(args.join(' ')) +}) +after(() => { + zeitenDeps.rebuild = original + console.warn = warn +}) + +test('buildZeiten: setup und active liefern siteKey, off/ordered/disabled null', async () => { + for (const state of ['setup', 'active']) { + feature(state, `p_${state}`) + assert.deepEqual(await buildZeiten(project({ $id: `p_${state}` })), { siteKey: KEY }, state) + } + for (const state of ['off', 'ordered', 'disabled']) { + feature(state, `p_${state}`) + assert.equal(await buildZeiten(project({ $id: `p_${state}` })), null, state) + } +}) + +test('buildZeiten: ohne Feature-Dokument, ohne siteKey, fremder Kunde oder ohne Kundenbezug null', async () => { + assert.equal(await buildZeiten(project({ $id: 'p_ohne_doc' })), null) + feature('active', 'p_ohne_key', { siteKey: '' }) + assert.equal(await buildZeiten(project({ $id: 'p_ohne_key' })), null) + feature('active', 'p_fremd') + assert.equal(await buildZeiten(project({ $id: 'p_fremd', customerId: 'c_andere' })), null) + assert.equal(await buildZeiten(project({ customerId: '' })), null) + assert.equal(await buildZeiten(null), null) + assert.equal(await buildZeiten(undefined), null) +}) + +test('buildZeiten: isPublic ist nicht noetig (anders als bei der Analyse)', async () => { + feature('active', 'p_privat') + assert.deepEqual(await buildZeiten(project({ $id: 'p_privat', isPublic: false })), { siteKey: KEY }) +}) + +test('buildZeiten: Sperrliste ⇒ null (auch bei anderer Gross-/Kleinschreibung), andere Subdomains nicht', async () => { + assert.ok(config.zeiten.keinNeubau.includes('emmas-schatzkiste'), 'Vorgabe der Sperrliste') + feature('active', 'p_emma') + assert.equal(await buildZeiten(project({ $id: 'p_emma', subdomain: 'emmas-schatzkiste' })), null) + assert.equal(await buildZeiten(project({ $id: 'p_emma', subdomain: 'Emmas-Schatzkiste' })), null) + assert.deepEqual(await buildZeiten(project({ $id: 'p_emma', subdomain: 'emmas-schatzkiste-2' })), { siteKey: KEY }) +}) + +test('buildZeiten wirft nie (Appwrite-Fehler ⇒ null, Warnung ohne Kundendaten)', async () => { + feature('active', 'p_fehler') + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) + assert.equal(await buildZeiten(project({ $id: 'p_fehler' })), null) + assert.equal(warnings.length, 1) + assert.match(warnings[0], /p_fehler/) + assert.doesNotMatch(warnings[0], new RegExp(`${KEY}|c1|bar-und-grill`)) + // der naechste Aufruf klappt wieder + assert.deepEqual(await buildZeiten(project({ $id: 'p_fehler' })), { siteKey: KEY }) +}) + +test('Neubau: queued ⇒ angestossen, das Projekt geht an den Neubau', async () => { + const gesehen = [] + zeitenDeps.rebuild = async (p) => { + gesehen.push(p) + return { queued: true, runId: 'run-1', subdomain: p.subdomain } + } + const p = project() + assert.equal(await stosseNeubauAn(p), 'angestossen') + assert.deepEqual(gesehen, [p]) +}) + +test('Neubau: nur queued === true zaehlt, alles andere ⇒ manuell', async () => { + for (const result of [{ skipped: true, reason: 'Preview ist ausgeschaltet' }, { error: 'Runner kaputt', subdomain: 'x' }, { deployed: true }, { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' }, { queued: 'ja' }, { queued: 1 }, {}, null, undefined]) { + zeitenDeps.rebuild = async () => result + assert.equal(await stosseNeubauAn(project()), 'manuell', JSON.stringify(result)) + } +}) + +test('Neubau: kein Repo, Preview aus, Dev-Modus und Sperrliste ⇒ manuell, ohne Neubau', async () => { + let aufrufe = 0 + zeitenDeps.rebuild = async () => { + aufrufe += 1 + return { queued: true } + } + assert.equal(await stosseNeubauAn(project({ repoFullName: '' })), 'manuell', 'kein Repo') + assert.equal(await stosseNeubauAn(project({ repoFullName: undefined })), 'manuell', 'Repo fehlt') + assert.equal(await stosseNeubauAn(project({ previewMode: 'off' })), 'manuell', 'Preview aus') + assert.equal(await stosseNeubauAn(project({ previewDetected: 'dev' })), 'manuell', 'Dev-Modus') + assert.equal(await stosseNeubauAn(project({ previewDetected: 'dev (vite)' })), 'manuell', 'Dev-Modus mit Zusatz') + assert.equal(await stosseNeubauAn(project({ subdomain: 'emmas-schatzkiste' })), 'manuell', 'Sperrliste') + assert.equal(await stosseNeubauAn(project({ subdomain: 'EMMAS-Schatzkiste' })), 'manuell', 'Sperrliste, Schreibweise') + assert.equal(await stosseNeubauAn(null), 'manuell', 'kein Projekt') + assert.equal(aufrufe, 0) + // Gegenprobe: ein normales Projekt (auch Vorgabemodus ohne previewMode) wird gebaut + assert.equal(await stosseNeubauAn(project({ previewMode: undefined, previewDetected: undefined })), 'angestossen') + assert.equal(aufrufe, 1) +}) + +test('Neubau: 503 (Runner ausgelastet) und andere Fehler ⇒ manuell, wirft nie, Warnung ohne Kundendaten', async () => { + zeitenDeps.rebuild = async () => { + const err = new Error('Server ausgelastet, versuche es gleich noch mal') + err.status = 503 + err.expected = true + throw err + } + assert.equal(await stosseNeubauAn(project()), 'manuell') + zeitenDeps.rebuild = async () => { + throw new Error('Netzwerk weg') + } + assert.equal(await stosseNeubauAn(project()), 'manuell') + assert.equal(warnings.length, 2) + for (const w of warnings) { + assert.match(w, /p1/) + assert.doesNotMatch(w, /bar-und-grill|c1\b/) + } +}) + +test('Neubauten laufen nacheinander (Sperre zeiten-neubau)', async () => { + let laufend = 0 + let maximal = 0 + const reihenfolge = [] + zeitenDeps.rebuild = async (p) => { + laufend += 1 + maximal = Math.max(maximal, laufend) + reihenfolge.push(`start ${p.$id}`) + await new Promise((resolve) => setTimeout(resolve, 15)) + reihenfolge.push(`ende ${p.$id}`) + laufend -= 1 + return { queued: true } + } + const ergebnisse = await Promise.all(['a', 'b', 'c'].map((id) => stosseNeubauAn(project({ $id: id })))) + assert.deepEqual(ergebnisse, ['angestossen', 'angestossen', 'angestossen']) + assert.equal(maximal, 1, 'nie zwei Neubauten gleichzeitig') + assert.deepEqual(reihenfolge, ['start a', 'ende a', 'start b', 'ende b', 'start c', 'ende c']) +}) + +test('Neubau: ein gescheiterter Neubau haelt die Sperre nicht fest', async () => { + let n = 0 + zeitenDeps.rebuild = async () => { + n += 1 + if (n === 1) throw new Error('kaputt') + return { queued: true } + } + const [a, b] = await Promise.all([stosseNeubauAn(project({ $id: 'a' })), stosseNeubauAn(project({ $id: 'b' }))]) + assert.deepEqual([a, b], ['manuell', 'angestossen']) +})