Files

427 lines
22 KiB
JavaScript

// UI-Testserver des Terminplaners (gemeinsam fuer Playwright-Skripte der Aufgaben 19 bis 21): die echten Express-Router
// (/api/public/termine/v1, /api/termine, /api/funktionen) und public/ als statische Dateien, dahinter der Nachbau von Appwrite
// und Stripe aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur 127.0.0.1).
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startTermineUiServer } from './termineTestServer.mjs'
// const srv = await startTermineUiServer()
// ... srv.url, srv.pageOrigin, srv.SITE_KEY, srv.sink.sent, srv.buchungen(), srv.speichere(...) ...
// await srv.stop() // erst den Browser schliessen, dann den Server
//
// Uhr: Die Server-Uhr beginnt bei jetzt (Vorgabe Montag 2026-10-05 10:00 Berliner Zeit) und laeuft dann in Echtzeit weiter,
// damit Tage und Uhrzeiten in Screenshots gleich bleiben und Zwischenspeicher, Haltezeiten und Formular-Token trotzdem
// ablaufen. srv.schiebeUhr(ms) verschiebt sie (z. B. +3 h: Formular-Token abgelaufen), srv.setzeUhr(date) setzt sie neu.
//
// Cookies fuer das Portal (Aufgaben 21a/21b): sessionCookie/customerCookie aus test/support/testApp.js und fixtures.js erst NACH dem
// Start dieses Servers dynamisch importieren (testApp.js prueft beim Laden, dass die Umgebung auf die Nachbauten zeigt):
// const srv = await startTermineUiServer()
// const { sessionCookie } = await import('../support/testApp.js')
//
// Portal (Aufgaben 21a/21b): srv.portalCookie() liefert { name, value, url } fuer addCookies; srv.zuruecksetzen({ ohneEinstellung: true })
// startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen
// Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route.
//
// Entwurf und Bestellung (TP2, Fragebogen bis Bestellklick): srv.zuruecksetzen({ entwurf: true }) startet wie ein Kunde vor der ersten Branchenwahl:
// kein Funktionsdokument, keine Einstellung, aber ein laufendes Wartungs-Abo (wartungDoc) fuer c1 und ein Konto mit Firma und Anschrift
// (Vorschlag fuer die Betriebsangaben). srv.abo(zustand) tauscht das Abo ('wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'),
// srv.entwurf({ eingabe, fragebogen }) legt Funktionsdokument (off), Einstellung und Fragebogen an (Abkuerzung statt aller Klicks),
// srv.bestellt({ mitZustimmung }) macht daraus eine Bestellung im Zustand ordered mit offener Stripe-Sitzung (und dem Nachweis dieser Sitzung),
// srv.kundenansichtCookie() ist die Sitzung eines Mitarbeiters in der Kundenansicht (der Waechter haengt hier nicht: geprueft wird die
// Absicherung der Router selbst). srv.fragebogen() / srv.version() lesen den gespeicherten Stand.
//
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { startAppwriteMock } from '../support/appwriteMock.js'
import { startFakeStripe } from '../support/fakeStripe.js'
import { mailSink } from '../support/mailSink.js'
import { schemaKeys } from '../support/schema.js'
import { useTestEnv } from '../support/testEnv.js'
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
export const SITE_KEY = 'abcdefghijklmnop'
export const KUNDE = 'c1'
export const PROJEKT = 'p1'
export const FEATURE = 'f_p1_termine'
export const JETZT = '2026-10-05T08:00:00.000Z' // Montag, 10:00 Berlin (MESZ)
// Feste IDs (aus den Mustern r_ + 8 und s_ + 8), damit Skripte sie in Seiten schreiben koennen (data-leistung)
export const R_ANNA = 'r_anna0001'
export const R_BEN = 'r_ben00002'
export const S_HERREN = 's_herren01'
export const S_DAMEN = 's_damen001'
export const S_KINDER = 's_kinder01'
export const S_FAERBEN = 's_faerben1'
export const S_KENNEN = 's_kennen01' // ohne Preis, sofort: Knopf "Termin buchen"
export const S_ANFRAGE = 's_anfrage1' // ohne Preis, Anfrage: Knopf "Termin anfragen"
/**
* Eingabe fuer normalisiereEinstellung: Salon (Leistungen der Vorlage "salon", zwei Personen), Mo bis Fr 09:00 bis 12:30 und
* 13:30 bis 18:00, Sa 09:00 bis 13:00, So zu; keine Feiertage, kein Vorlauf, 10 offene Termine je E-Mail erlaubt.
*/
export function salonEingabe(ueberschreibe = {}) {
const woche = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] }
for (const tag of [2, 3, 4, 5]) woche[tag] = woche[1]
const ressourcen = [R_ANNA, R_BEN]
const leistung = (id, name, dauer, pufferNach, preisArt, preisCent, buchungsart, beschreibung = '') => ({
id, name, beschreibung, dauer, pufferNach, preisArt, preisCent, buchungsart, ressourcen,
})
return {
vorlage: 'salon',
betrieb: {
name: 'Salon Müller',
email: 'info@salon-mueller.example',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
kleinunternehmer: false,
websiteUrl: 'https://salon-mueller.example',
impressumUrl: 'https://salon-mueller.example/impressum',
datenschutzUrl: 'https://salon-mueller.example/datenschutz',
agbUrl: 'https://salon-mueller.example/agb',
},
anrede: 'sie',
farbe: '#0f766e',
bundesland: 'RP',
feiertageGeschlossen: false,
ressourcen: [
{ id: R_ANNA, name: 'Anna', typ: 'person', plan: woche },
{ id: R_BEN, name: 'Ben', typ: 'person', plan: woche },
],
leistungen: [
leistung(S_HERREN, 'Herrenschnitt', 30, 5, 'fest', 2800, 'sofort', 'Waschen, Schneiden und Föhnen.'),
leistung(S_DAMEN, 'Damenschnitt', 45, 5, 'ab', 4500, 'sofort', 'Mit Beratung zur Frisur.'),
leistung(S_KINDER, 'Kinderschnitt', 30, 5, 'fest', 1800, 'sofort'),
leistung(S_FAERBEN, 'Färben', 120, 10, 'ab', 7500, 'sofort'),
leistung(S_KENNEN, 'Kennenlern-Beratung', 15, 0, 'keiner', 0, 'sofort', 'Kostenlos und unverbindlich.'),
leistung(S_ANFRAGE, 'Brautfrisur', 90, 15, 'keiner', 0, 'anfrage', 'Wir melden uns und bestätigen den Termin.'),
],
regeln: { raster: 15, vorlaufMin: 0, horizontTage: 60, absageBisStd: 24, maxOffeneJeEmail: 10, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: 'Sie können Ihren Termin bis 24 Stunden vorher kostenlos absagen oder verschieben. Danach berechnen wir 50 % des Preises.' },
recht: { gaesteSind: 'verbraucher', freizeit: false },
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] },
...ueberschreibe,
}
}
/**
* Startet Nachbauten, Router und Seiten.
* @param {object} [opt]
* @param {string|Date} [opt.jetzt] Startzeit der Server-Uhr
* @param {boolean} [opt.crossOrigin] pageOrigin (localhost) gilt als Herkunft des Portals
* @param {(eingabe: object) => object} [opt.anpassen] aendert die Eingabe der Einstellung vor dem Speichern
*/
export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, anpassen = (e) => e } = {}) {
const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js')
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
customerViewLog: schemaKeys('customerViewLog'),
subscriptions: schemaKeys('subscriptions'),
featureConsents: schemaKeys('featureConsents'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../../server/config.js')
const { startTestApp } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js')
const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js')
const { speichereEinstellung, speichereFragebogen, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
const { SCHRITTE, leerFragebogen } = await import('../../server/services/termine/fragebogen.js')
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
const { hakenTexte, markOrdered } = await import('../../server/services/siteFeatures.js')
const { nachweisTexte, speichereZustimmung, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
const { hostingDoc, wartungDoc } = await import('../support/fixtures.js')
// Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per
// route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen.
const seiten = new Map()
const seitenRouter = express.Router()
seitenRouter.get('/:name', (req, res) => {
const html = seiten.get(req.params.name)
if (html === undefined) return res.status(404).type('text').send('nicht gefunden')
return res.set('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(html)
})
const app = await startTestApp({
'/api/public/termine/v1': publicTermineRoutes,
'/api/termine': termineRoutes,
'/api/funktionen': funktionenRoutes,
'/ui-seiten': seitenRouter,
'/': express.static(PUBLIC_DIR),
})
const pageOrigin = app.url.replace('127.0.0.1', 'localhost')
const startMs = new Date(jetzt).getTime()
let versatz = startMs - Date.now()
const uhr = () => new Date(Date.now() + versatz)
const vorher = {
mode: config.termine.mode,
pilotEmails: config.termine.pilotEmails,
formMinSekunden: config.termine.formMinSekunden,
minAntwortMs: config.termine.minAntwortMs,
publicBaseUrl: config.publicBaseUrl,
smtpFrom: config.smtp.from,
}
const srv = {
url: app.url,
pageOrigin,
SITE_KEY, KUNDE, PROJEKT, FEATURE,
aw, st, config,
sink: null,
uhr,
/** Testseite unter pageOrigin/ui-seiten/<name> bereitstellen; liefert die Adresse. */
seite(name, html) {
seiten.set(name, html)
return `${pageOrigin}/ui-seiten/${name}`
},
/** Zeitpunkt der Server-Uhr verschieben (ms) oder neu setzen. */
schiebeUhr(ms) { versatz += ms },
setzeUhr(datum) { versatz = new Date(datum).getTime() - Date.now() },
/** Portal-Funktion des Termin-Features (portalFeatures-Dokument). */
featureDoc(state = 'active', extra = {}) {
return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z', ...extra }
},
/** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); extra: weitere Felder des Dokuments (z. B. disabledReason, disabledAt). Der Kontext-Speicher des Servers wird geleert. */
async setzeFunktion(state, extra = {}) {
aw.seed('portalFeatures', srv.featureDoc(state, extra))
resetKontextForTests()
},
/** Einstellung speichern: Eingabe fuer normalisiereEinstellung (z. B. salonEingabe({ anrede: 'du' })). Liefert die normalisierte Einstellung. */
async speichere(eingabe = salonEingabe()) {
const erg = normalisiereEinstellung(eingabe, { now: uhr() })
if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`)
resetKontextForTests()
const doc = aw.docs('termineConfig').get(FEATURE)
await speichereEinstellung(srv.featureDoc(), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() })
return erg.einstellung
},
/** Gespeicherte Einstellung (Objekt) oder null. */
einstellung() {
const doc = aw.docs('termineConfig').get(FEATURE)
return doc ? JSON.parse(doc.einstellung) : null
},
/** Alle Buchungen (Appwrite-Dokumente), alle Mails der Senke (nach warteAufVersand(): die Warteschlange sendet im Hintergrund). */
buchungen() { return [...aw.docs('termineBookings').values()] },
mails() { return srv.sink.sent },
warteAufVersand,
/** Nur Grenzen und Zwischenspeicher leeren (zwischen Pruefungen, damit die Grenzen je IP nicht greifen). */
limitsZuruecksetzen() {
resetTermineLimitsForTests()
resetKontextForTests()
},
/** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. Das Portal (dashboard.html) liegt unter srv.url. */
async portalCookie({ email = 'silke@salon-mueller.example', name = 'Silke Müller' } = {}) {
const { sessionCookie } = await import('../support/testApp.js')
const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name, email })
const i = roh.indexOf('=')
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
},
/**
* Wartungs-Abo des Kunden c1 tauschen: 'wartung' (laeuft), 'hosting' (nur Hosting-Abo, Grund kein_abo), 'keins' (gar kein Abo, kein_abo),
* 'gekuendigt' (Kuendigung vorgemerkt, Grund gekuendigt) oder 'nicht_aktiv' (Zahlung offen, Grund nicht_aktiv).
*/
async abo(zustand = 'wartung') {
for (const id of [...aw.docs('subscriptions').keys()]) await deleteDocument('subscriptions', id)
const docs = {
wartung: [wartungDoc],
hosting: [hostingDoc],
keins: [],
gekuendigt: [{ ...wartungDoc, cancelAtPeriodEnd: true }],
nicht_aktiv: [{ ...wartungDoc, status: 'past_due' }],
}[zustand]
if (!docs) throw new Error(`Unbekanntes Abo: ${zustand}`)
for (const doc of docs) aw.seed('subscriptions', doc)
},
/** Cookie einer Kundenansicht (Mitarbeiter Andrej sieht das Portal von c1) fuer page.context().addCookies([...]): { name, value, url }. */
async kundenansichtCookie({ customerId = KUNDE } = {}) {
const { kundenansichtCookie } = await import('../support/fixtures.js')
const roh = kundenansichtCookie({ customerId })
const i = roh.indexOf('=')
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
},
/**
* Entwurf wie nach der Branchenwahl, ohne alle Klicks: Funktionsdokument im Zustand off (mit siteKey), Einstellung (Vorgabe: salonEingabe,
* gespeichert als Entwurf, also auch ohne Pflichtangaben fuer Gaeste) und Fragebogen (Vorgabe: Branche salon, nichts bestaetigt; fragebogen
* ueberschreibt einzelne Felder, z. B. { bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true }).
*/
async entwurf({ eingabe = salonEingabe(), fragebogen = {} } = {}) {
const erg = normalisiereEinstellung(eingabe, { now: uhr(), entwurf: true })
if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`)
resetKontextForTests()
aw.seed('portalFeatures', srv.featureDoc('off'))
const doc = aw.docs('termineConfig').get(FEATURE)
await speichereEinstellung(srv.featureDoc('off'), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() })
await speichereFragebogen(srv.featureDoc('off'), () => ({ ...leerFragebogen(), branche: 'salon', ...fragebogen }), { by: `kunde:${KUNDE}`, now: uhr() })
},
/** Gespeicherter Fragebogen (Objekt) oder null; Version der gespeicherten Einstellung (0 ohne Dokument). */
fragebogen() {
const doc = aw.docs('termineConfig').get(FEATURE)
return doc?.fragebogen ? JSON.parse(doc.fragebogen) : null
},
version() { return aw.docs('termineConfig').get(FEATURE)?.version ?? 0 },
SCHRITTE,
/**
* Bestellung im Zustand ordered, wie sie der Checkout hinterlaesst: eine offene Stripe-Sitzung im Nachbau, das Funktionsdokument ordered
* (markOrdered) und, mit mitZustimmung, der Zustimmungsnachweis dieser Sitzung (Texte des Registers samt Belehrung, Fassungen, Zeit).
* Setzt einen Entwurf voraus (srv.entwurf). Liefert die ID der Sitzung.
*/
async bestellt({ sitzungId = 'cs_test_ui_bestellt', mitZustimmung = true } = {}) {
const register = featureConfig('termine')
st.checkoutSessions.set(sitzungId, {
id: sitzungId,
object: 'checkout.session',
url: `https://checkout.stripe.test/c/pay/${sitzungId}`,
mode: 'payment',
customer: null,
client_reference_id: KUNDE,
subscription: null,
metadata: { customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine' },
status: 'open',
payment_status: 'unpaid',
invoice: null,
})
const klickAt = uhr().toISOString()
await markOrdered({ customerId: KUNDE, projectId: PROJEKT, key: 'termine', sessionId: sitzungId, consent: { text: hakenTexte(register), at: klickAt, ip: '127.0.0.1', userAgent: 'ui-test' } })
if (mitZustimmung) {
await speichereZustimmung({
customerId: KUNDE,
projectId: PROJEKT,
featureKey: 'termine',
stripeSessionId: sitzungId,
fassungen: zustimmungsfassungen(register),
texte: nachweisTexte(register, { belehrung: (await featureHaken('termine').belehrung?.()) ?? null }),
ip: '127.0.0.1',
userAgent: 'ui-test',
konto: 'Silke Müller <silke@salon-mueller.example>',
at: klickAt,
})
}
resetKontextForTests()
return sitzungId
},
/**
* Bestellung bezahlt, Einrichtungs-Ticket angelegt: das Funktionsdokument im Zustand setup mit Ticketnummer (Ticket im Nachbau, noch nicht
* geschlossen, sonst schaltete GET /api/funktionen frei) und Rechnungslink. Setzt ein Funktionsdokument voraus (srv.entwurf, srv.bestellt).
*/
async einrichtung({ woid = 100342, invoiceUrl = 'https://invoice.stripe.test/i/in_ui1' } = {}) {
aw.seed('workorders', { $id: 't_ui1', woid, status: 'In Progress' })
await updateDocument('portalFeatures', FEATURE, { state: 'setup', enabled: false, purchaseStatus: 'paid', ticketId: 't_ui1', invoiceUrl })
resetKontextForTests()
},
/** Zustimmungsnachweise loeschen (ein ordered ohne Nachweis: der Zustand nach einer abgelaufenen Sitzung oder einem Fehler). */
async loescheZustimmungen() {
for (const id of [...aw.docs('featureConsents').keys()]) await deleteDocument('featureConsents', id)
},
/**
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen.
* ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen).
* entwurf: ein Kunde vor der ersten Branchenwahl (TP2): kein Funktionsdokument, keine Einstellung, ein laufendes Wartungs-Abo fuer c1 und ein
* Konto mit Firma und Anschrift; die Stripe-Sitzungen und Aufrufe des Nachbaus sind leer.
*/
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false, entwurf = false } = {}) {
await warteAufVersand()
const sammlungen = ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects', 'subscriptions', 'featureConsents', 'workorders']
for (const collection of sammlungen) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
}
st.checkoutSessions.clear()
st.calls.length = 0
resetKontextForTests()
resetOutboxForTests()
resetTermineLimitsForTests()
resetPublicTermineForTests()
resetTermineRoutesForTests()
if (uhrNeu) versatz = startMs - Date.now()
setzeUhrPublic(uhr)
setzeUhrKunde(uhr)
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['*']
config.termine.formMinSekunden = 0
config.termine.minAntwortMs = 0
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = crossOrigin ? pageOrigin : app.url
srv.sink = mailSink()
setMailTransportForTests(srv.sink)
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
aw.seed('customers', {
$id: KUNDE,
name: 'Silke Müller',
email: 'silke@salon-mueller.example',
...(entwurf ? { companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler' } : {}),
})
if (entwurf) {
await srv.abo('wartung')
return
}
aw.seed('portalFeatures', srv.featureDoc())
if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe()))
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
await warteAufVersand().catch(() => {})
setMailTransportForTests(null)
resetPublicTermineForTests()
resetTermineRoutesForTests()
Object.assign(config.termine, { mode: vorher.mode, pilotEmails: vorher.pilotEmails, formMinSekunden: vorher.formMinSekunden, minAntwortMs: vorher.minAntwortMs })
config.publicBaseUrl = vorher.publicBaseUrl
config.smtp.from = vorher.smtpFrom
await Promise.all([srv.beendeApp(), aw.close(), st.close()])
},
/** Nur die App (die Seiten und Schnittstellen) beenden; Appwrite-Nachbau bleibt (Test "Server gestoppt"). */
async beendeApp() {
if (srv.appBeendet) return
srv.appBeendet = true
let frist = null
const geschlossen = app.close()
app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })])
clearTimeout(frist)
},
appBeendet: false,
}
await srv.zuruecksetzen()
return srv
}