Öffnungszeiten: Neubau anstoßen und Feld im Runner-Auftrag
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0042d8dd8f
commit
302b58cf72
5 files changed
+279
-2
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
Reference in new issue
Block a user