Files
Webklar-Kundenbereich/test/ui/unterkunftTestServer.mjs
T
2026-10-07 15:06:33 +00:00

231 lines
13 KiB
JavaScript

// UI-Testserver des Buchungskalenders (fuer test/ui/unterkunft.ui.mjs): die echten Express-Router (/api/public/unterkunft/v1,
// /api/shop/stripe-webhook (leitet Buchungskalender-Ereignisse weiter), /api/unterkunft, /api/funktionen), die Seiten /unterkunft/... samt
// iCal-Export und public/ als statische Dateien, dahinter der Nachbau von Appwrite und Stripe (Connect) aus test/support/ und eine Mail-Senke.
// Nichts verlaesst den Rechner (useTestEnv erlaubt nur 127.0.0.1). Muster: test/ui/shopTestServer.mjs.
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startUnterkunftUiServer } from './unterkunftTestServer.mjs'
// const srv = await startUnterkunftUiServer()
// ... srv.url, srv.portalUrl, srv.kundenUrl, srv.sink.sent, srv.st (Stripe-Nachbau), srv.aw (Appwrite-Nachbau) ...
// await srv.stop() // erst den Browser schliessen, dann den Server
//
// Adressen: Der Server lauscht auf srv.url (http://127.0.0.1:PORT). Das Portal heisst fuer Server und Browser srv.portalUrl
// (https://project.webklar.test = config.publicBaseUrl); das Skript leitet diese Adresse im Browser per page.route an srv.url um.
// Die Website des Vermieters heisst srv.kundenUrl (liveDomain des Projekts); ihre Seiten liefert das Skript per page.route.
// Uhr: echte Uhr (Server und Browser).
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 PORTAL_URL = 'https://project.webklar.test'
export const KUNDEN_URL = 'https://www.donau-home.example'
export const KUNDE = 'c1'
export const PROJEKT = 'p1'
export const FEATURE = 'f_p1_unterkunft'
export const SITE_KEY = 'donauhomeukwxyz2'
export const CONNECT_SECRET = 'whsec_connect_ui_test'
export const KUNDEN_EMAIL = 'mimi@donau-home.example'
export const KUNDEN_NAME = 'Mimi Donau'
const SAMMLUNGEN = [
'portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen',
'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv', 'termineOutbox', 'featureConsents', 'subscriptions',
]
export async function startUnterkunftUiServer() {
const { UNTERKUNFT_DATETIMES } = await import('../../server/services/unterkunft/schema.js')
const { SHOP_DATETIMES } = await import('../../server/services/shop/schema.js')
const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js')
const aw = await startAppwriteMock({
schema: Object.fromEntries(SAMMLUNGEN.map((n) => [n, schemaKeys(n)])),
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES, ...UNTERKUNFT_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../../server/config.js')
const { startTestApp, postWebhook, sessionCookie } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicRoutes, setzeUhrFuerTests } = await import('../../server/routes/publicUnterkunft.js')
const { default: unterkunftRoutes, resetUnterkunftRoutesForTests } = await import('../../server/routes/unterkunft.js')
const { default: shopWebhookRoutes } = await import('../../server/routes/shopWebhook.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { unterkunftSeiteRouter, resetUnterkunftSeiteForTests } = await import('../../server/routes/unterkunftSeite.js')
const { legalRouter } = await import('../../server/services/legalDocs.js')
const { leereCache } = await import('../../server/services/unterkunft/kontext.js')
const mails = await import('../../server/services/unterkunft/mails.js')
const zahlung = await import('../../server/services/unterkunft/zahlung.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
// Reihenfolge wie server/index.js: /embed (CORS), /rechtliches, Buchungskalender-Seiten, public/
const statisch = express.Router()
statisch.use('/embed/', (_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*')
next()
})
statisch.use('/rechtliches', legalRouter())
statisch.use(unterkunftSeiteRouter())
statisch.use(express.static(PUBLIC_DIR))
const app = await startTestApp({
'/api/public/unterkunft/v1': publicRoutes,
'/api/shop/stripe-webhook': shopWebhookRoutes,
'/api/unterkunft': unterkunftRoutes,
'/api/funktionen': funktionenRoutes,
'/': statisch,
})
const uploadDir = await mkdtemp(path.join(tmpdir(), 'unterkunft-ui-'))
const vorher = {
unterkunft: { ...config.unterkunft },
shopMode: config.shop.mode,
shopPilot: config.shop.pilotEmails,
connectWebhookSecret: config.shop.connectWebhookSecret,
uploadDir: config.shop.uploadDir,
publicBaseUrl: config.publicBaseUrl,
smtpFrom: config.smtp.from,
}
const col = (name) => config.collections[name]
const json = (name, wert) => (typeof wert === 'string' ? JSON.parse(wert) : wert)
const srv = {
url: app.url,
portalUrl: PORTAL_URL,
kundenUrl: KUNDEN_URL,
KUNDE, PROJEKT, FEATURE, SITE_KEY,
aw, st, config, mails, zahlung,
sink: null,
warteAufVersand,
/** Cookie des Portal-Kunden (c1) fuer context.addCookies([...]): { name, value, url }. */
async portalCookie() {
const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL })
const i = roh.indexOf('=')
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: PORTAL_URL }
},
// ---------------------------------------------------------------- Dokumente lesen (JSON-Felder geparst)
einstellungen() { return [...aw.docs('ukEinstellungen').values()][0] ?? null },
unterkuenfte() { return [...aw.docs('ukUnterkuenfte').values()] },
preise() { return [...aw.docs('ukPreise').values()].map((d) => ({ ...d, staffeln: json('staffeln', d.staffeln) })) },
sperren() { return [...aw.docs('ukSperren').values()] },
anfragen() {
return [...aw.docs('ukAnfragen').values()].map((d) => ({
...d, gast: json('gast', d.gast), rechnungsadresse: json('r', d.rechnungsadresse), posten: json('p', d.posten), summen: json('s', d.summen),
}))
},
anfrage(id) { return srv.anfragen().find((a) => a.$id === id) ?? null },
rechnungen() { return [...aw.docs('ukRechnungen').values()].map((d) => ({ ...d, inhalt: json('i', d.inhalt) })) },
outbox() { return [...aw.docs('termineOutbox').values()] },
/** Mails der Senke; mit Teiltext des Betreffs filtern. Vorher warteAufVersand() (die Warteschlange sendet im Hintergrund). */
mailsMit(betreff) { return srv.sink.sent.filter((m) => !betreff || String(m.subject).includes(betreff)) },
/** Arten der bisher eingereihten Gastmails (G16 ...) in Reihenfolge. */
gastArten() { return mails._aufrufe.filter((a) => a.an === 'gast').map((a) => a.art) },
vermieterArten() { return mails._aufrufe.filter((a) => a.an === 'vermieter').map((a) => a.template) },
// ---------------------------------------------------------------- Stripe (Haendlerkonto des Vermieters)
kontoId() { return srv.einstellungen()?.stripeAccountId ?? null },
/** Das Konto nimmt Zahlungen an (wie nach dem Onboarding bei Stripe). */
kontoFreigeben(patch = {}) {
const id = srv.kontoId()
if (!id) throw new Error('kein Stripe-Konto angelegt')
return st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null }, ...patch })
},
kassenSitzungen() { return [...st.accountSessions(srv.kontoId()).values()] },
erstattungen() { return [...st.refunds(srv.kontoId()).values()] },
/** Sitzung des Gastes als bezahlt abschliessen (Stripe-Nachbau); liefert die Sitzung, wie sie der Webhook traegt. */
bezahleKasse(sessionId, { zahlung: pi = 'pi_uk_ui_1', betrag } = {}) {
const acct = srv.kontoId()
const sitzung = st.accountSessions(acct).get(sessionId)
if (!sitzung) throw new Error(`keine Sitzung ${sessionId}`)
const summe = betrag ?? sitzung.line_items?.[0]?.price_data?.unit_amount ?? srv.anfragen().find((a) => a.stripeSessionId === sessionId)?.summen?.bruttoCent ?? null
st.setPaymentIntent(acct, pi, { amount: summe })
return st.completeSession(acct, sessionId, { payment_status: 'paid', amount_total: summe, payment_intent: pi, payment_method_types: ['card'] })
},
/** Signiertes Connect-Ereignis an POST /api/shop/stripe-webhook -> { status, body }. */
sendeConnect(type, objekt, { account } = {}) {
const event = { id: `evt_${Math.random().toString(36).slice(2)}`, object: 'event', type, livemode: false, account: account ?? srv.kontoId(), data: { object: objekt } }
return postWebhook(event, { path: '/api/shop/stripe-webhook', secret: CONNECT_SECRET })
},
leereCache() { leereCache() },
/** Eine freigegebene (reservierte, noch nicht gebuchte) Anfrage ueber die Dienste anlegen (Kalenderfarbe „reserviert“); liefert { id, token }. */
async reserviere({ unterkunftId, von, bis, personen = 2, name = 'Rita Reserviert', email = 'rita@example.com' }) {
const ab = await import('../../server/services/unterkunft/ablauf.js')
const { unterkunftPerSiteKey } = await import('../../server/services/unterkunft/kontext.js')
const ctx = await unterkunftPerSiteKey(SITE_KEY)
const a = await ab.legeAnfrageAn(ctx, { unterkunftId, von, bis, personen, gast: { name, firma: '', email, telefon: '', nachricht: '' } }, { now: new Date(), ipHash: 'ui' })
const { token } = await ab.gibFrei(ctx, a.$id, { now: new Date() })
return { id: a.$id, token }
},
/** Alles auf Anfang: Dokumente loeschen, Zwischenspeicher leeren, neue Senke; Kunde c1, Projekt p1, Funktion 'unterkunft' aktiv, Schalter on. */
async zuruecksetzen() {
await warteAufVersand()
for (const sammlung of [...SAMMLUNGEN, 'customers', 'websiteProjects', 'workorders']) {
for (const id of [...aw.docs(sammlung).keys()]) await deleteDocument(col(sammlung) ?? sammlung, id)
}
st.checkoutSessions.clear()
st.invoices.clear()
st.calls.length = 0
leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
resetUnterkunftRoutesForTests()
resetUnterkunftSeiteForTests()
zahlung.leereAbgleichFuerTests()
mails._aufrufe.length = 0
setzeUhrFuerTests(() => new Date())
Object.assign(config.unterkunft, { mode: 'on', minAntwortMs: 0, formMinSekunden: 0 })
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['*']
config.shop.connectWebhookSecret = CONNECT_SECRET
config.shop.uploadDir = uploadDir
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = PORTAL_URL
srv.sink = mailSink()
setMailTransportForTests(srv.sink)
aw.seed('websiteProjects', {
$id: PROJEKT, customerId: KUNDE, projectName: 'Donau Home', subdomain: 'donau-home', liveDomain: new URL(KUNDEN_URL).host,
})
aw.seed('customers', { $id: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL, status: 'active', portalAccessEnabled: true })
aw.seed('portalFeatures', {
$id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'unterkunft', siteKey: SITE_KEY, state: 'active', enabled: true,
})
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
await warteAufVersand().catch(() => {})
setMailTransportForTests(null)
Object.assign(config.unterkunft, vorher.unterkunft)
Object.assign(config.shop, { mode: vorher.shopMode, pilotEmails: vorher.shopPilot, connectWebhookSecret: vorher.connectWebhookSecret, uploadDir: vorher.uploadDir })
config.publicBaseUrl = vorher.publicBaseUrl
config.smtp.from = vorher.smtpFrom
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)
await Promise.all([aw.close(), st.close()])
await rm(uploadDir, { recursive: true, force: true })
},
}
await srv.zuruecksetzen()
return srv
}