Files
Webklar-Kundenbereich/test/ui/shopTestServer.mjs
T

290 lines
15 KiB
JavaScript

// UI-Testserver des Webshops (fuer test/ui/shop.ui.mjs): die echten Express-Router (/api/public/shop/v1, /api/shop/stripe-webhook,
// /api/shop, /api/funktionen, /api/abo), die Seiten /shop/:siteKey und /rechtliches/ sowie public/ als statische Dateien, dahinter der
// Nachbau von Appwrite und Stripe (mit Connect) aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur
// 127.0.0.1). Muster: test/ui/termineTestServer.mjs.
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startShopUiServer } from './shopTestServer.mjs'
// const srv = await startShopUiServer()
// ... 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
//
// Uhr: echte Uhr (Server und Browser). Zeitabhaengige Pruefungen lesen die Zeit aus der Antwort.
//
// Mitarbeiter-Schritte rufen die Dienste direkt auf (die Team-Routen brauchen ein Mitarbeiter-JWT):
// srv.entscheide('annehmen' | 'ablehnen', begruendung) entscheideAnfrage
// srv.bezahleEinrichtung() Plattform-Webhook checkout.session.completed fuer die Bestellung der Einrichtung (signiert, /api/abo/webhook)
// srv.schliesseTicket() Einrichtungs-Ticket schliessen (workorders.status 'Closed'); GET /api/funktionen schaltet danach frei
// Haendler-Konto bei Stripe: srv.kontoFreigeben() (charges_enabled, payouts_enabled), Kasse des Kaeufers: srv.bezahleKasse(sessionId) +
// srv.sendeConnect(type, objekt) (signierter Connect-Webhook an /api/shop/stripe-webhook).
//
// Adressen: Der Server lauscht auf srv.url (http://127.0.0.1:PORT, fuer Webhooks und Direktaufrufe). Das Portal heisst fuer Server und Browser
// srv.portalUrl (https://project.webklar.test = config.publicBaseUrl, wie in Produktion https); das Skript leitet diese Adresse im Browser per
// page.route an srv.url um (route.fetch). Die Website des Haendlers heisst srv.kundenUrl (https://www.toepferei-brandt.example = liveDomain des
// Projekts); ihre Seiten liefert das Skript per page.route. So sind Stripe-Rueckspruenge, Bild-Adressen (Widget verlangt https) und
// Cross-Origin-Anfragen (data-api) so wie im Betrieb.
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.toepferei-brandt.example'
export const KUNDE = 'c1'
export const PROJEKT = 'p1'
export const FEATURE = 'f_p1_shop'
export const CONNECT_SECRET = 'whsec_connect_ui_test'
export const KUNDEN_EMAIL = 'hanna@toepferei-brandt.example'
export const KUNDEN_NAME = 'Hanna Brandt'
export const MITARBEITER = { name: 'Kenso', email: 'kenso@webklar.com' }
/** Anfrage, wie sie das Formular im Portal schickt. */
export const ANFRAGE = {
warenart: 'kunst',
beschreibung: 'Handgemachte Keramik, Tassen und Vasen aus eigener Werkstatt in Winnweiler.',
artikelzahl: 'bis20',
versand: true,
abholung: true,
unternehmer: true,
keineAusgeschlossenen: true,
nachricht: 'Wir würden gern vor Weihnachten starten.',
}
export async function startShopUiServer() {
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: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'),
featureConsents: schemaKeys('featureConsents'),
subscriptions: schemaKeys('subscriptions'),
},
datetimes: { ...SHOP_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, postWebhook } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicShopRoutes, resetPublicShopForTests, setzeUhrFuerTests: setzeUhrPublic } = await import('../../server/routes/publicShop.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../../server/routes/shop.js')
const { default: shopWebhookRoutes } = await import('../../server/routes/shopWebhook.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../../server/routes/abo.js')
const { shopSeiteRouter, resetShopSeiteForTests } = await import('../../server/routes/shopSeite.js')
const { legalRouter } = await import('../../server/services/legalDocs.js')
const { entscheideAnfrage } = await import('../../server/services/shop/anfrage.js')
const { leereCache } = await import('../../server/services/shop/kontext.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
const { getFeatureDoc } = await import('../../server/services/siteFeatures.js')
// Reihenfolge wie server/index.js: /embed (CORS, Cache), /rechtliches, Shop-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(shopSeiteRouter())
statisch.use(express.static(PUBLIC_DIR))
const app = await startTestApp({
'/api/public/shop/v1': publicShopRoutes,
'/api/shop/stripe-webhook': shopWebhookRoutes,
'/api/shop': shopRoutes,
'/api/funktionen': funktionenRoutes,
'/api/abo': aboRoutes,
'/': statisch,
})
const uploadDir = await mkdtemp(path.join(tmpdir(), 'shop-ui-'))
const vorher = {
mode: config.shop.mode,
pilotEmails: config.shop.pilotEmails,
connectWebhookSecret: config.shop.connectWebhookSecret,
formMinSekunden: config.shop.formMinSekunden,
minAntwortMs: config.shop.minAntwortMs,
uploadDir: config.shop.uploadDir,
publicBaseUrl: config.publicBaseUrl,
smtpFrom: config.smtp.from,
}
const col = (name) => config.collections[name]
const srv = {
url: app.url,
portalUrl: PORTAL_URL,
kundenUrl: KUNDEN_URL,
KUNDE, PROJEKT, FEATURE,
aw, st, config,
sink: null,
warteAufVersand,
/** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. */
async portalCookie() {
const { sessionCookie } = await import('../support/testApp.js')
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
featureDoc() { return [...aw.docs('portalFeatures').values()].find((d) => d.featureKey === 'shop') ?? null },
shopDoc() { return [...aw.docs('shopConfig').values()][0] ?? null },
siteKey() { return srv.featureDoc()?.siteKey ?? srv.shopDoc()?.siteKey ?? null },
einstellungen() { return JSON.parse(srv.shopDoc().einstellungen) },
rechtstexte() { return JSON.parse(srv.shopDoc().rechtstexte) },
produkte() { return [...aw.docs('shopProducts').values()].map((d) => ({ ...d, produkt: JSON.parse(d.produkt) })) },
bestellungen() { return [...aw.docs('shopOrders').values()].map((d) => ({ ...d, daten: JSON.parse(d.bestellung) })) },
widerrufe() { return [...aw.docs('shopWiderrufe').values()] },
/** Mails der Senke; mit Teiltext des Betreffs filtern. Vorher warteAufVersand() (die Warteschlange sendet im Hintergrund). */
mails(betreff) { return srv.sink.sent.filter((m) => !betreff || String(m.subject).includes(betreff)) },
outbox() { return [...aw.docs('termineOutbox').values()] },
// ---------------------------------------------------------------- Mitarbeiter-Schritte (Dienste direkt)
async entscheide(entscheidung = 'annehmen', begruendung = '') {
return entscheideAnfrage({ customerId: KUNDE, projectId: PROJEKT, entscheidung, begruendung, person: MITARBEITER })
},
/** Bestellung der Einrichtung (Zustand ordered) bezahlen: Sitzung abschliessen, signierter Plattform-Webhook. Liefert { status } der Antwort. */
async bezahleEinrichtung() {
const doc = srv.featureDoc()
if (!doc?.stripeSessionId) throw new Error(`keine Bestellung der Einrichtung (Zustand ${doc?.state})`)
const sessionId = doc.stripeSessionId
const invoice = `in_${sessionId}`
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
const sitzung = { ...st.checkoutSessions.get(sessionId), status: 'complete', payment_status: 'paid', invoice }
st.checkoutSessions.set(sessionId, sitzung)
const event = { id: `evt_${Math.random()}`, object: 'event', type: 'checkout.session.completed', data: { object: sitzung } }
return postWebhook(event)
},
/** Einrichtungs-Ticket der Funktion schliessen. */
async schliesseTicket() {
const doc = srv.featureDoc()
if (!doc?.ticketId) throw new Error(`kein Ticket (Zustand ${doc?.state})`)
await updateDocument(col('workorders'), doc.ticketId, { status: 'Closed' })
},
// ---------------------------------------------------------------- Stripe (Haendlerkonto)
/** Das Haendlerkonto des Shops nimmt Zahlungen an (wie nach dem Onboarding bei Stripe). */
kontoFreigeben(patch = {}) {
const id = srv.shopDoc()?.stripeAccountId
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 })
},
kontoId() { return srv.shopDoc()?.stripeAccountId ?? null },
/** Offene Checkout-Sitzungen des Haendlerkontos. */
kassenSitzungen() { return [...st.accountSessions(srv.kontoId()).values()] },
/** Sitzung des Kaeufers als bezahlt abschliessen (Stripe-Nachbau); liefert die Sitzung, wie sie der Webhook traegt. */
bezahleKasse(sessionId, { zahlung = 'pi_ui_1', kunde, versand, betrag } = {}) {
const acct = srv.kontoId()
const sitzung = st.accountSessions(acct).get(sessionId)
if (!sitzung) throw new Error(`keine Sitzung ${sessionId}`)
const order = srv.bestellungen().find((o) => o.stripeSessionId === sessionId)
const summe = betrag ?? order?.summeCent ?? null
st.setPaymentIntent(acct, zahlung, { amount: summe })
return st.completeSession(acct, sessionId, {
payment_status: 'paid',
amount_total: summe,
customer_details: kunde ?? {
name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567',
address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' },
},
shipping: versand ?? { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } },
payment_intent: zahlung,
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() },
/**
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke; Kunde c1 mit Projekt p1 und Wartungs-Abo-freier
* Bestellung (der Webshop verlangt kein Abo). Schalter: Pilotmodus fuer alle Kunden ('*').
*/
async zuruecksetzen() {
await warteAufVersand()
const sammlungen = [
'portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv', 'termineOutbox',
'featureConsents', 'customers', 'websiteProjects', 'workorders', 'subscriptions',
]
for (const collection of sammlungen) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(col(collection) ?? collection, id)
}
st.checkoutSessions.clear()
st.invoices.clear()
st.calls.length = 0
leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
resetPublicShopForTests()
resetShopRoutesForTests()
resetShopSeiteForTests()
setzeUhrPublic(() => new Date())
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['*']
config.shop.connectWebhookSecret = CONNECT_SECRET
config.shop.formMinSekunden = 0
config.shop.minAntwortMs = 0
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: 'Töpferei Brandt', subdomain: 'toepferei', liveDomain: new URL(KUNDEN_URL).host,
})
aw.seed('customers', { $id: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL, status: 'active', portalAccessEnabled: true })
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
await warteAufVersand().catch(() => {})
setMailTransportForTests(null)
resetPublicShopForTests()
resetShopRoutesForTests()
Object.assign(config.shop, {
mode: vorher.mode, pilotEmails: vorher.pilotEmails, connectWebhookSecret: vorher.connectWebhookSecret, formMinSekunden: vorher.formMinSekunden,
minAntwortMs: vorher.minAntwortMs, 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 })
},
}
// getFeatureDoc ist nur fuer Skripte gedacht, die den Zustand pruefen wollen
srv.getFeatureDoc = () => getFeatureDoc(KUNDE, PROJEKT, 'shop')
await srv.zuruecksetzen()
return srv
}