Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.
Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.
Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
+175
-34
@@ -1,4 +1,4 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
@@ -149,52 +149,155 @@ test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4
|
||||
assert.ok(k03u.text.includes('§ 11 der Vertragsbedingungen'), k03u.text)
|
||||
})
|
||||
|
||||
// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden
|
||||
// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden.
|
||||
// Entscheidung 2026-10-05: nur, wenn der Kunde eine von beiden hat (Funktionsdokument analyse oder termine in einem Zustand ausser off).
|
||||
const FUNKTIONEN_SATZ = 'Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm.'
|
||||
const WARTUNG_V4 = { productKey: 'wartung', termsVersion: config.abo.termsVersion }
|
||||
const WARTUNG = { patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }
|
||||
const k02 = () => bySubject('Bestätigung Ihrer Kündigung')
|
||||
|
||||
test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos', async () => {
|
||||
await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
|
||||
const FUNKTION_DOC = 'fk_funktion'
|
||||
/** Fuehrt fn mit einem Funktionsdokument von c1 aus (doc null: keins; doc ueberschreibt die Vorgaben) und raeumt es danach auf. */
|
||||
async function mitFunktion(doc, fn) {
|
||||
if (doc) aw.seed('portalFeatures', { $id: FUNKTION_DOC, customerId: 'c1', projectId: 'fk_p', featureKey: 'analyse', state: 'active', enabled: doc.state === 'active', ...doc })
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await deleteDocument(config.collections.portalFeatures, FUNKTION_DOC).catch(() => {})
|
||||
}
|
||||
}
|
||||
/** K03: Abo-Kuendigung ueber die Kuendigungsseite; liefert die Mail. setup = Parameter von resetSubH. */
|
||||
async function kuendigeMitK03(setup = {}) {
|
||||
await resetSubH(setup)
|
||||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
|
||||
assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text)
|
||||
assert.ok(k03().text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), k03().text)
|
||||
assert.ok(k03(), 'K03')
|
||||
return k03()
|
||||
}
|
||||
const gekuendigt = (id) => ({
|
||||
id,
|
||||
object: 'event',
|
||||
type: 'customer.subscription.updated',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } },
|
||||
})
|
||||
let k02Nummer = 0
|
||||
/** K02: Kuendigung im Stripe-Kundenportal (Webhook); liefert die Mail. setup = Parameter von resetSubH. */
|
||||
async function kuendigeMitK02(setup = {}) {
|
||||
await resetSubH({ patch: { ...setup.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...setup.stripe } })
|
||||
const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`))
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.ok(k02(), 'K02')
|
||||
return k02()
|
||||
}
|
||||
|
||||
// Hosting-Abo: weder Analyse noch Terminplaner
|
||||
await resetSubH()
|
||||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
|
||||
assert.doesNotMatch(k03().text, /Terminplaner/)
|
||||
test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos mit Analyse oder Terminplaner', async () => {
|
||||
await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => {
|
||||
const mail = await kuendigeMitK03(WARTUNG)
|
||||
assert.ok(mail.text.includes(FUNKTIONEN_SATZ), mail.text)
|
||||
assert.ok(mail.text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), mail.text)
|
||||
|
||||
// nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis
|
||||
await resetSubH({ units: 3, patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
|
||||
assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200)
|
||||
assert.doesNotMatch(k03().text, /Terminplaner/)
|
||||
// Hosting-Abo: weder Analyse noch Terminplaner
|
||||
assert.doesNotMatch((await kuendigeMitK03()).text, /Terminplaner/)
|
||||
|
||||
// unbekannte E-Mail (kein Abo gefunden): kein Hinweis
|
||||
await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
|
||||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
|
||||
assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/)
|
||||
// nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis
|
||||
await resetSubH({ units: 3, ...WARTUNG })
|
||||
assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200)
|
||||
assert.doesNotMatch(k03().text, /Terminplaner/)
|
||||
|
||||
// unbekannte E-Mail (kein Abo gefunden): kein Hinweis
|
||||
await resetSubH(WARTUNG)
|
||||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
|
||||
assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/)
|
||||
})
|
||||
})
|
||||
|
||||
test('K02: Kündigung im Stripe-Kundenportal, Hinweis auf das Ende der Funktionen nur beim Wartungs-Abo', async () => {
|
||||
const gekuendigt = (id) => ({
|
||||
id,
|
||||
object: 'event',
|
||||
type: 'customer.subscription.updated',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } },
|
||||
// (a) bis (d): welche Funktionsdokumente des Kunden den Hinweis ausloesen
|
||||
const FUNKTION_FAELLE = [
|
||||
['ohne Funktionsdokument', null, false],
|
||||
['aktive Analyse', { featureKey: 'analyse', state: 'active' }, true],
|
||||
['Terminplaner im Zustand setup', { featureKey: 'termine', state: 'setup' }, true],
|
||||
['Terminplaner bestellt (ordered)', { featureKey: 'termine', state: 'ordered' }, true],
|
||||
['abgeschalteter Terminplaner (disabled)', { featureKey: 'termine', state: 'disabled' }, true],
|
||||
['nur ein Terminplaner-Dokument im Zustand off', { featureKey: 'termine', state: 'off' }, false],
|
||||
['nur ein Analyse-Dokument im Zustand off', { featureKey: 'analyse', state: 'off' }, false],
|
||||
['Dokument ohne Zustand (gilt wie off)', { featureKey: 'termine', state: '' }, false],
|
||||
['aktive News-Pinnwand (keine Funktion des Hinweises)', { featureKey: 'news', state: 'active' }, false],
|
||||
['aktive Analyse eines anderen Kunden', { featureKey: 'analyse', state: 'active', customerId: 'c2' }, false],
|
||||
]
|
||||
for (const [name, doc, erwartet] of FUNKTION_FAELLE) {
|
||||
const wort = erwartet ? 'mit' : 'ohne'
|
||||
test(`K03, Wartungs-Abo: ${name} => ${wort} Hinweis`, async () => {
|
||||
const mail = await mitFunktion(doc, () => kuendigeMitK03(WARTUNG))
|
||||
assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text)
|
||||
assert.equal(/Terminplaner/.test(mail.text), erwartet)
|
||||
assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung')
|
||||
})
|
||||
for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) {
|
||||
const patch = productKey === 'wartung' ? WARTUNG_V4 : {}
|
||||
await resetSubH({ patch: { ...patch, cancelAtPeriodEnd: true }, stripe: { productKey, cancel_at_period_end: true, metadata: productKey === 'wartung' ? { termsVersion: config.abo.termsVersion } : {} } })
|
||||
const res = await postWebhook(gekuendigt(`evt_k02_${productKey}`))
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.ok(k02(), `${productKey}: K02`)
|
||||
assert.equal(k02().text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${k02().text}`)
|
||||
assert.equal(/Terminplaner/.test(k02().text), mitHinweis, productKey)
|
||||
test(`K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: ${name} => ${wort} Hinweis`, async () => {
|
||||
const mail = await mitFunktion(doc, () => kuendigeMitK02(WARTUNG))
|
||||
assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text)
|
||||
assert.equal(/Terminplaner/.test(mail.text), erwartet)
|
||||
assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung')
|
||||
})
|
||||
}
|
||||
|
||||
// (f) Hosting-Abo: nie ein Hinweis, auch nicht mit aktiver Analyse; die Funktionen werden dafuer gar nicht erst gelesen
|
||||
test('K03 und K02, Hosting-Abo: kein Hinweis (auch mit aktiver Analyse), Funktionsdokumente werden nicht gelesen', async () => {
|
||||
await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => {
|
||||
aw.resetListCalls()
|
||||
const mail03 = await kuendigeMitK03()
|
||||
assert.doesNotMatch(mail03.text, /Terminplaner/)
|
||||
const mail02 = await kuendigeMitK02({ stripe: { productKey: 'hosting' } })
|
||||
assert.doesNotMatch(mail02.text, /Terminplaner/)
|
||||
assert.equal(aw.listCalls('portalFeatures').length, 0, 'kein Lesen der Funktionen beim Hosting-Abo')
|
||||
})
|
||||
})
|
||||
|
||||
// (e) Lesefehler: lieber informieren als nicht; die Mail geht in jedem Fall raus, eine Zeile im Log
|
||||
test('K03, Wartungs-Abo: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => {
|
||||
const log = mock.method(console, 'error', () => {})
|
||||
try {
|
||||
await resetSubH(WARTUNG)
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' })
|
||||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
|
||||
assert.ok(k03(), 'K03 geht trotzdem raus')
|
||||
assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text)
|
||||
assert.ok(t08(), 'T08 ans Team')
|
||||
const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen'))
|
||||
assert.equal(zeilen.length, 1, zeilen.join('\n'))
|
||||
assert.match(zeilen[0], /\[abo\] Funktionen/)
|
||||
} finally {
|
||||
log.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => {
|
||||
const log = mock.method(console, 'error', () => {})
|
||||
try {
|
||||
await resetSubH({ patch: { ...WARTUNG.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...WARTUNG.stripe } })
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' })
|
||||
const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`))
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.ok(k02(), 'K02 geht trotzdem raus')
|
||||
assert.ok(k02().text.includes(FUNKTIONEN_SATZ), k02().text)
|
||||
assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, 'die Kündigung wird danach noch vermerkt')
|
||||
const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen'))
|
||||
assert.equal(zeilen.length, 1, zeilen.join('\n'))
|
||||
assert.ok(!log.mock.calls.some((c) => String(c.arguments[0]).includes('Kuendigungsbestaetigung')), 'kein Fehler der Bestätigung')
|
||||
} finally {
|
||||
log.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('K02: Kündigung im Stripe-Kundenportal, Hinweis nur beim Wartungs-Abo mit Funktion (Hosting-Abo nie)', async () => {
|
||||
await mitFunktion({ featureKey: 'termine', state: 'active' }, async () => {
|
||||
for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) {
|
||||
const setup = productKey === 'wartung' ? WARTUNG : { patch: {}, stripe: { productKey, metadata: {} } }
|
||||
const mail = await kuendigeMitK02(setup)
|
||||
assert.equal(mail.text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${mail.text}`)
|
||||
assert.equal(/Terminplaner/.test(mail.text), mitHinweis, productKey)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('2 von 3 Paketen ergibt Menge 1', async () => {
|
||||
await resetSubH({ units: 3 })
|
||||
const res = await cancel({ contract: 'zusatzspeicher', packages: '2' })
|
||||
@@ -1007,6 +1110,43 @@ test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 de
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_shop: Webshop (Einrichtung) rechnet mit der eigenen Frist, K04 und T09 nennen Webshop, Preis und Projekt, nichts wird automatisch geändert', async () => {
|
||||
const shopAt = daysAgo(3)
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(20), state: 'active' })
|
||||
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: daysAgo(2) })
|
||||
seedBezahlt({ projectId: 'ps1', projektName: 'Bäckerei Shop', featureKey: 'shop', orderedAt: shopAt })
|
||||
try {
|
||||
await resetSubH({ units: 0 })
|
||||
const before = st.calls.length
|
||||
assert.equal((await withdraw({ order: 'shop', customerNumber: 'KD-9' })).status, 200)
|
||||
const kunde = k04()
|
||||
assert.match(kunde.subject, /WEBklar Webshop$/)
|
||||
assert.match(kunde.text, /Widerruf der Bestellung „Webshop \(Einrichtung\)“/)
|
||||
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
|
||||
assert.doesNotMatch(kunde.text, /Es erfolgen keine weiteren Abbuchungen/)
|
||||
const team = t09().text
|
||||
assert.match(team, /RECHTZEITIG/)
|
||||
assert.doesNotMatch(team, /NACH Fristende/)
|
||||
assert.match(team, /Webshop \(Einrichtung\)/)
|
||||
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.shop.label.name}, ${config.features.shop.priceLabel} einmalig)`), team)
|
||||
assert.match(team, /299 €/)
|
||||
assert.ok(team.includes(fmtDate(withdrawalDeadline(shopAt).endsAt)), 'Fristende des Webshops')
|
||||
assert.match(team, /Bäckerei Shop: in Einrichtung/)
|
||||
assert.doesNotMatch(team, /Bäckerei News|Studio Termine/, 'andere Funktionen gehören nicht zu dieser Wahl')
|
||||
assert.match(team, /NICHTS automatisch geändert/)
|
||||
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
|
||||
assert.equal(aw.docs('portalFeatures').get('f_ps1_shop').state, 'setup', 'nichts automatisch geändert')
|
||||
|
||||
// ohne bezahlten Webshop: ungeprüft, die Antwort bleibt 200
|
||||
await raeumeBezahlt('f_ps1_shop')
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'shop' })).status, 200)
|
||||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine', 'f_ps1_shop')
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_zeiten: „Öffnungszeiten live“ gehört zu den Zeiten (nicht zur News), Frist und Produkt in K04 und T09 stimmen', async () => {
|
||||
const newsAt = daysAgo(20)
|
||||
const zeitenAt = daysAgo(3)
|
||||
@@ -1313,6 +1453,7 @@ test('widerrufen_html_radio: Radioknöpfe order', () => {
|
||||
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
|
||||
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
|
||||
assert.match(src, /name="order" value="termine"[^>]*data-text="Terminplaner \(Einrichtung\)"[^>]*> Terminplaner \(Einrichtung\)<\/label>/)
|
||||
assert.match(src, /name="order" value="shop"[^>]*data-text="Webshop \(Einrichtung\)"[^>]*> Webshop \(Einrichtung\)<\/label>/)
|
||||
assert.match(src, /name="order" value="zeiten"[^>]*data-text="Öffnungszeiten live"[^>]*> Öffnungszeiten live<\/label>/)
|
||||
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||||
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
|
||||
|
||||
Reference in new issue
Block a user