1467 lines
78 KiB
JavaScript
1467 lines
78 KiB
JavaScript
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'
|
||
import { startFakeStripe } from './support/fakeStripe.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { schemaKeys } from './support/schema.js'
|
||
import { mailSink } from './support/mailSink.js'
|
||
|
||
// Oeffentliche Kuendigungs- und Widerrufsseite (Task 10): Auswahl des Vertrags (Abo oder nur der
|
||
// E-Mail-Zusatzspeicher, ganz oder mit Paketzahl) bzw. der Bestellung (Abschluss, Wechsel, Zusatzspeicher).
|
||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
|
||
const aw = await startAppwriteMock({
|
||
schema: {
|
||
subscriptions: schemaKeys('subscriptions'),
|
||
contractEvents: schemaKeys('contractEvents'),
|
||
hourCredits: schemaKeys('hourCredits'),
|
||
portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'],
|
||
featureConsents: schemaKeys('featureConsents'),
|
||
},
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
const { config } = await import('../server/config.js')
|
||
const { startTestApp, post, postWebhook } = await import('./support/testApp.js')
|
||
const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
|
||
const { default: aboRoutes, resetPublicRateLimit, publicRateLimitState, purgeRateLimit } = await import('../server/routes/abo.js')
|
||
const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js')
|
||
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||
const { gleicheAboAb } = await import('../server/services/termine/aboAbgleich.js')
|
||
const { handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js')
|
||
|
||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||
const app = await startTestApp({ '/api/abo': aboRoutes })
|
||
// Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar
|
||
const mails = mailSink()
|
||
setMailTransportForTests(mails)
|
||
after(() => {
|
||
setMailTransportForTests(null)
|
||
return Promise.all([app.close(), aw.close(), st.close()])
|
||
})
|
||
seedFixtures(aw)
|
||
|
||
// Preise im Stripe-Nachbau mit den lookup_keys des Katalogs (ensurePrice findet sie, stripeSubscription() haengt
|
||
// dieselben IDs an die Abo-Posten)
|
||
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
|
||
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
|
||
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
|
||
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
|
||
|
||
const DAY_MS = 86400000
|
||
const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString()
|
||
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd)
|
||
|
||
async function clearEvents(customerId) {
|
||
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
|
||
}
|
||
|
||
/** Hosting-Abo sub_h von c1 in beiden Nachbauten auf einen Stand setzen (units Pakete); leert Ereignisse und Senke. */
|
||
async function resetSubH({ units = 0, patch = {}, stripe = {} } = {}) {
|
||
await clearEvents('c1')
|
||
mails.sent.length = 0
|
||
aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, ...patch })
|
||
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units, ...stripe }))
|
||
}
|
||
|
||
// Jeder Test faengt ohne Ratenbegrenzung an (10 Anfragen je Stunde und IP, F30)
|
||
beforeEach(() => resetPublicRateLimit())
|
||
|
||
const subscriptionUpdates = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h')
|
||
const storageQuantity = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)?.quantity ?? 0
|
||
const bySubject = (start) => mails.sent.find((m) => m.subject.startsWith(start)) || null
|
||
const k03 = () => bySubject('Eingangsbestätigung Ihrer Kündigung')
|
||
const k04 = () => bySubject('Eingangsbestätigung Ihres Widerrufs')
|
||
const k13 = () => bySubject('Ihr E-Mail-Zusatzspeicher')
|
||
const t08 = () => bySubject('[Abo] Kündigung eingegangen')
|
||
const t09 = () => bySubject('[Abo] WIDERRUF eingegangen')
|
||
const t13 = () => bySubject('[Speicher]')
|
||
const events = async (type) => (await listContractEvents('c1')).filter((e) => e.type === type)
|
||
|
||
const cancel = (body) => post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', ...body })
|
||
const withdraw = (body) => post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', ...body })
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Kuendigung: nur der Zusatzspeicher (Rechtsrunde 28.09.2026, Entscheidung 5)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('Kündigung nur Zusatzspeicher: Menge 0 ohne Erstattung, Ereignis', async () => {
|
||
await resetSubH({ units: 3 })
|
||
const before = st.calls.length
|
||
const res = await cancel({ contract: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(st.calls.findLast((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h').body['items[0][quantity]'], '0')
|
||
assert.equal(subscriptionUpdates(before)[0].body.proration_behavior, 'none')
|
||
assert.equal(storageQuantity(), 0)
|
||
const all = await listContractEvents('c1')
|
||
assert.equal(all[0].type, 'kuendigung')
|
||
// F31: das Ereignis traegt Vertrag, Wirksamkeit und die Pakete; ohne IP oder Browser (Datenschutz Abschnitt 7)
|
||
assert.deepEqual(all[0].details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 3, fromUnits: 3, toUnits: 0 })
|
||
assert.equal(all[0].status, 'erledigt')
|
||
assert.equal(all[0].subscriptionId, 'sub_h')
|
||
assert.equal(all[0].consentIp ?? null, null)
|
||
assert.equal(all[0].$id.length, 36)
|
||
// Die Verringerung selbst ist das Ereignis speicher ueber den Kuendigungsweg (Task 8)
|
||
const [speicher] = await events('speicher')
|
||
assert.equal(speicher.details.viaCancellation, true)
|
||
assert.equal(speicher.details.toUnits, 0)
|
||
// K03 nennt Vertrag, Pakete und den Zeitpunkt; K13 entfaellt (F31), T08 und T13 gehen ans Team
|
||
assert.ok(k03(), 'K03')
|
||
assert.match(k03().text, /E-Mail-Zusatzspeicher/)
|
||
assert.ok(k03().text.includes('um 3 Paket(e) gekündigt; danach bleiben 0 Paket(e)'), k03().text)
|
||
assert.ok(k03().text.includes(PERIOD_END_LABEL), PERIOD_END_LABEL)
|
||
assert.doesNotMatch(k03().text, /Wartungs-Abo\n/, 'kein fester Vertragsname')
|
||
assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular')
|
||
assert.match(t08().text, /um 3 Paket\(e\) verringert/)
|
||
assert.match(t08().text, /E-Mail-Zusatzspeicher/)
|
||
assert.ok(t13(), 'T13 (Speicher-Aufgabe)')
|
||
// Die Abo-Kuendigung selbst bleibt aus: kein cancel_at, kein cancelRequestedAt
|
||
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
|
||
assert.equal(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, undefined)
|
||
})
|
||
|
||
// Task 12: Betreff und Text nennen das Abo des Kunden (Katalogname ohne „WEBklar “, F33); das Vertragsende steht
|
||
// in der Fassung 4 in § 11, nicht in § 9 (F08); das Stundenkontingent gibt es nur im Wartungs-Abo (kein Geldguthaben, Fix 1)
|
||
test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4', async () => {
|
||
await resetSubH()
|
||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
|
||
assert.equal(k03().subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Hosting-Abo')
|
||
assert.ok(k03().text.includes('§ 11 der Vertragsbedingungen'), k03().text)
|
||
assert.ok(!k03().text.includes('§ 9 der Vertragsbedingungen') && !k03().text.includes('Stundenkontingent') && !k03().text.includes('Stundenguthaben'))
|
||
assert.ok(t08().text.includes('Abo: Hosting-Abo'), t08().text)
|
||
await resetSubH()
|
||
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
|
||
assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Hosting-Abo')
|
||
assert.ok(t09().text.includes('Abo: Hosting-Abo'), t09().text)
|
||
// Wartungs-Abo nach Fassung 3 (Altbestand): § 9, nicht genutzte Arbeitszeit aus dem Stundenkontingent
|
||
await resetSubH({ patch: { productKey: 'wartung', termsVersion: '2026-09-25-v3' }, stripe: { productKey: 'wartung', metadata: { termsVersion: '2026-09-25-v3' } } })
|
||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
|
||
assert.equal(k03().subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo')
|
||
assert.ok(k03().text.includes('§ 9 der Vertragsbedingungen') && k03().text.includes('Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende.'), k03().text)
|
||
// Unbekannte E-Mail: kein Abo, neutrale Anrede „Abo“, Paragraph der aktuellen Fassung (F08)
|
||
await resetSubH()
|
||
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
|
||
const k03u = mails.sent.find((m) => m.subject.startsWith('Eingangsbestätigung Ihrer Kündigung'))
|
||
assert.equal(k03u.subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Abo')
|
||
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.
|
||
// 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')
|
||
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(), '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()
|
||
}
|
||
|
||
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)
|
||
|
||
// Hosting-Abo: weder Analyse noch Terminplaner
|
||
assert.doesNotMatch((await kuendigeMitK03()).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/)
|
||
})
|
||
})
|
||
|
||
// (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')
|
||
})
|
||
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' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(storageQuantity(), 1)
|
||
const [ev] = await events('kuendigung')
|
||
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 2, fromUnits: 3, toUnits: 1 })
|
||
assert.ok(k03().text.includes('um 2 Paket(e) gekündigt; danach bleiben 1 Paket(e)'), k03().text)
|
||
assert.doesNotMatch(k03().text, /um 3 Paket/)
|
||
})
|
||
|
||
test('Ohne packages ergibt Menge 0; mehr Pakete als gebucht heißt alle', async () => {
|
||
await resetSubH({ units: 3 })
|
||
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '' })).status, 200)
|
||
assert.equal(storageQuantity(), 0)
|
||
await resetSubH({ units: 2 })
|
||
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: 10 })).status, 200)
|
||
assert.equal(storageQuantity(), 0)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.details.packages, 2)
|
||
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
|
||
})
|
||
|
||
test('Gekündigtes Abo: die Menge wird trotzdem reduziert', async () => {
|
||
await resetSubH({
|
||
units: 2,
|
||
patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd },
|
||
stripe: { cancel_at_period_end: true },
|
||
})
|
||
const res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(storageQuantity(), 1)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.details.toUnits, 1)
|
||
assert.match(k03().text, /um 1 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/)
|
||
})
|
||
|
||
test('packages: 0 oder 11 ergibt 400, ohne Stripe-Aufruf und ohne Mail', async () => {
|
||
await resetSubH({ units: 3 })
|
||
const before = st.calls.length
|
||
for (const packages of [0, 11, '0', '11', '1,5', 'x']) {
|
||
const res = await cancel({ contract: 'zusatzspeicher', packages })
|
||
assert.equal(res.status, 400, String(packages))
|
||
assert.match(res.body.error, /1 bis 10/)
|
||
}
|
||
assert.equal(st.calls.length, before)
|
||
assert.deepEqual(mails.sent, [])
|
||
assert.deepEqual(await listContractEvents('c1'), [])
|
||
// Beim Abo zaehlt das Feld nicht (im Formular ausgeblendet)
|
||
assert.equal((await cancel({ contract: 'abo', packages: '0' })).status, 200)
|
||
})
|
||
|
||
test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Änderung', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const before = st.calls.length
|
||
const res = await cancel({ contract: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200)
|
||
assert.equal(st.calls.filter((c) => c.method === 'POST').length, st.calls.slice(0, before).filter((c) => c.method === 'POST').length)
|
||
// K03 sagt "wir pruefen", T08 sagt dem Team, dass kein Speicher gebucht ist; Ereignis als Aufgabe (offen)
|
||
assert.match(k03().text, /prüfen/)
|
||
assert.doesNotMatch(k03().text, /endet zum|verringert/)
|
||
assert.match(t08().text, /kein Zusatzspeicher gebucht/i)
|
||
// § 312k Abs. 4 BGB: die Bestaetigung nennt den Inhalt der Erklaerung, also auch die Paketzahl
|
||
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
|
||
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.status, 'offen')
|
||
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 0, toUnits: null })
|
||
assert.equal(t13(), null)
|
||
mails.sent.length = 0
|
||
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
|
||
assert.match(k03().text, /Pakete: 2 Paket\(e\)/)
|
||
assert.match(t08().text, /Pakete: 2 Paket\(e\)/)
|
||
})
|
||
|
||
test('Unbekannte E-Mail: 200, keine Änderung', async () => {
|
||
await resetSubH({ units: 1 })
|
||
const before = st.calls.length
|
||
const res = await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'abo' })
|
||
assert.equal(res.status, 200)
|
||
assert.equal(st.calls.length, before)
|
||
assert.match(k03().text, /ordnen Ihre Kündigung/)
|
||
assert.match(t08().text, /KEIN aktives Abo/)
|
||
assert.deepEqual(await listContractEvents('c1'), [])
|
||
// Auch beim Zusatzspeicher
|
||
mails.sent.length = 0
|
||
assert.equal((await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'zusatzspeicher' })).status, 200)
|
||
assert.equal(st.calls.length, before)
|
||
assert.match(k03().text, /ordnen Ihre Kündigung/)
|
||
assert.match(k03().text, /E-Mail-Zusatzspeicher/)
|
||
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
|
||
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
|
||
})
|
||
|
||
test('Kündigung des Abos: wie bisher bei Stripe, dazu das Ereignis kuendigung mit dem Abo-Namen', async () => {
|
||
await resetSubH({ units: 2 })
|
||
const before = st.calls.length
|
||
const res = await cancel({ contract: 'abo', customerNumber: 'KD-1' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const upd = subscriptionUpdates(before)
|
||
assert.equal(upd.length, 1)
|
||
assert.equal(upd[0].body['metadata[cancelSource]'], 'kuendigungsbutton')
|
||
assert.ok(upd[0].body.cancel_at_period_end === 'true' || upd[0].body.cancel_at, 'Kuendigung bei Stripe')
|
||
assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt)
|
||
// Der Speicher bleibt (er endet mit dem Abo)
|
||
assert.equal(storageQuantity(), 2)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.status, 'erledigt')
|
||
assert.equal(ev.details.contract, 'abo')
|
||
assert.equal(ev.details.kind, 'ordentlich')
|
||
assert.ok(ev.details.effectiveAt)
|
||
assert.equal(ev.productKey, 'hosting')
|
||
assert.deepEqual(await events('speicher'), [])
|
||
// Die Bestaetigung nennt das tatsaechliche Abo (Spec 4.5)
|
||
assert.match(k03().text, /Vertrag: WEBklar Hosting-Abo/)
|
||
assert.match(k03().text, /Ihr Abo endet zum/)
|
||
assert.match(t08().text, /Vertrag: WEBklar Hosting-Abo/)
|
||
assert.match(t08().text, /Automatisch gekündigt/)
|
||
// Standard ohne contract: das Abo
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await cancel({})).status, 200)
|
||
assert.equal((await events('kuendigung'))[0].details.contract, 'abo')
|
||
})
|
||
|
||
test('Außerordentliche Kündigung des Abos: keine Stripe-Änderung, Ereignis offen fürs Team', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const before = st.calls.length
|
||
assert.equal((await cancel({ kind: 'ausserordentlich', reason: 'Grund' })).status, 200)
|
||
assert.deepEqual(subscriptionUpdates(before), [])
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.status, 'offen')
|
||
assert.deepEqual(ev.details, { contract: 'abo', kind: 'ausserordentlich', effectiveAt: null })
|
||
assert.match(k03().text, /Wir prüfen Ihre Kündigung/)
|
||
})
|
||
|
||
// Controller: waehrend ein Kartenwechsel aussteht (offenes Ereignis wechsel, Stripe pending_update) darf die
|
||
// Verringerung das Stripe-Abo nicht anfassen - Stripe wuerde die Wechselrechnung stornieren
|
||
test('Wechsel ausstehend (Karte): Speicher nicht bei Stripe geändert, K03 „wir prüfen“, T08 mit Hinweis', async () => {
|
||
const switchedAt = new Date().toISOString()
|
||
const openSwitch = () =>
|
||
recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
|
||
consent, status: 'offen',
|
||
})
|
||
// Ausloeser 1: nur das offene Ereignis wechsel (Stripe traegt noch kein pending_update, z. B. Webhook-Rueckstand)
|
||
await resetSubH({ units: 2 })
|
||
await openSwitch()
|
||
let before = st.calls.length
|
||
let res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
|
||
assert.equal(storageQuantity(), 2)
|
||
assert.deepEqual(await events('speicher'), [])
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.status, 'offen')
|
||
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: 1, fromUnits: 2, toUnits: null })
|
||
assert.match(k03().text, /prüfen/)
|
||
assert.match(k03().text, /Pakete: 1 Paket\(e\)/)
|
||
assert.match(t08().text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/)
|
||
assert.match(t08().text, /Pakete: 1 Paket\(e\)/)
|
||
assert.equal((await events('wechsel'))[0].status, 'offen')
|
||
|
||
// Ausloeser 2: nur pending_update am Stripe-Abo (Ereignis nicht gespeichert, nachweis_fehlt beim Wechsel)
|
||
await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } })
|
||
before = st.calls.length
|
||
res = await cancel({ contract: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
|
||
assert.equal(storageQuantity(), 2)
|
||
assert.match(t08().text, /Wechsel ausstehend/)
|
||
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
|
||
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
|
||
|
||
// Beide Ausloeser weg: Verringerung laeuft
|
||
await resetSubH({ units: 2 })
|
||
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '1' })).status, 200)
|
||
assert.equal(storageQuantity(), 1)
|
||
})
|
||
|
||
// F20: wirft changeStorage trotzdem (z. B. Stripe lehnt ab), bleibt die Seite bei 200 - K03 "wir pruefen", T08 Hinweis
|
||
test('changeStorage scheitert: 200, K03 „wir prüfen“, T08 nennt den Fehler', async () => {
|
||
await resetSubH({ units: 2 })
|
||
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau lehnt ab' })
|
||
const res = await cancel({ contract: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(storageQuantity(), 2)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.status, 'offen')
|
||
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 2, toUnits: null })
|
||
assert.match(k03().text, /prüfen/)
|
||
assert.match(t08().text, /nicht automatisch verringert/i)
|
||
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
|
||
assert.match(t08().text, /Stripe/)
|
||
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
|
||
assert.equal((await events('speicher'))[0].status, 'gescheitert')
|
||
})
|
||
|
||
// Sind die Ereignisse nicht lesbar (Wechsel-Pruefung unmoeglich), bleibt Stripe unangetastet: "wir pruefen" + T08
|
||
test('Ereignisse nicht lesbar: Speicher nicht angefasst, K03 „wir prüfen“, T08 nennt den Fehler', async () => {
|
||
await resetSubH({ units: 2 })
|
||
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
|
||
const before = st.calls.length
|
||
const res = await cancel({ contract: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(subscriptionUpdates(before), [])
|
||
assert.equal(storageQuantity(), 2)
|
||
assert.match(k03().text, /prüfen/)
|
||
assert.match(t08().text, /NICHT automatisch verringert/)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.status, 'offen')
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Widerruf: Bestellung waehlen (Abschluss, Wechsel, Zusatzspeicher)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Preiswechsel', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const consentAt = daysAgo(3)
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||
})
|
||
const before = st.calls.length
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const evs = await listContractEvents('c1')
|
||
const wechsel = evs.find((e) => e.type === 'wechsel')
|
||
assert.equal(wechsel.status, 'widerrufen')
|
||
assert.equal(st.calls.slice(before).filter((c) => c.method === 'POST' && /subscriptions/.test(c.path)).length, 0)
|
||
const widerruf = evs.find((e) => e.type === 'widerruf')
|
||
assert.equal(widerruf.details.order, 'wechsel')
|
||
assert.equal(widerruf.details.eventId, wechsel.$id)
|
||
assert.deepEqual(widerruf.details.eventIds, [wechsel.$id])
|
||
assert.equal(widerruf.status, 'offen')
|
||
assert.equal(widerruf.$id.length, 36)
|
||
assert.equal(widerruf.consentIp ?? null, null)
|
||
// Das Abo wird nicht gekuendigt; withdrawalRequestedAt bleibt der Abo-Kuendigung vorbehalten
|
||
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
|
||
assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined)
|
||
// K04 nennt die Bestellung, T09 die Handgriffe und die Frist
|
||
assert.match(k04().text, /Wechsel ins Wartungs-Abo/)
|
||
assert.match(k04().text, /zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben/)
|
||
assert.doesNotMatch(k04().text, /keine weiteren Abbuchungen/)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
assert.match(t09().text, /Preis .*zurück/)
|
||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(consentAt).endsAt)))
|
||
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
|
||
})
|
||
|
||
// Terminplaner (Dokument wf_termine) gehoert zum Wartungs-Abo: der Widerruf des Wechsels beendet ihn sofort (E9, endeTermineFuerKunde)
|
||
const TERMINE = 'wf_termine'
|
||
const termine = () => aw.docs('portalFeatures').get(TERMINE)
|
||
const k22 = () => mails.sent.filter((m) => /^Dein Terminplaner wurde beendet/.test(m.subject))
|
||
const k23 = () => mails.sent.filter((m) => /^Dein Terminplaner läuft wieder/.test(m.subject))
|
||
const t19 = () => mails.sent.filter((m) => m.subject.startsWith('[Abo] Erstattung fällig'))
|
||
const aufraeumen = async () => {
|
||
for (const id of [TERMINE, 'wf_feat']) await deleteDocument(config.collections.portalFeatures, id).catch(() => {})
|
||
await deleteDocument('workorders', 't_wf').catch(() => {})
|
||
}
|
||
/** Fuehrt fn mit einem Terminplaner-Dokument (Vorgabe: aktiv, bezahlt) von c1 aus und raeumt danach auf (andere Tests sehen es nie). */
|
||
async function mitTermine(over, fn) {
|
||
aw.seed('portalFeatures', {
|
||
$id: TERMINE, customerId: 'c1', projectId: 'wf_pt', featureKey: 'termine', state: 'active', enabled: true,
|
||
unlockedByPurchase: true, purchaseStatus: 'paid', siteKey: 'wftermine01234567', ...over,
|
||
})
|
||
try {
|
||
return await fn()
|
||
} finally {
|
||
await aufraeumen()
|
||
}
|
||
}
|
||
/** Bezahlter Aufbau: setup, Sitzung cs_test_1, Rechnungslink, offenes Ticket WO-4711. */
|
||
const AUFBAU = { state: 'setup', enabled: false, stripeSessionId: 'cs_test_1', stripeInvoiceId: 'in_1', invoiceUrl: 'https://invoice.stripe.test/in_1', ticketId: 't_wf' }
|
||
const legeTicket = () => aw.seed('workorders', { $id: 't_wf', woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen' })
|
||
const T19_BETREFF = '[Abo] Erstattung fällig: Terminplaner-Einrichtung'
|
||
/** Der Spiegel steht auf Wartung (so war es nach dem Wechsel): der Tagesabgleich darf den Widerruf nur ueber die Wechsel-Regel kennen. */
|
||
const wartungSpiegel = () => resetSubH({ units: 0, patch: { productKey: 'wartung' } })
|
||
|
||
test('Widerruf Wechsel in der Frist beendet den bezahlten Terminplaner (disabled/abo_ende, K22 Hosting); die Analyse verhält sich wie zuvor', async () => {
|
||
await wartungSpiegel()
|
||
await wechselIn(3)
|
||
await withActiveAnalyse(async (stops) => {
|
||
await mitTermine({}, async () => {
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const d = termine()
|
||
assert.deepEqual([d.state, d.enabled, d.disabledReason, d.adminNote], ['disabled', false, 'abo_ende', 'Abgeschaltet: Wechsel widerrufen'])
|
||
assert.ok(Math.abs(Date.parse(d.disabledAt) - Date.now()) < 10000)
|
||
assert.ok(d.aboEndeMailAt, 'Marker nach K22')
|
||
assert.equal(k22().length, 1)
|
||
assert.deepEqual(k22()[0].to.map((a) => a.address), ['k@example.invalid'])
|
||
assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/)
|
||
assert.equal(t19().length, 0)
|
||
// Analyse wie bisher: Messdienst aus, off, K19
|
||
assert.deepEqual(stops, ['wfsitekey0123456789'])
|
||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'off')
|
||
assert.ok(bySubject('Deine Website-Analyse wurde beendet'))
|
||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||
assert.ok(t09() && k04(), 'K04 und T09 wie immer')
|
||
})
|
||
})
|
||
})
|
||
|
||
test('Widerruf Wechsel: scheitert die Abschaltung des Terminplaners, bleibt der Widerruf 200; der Tagesabgleich holt es über die Wechsel-Regel nach', async (t) => {
|
||
const log = t.mock.method(console, 'error', () => {})
|
||
await wartungSpiegel()
|
||
await wechselIn(3)
|
||
await mitTermine({}, async () => {
|
||
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||
assert.ok((await events('widerruf'))[0], 'Widerrufsereignis gespeichert')
|
||
assert.equal(termine().state, 'active', 'nicht abgeschaltet')
|
||
assert.equal(k22().length, 0)
|
||
assert.ok(log.mock.calls.some((c) => /Sofortpfad/.test(String(c.arguments[0]))), 'der Fehler steht im Log')
|
||
assert.ok(k04() && t09())
|
||
// der Abgleich am Folgetag schaltet ab (Spiegel Wartung, aber der Wechsel ist widerrufen) und sendet K22
|
||
const lauf = await gleicheAboAb(new Date())
|
||
assert.equal(lauf.abgeschaltet, 1)
|
||
assert.equal(termine().disabledReason, 'abo_ende')
|
||
assert.equal(k22().length, 1)
|
||
})
|
||
})
|
||
|
||
test('Widerruf Wechsel: Pilot- und Kulanz-Terminplaner (ohne Kauf) bleiben aktiv', async () => {
|
||
await wartungSpiegel()
|
||
await wechselIn(3)
|
||
await mitTermine({ unlockedByPurchase: false, purchaseStatus: '' }, async () => {
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.equal(termine().state, 'active')
|
||
assert.equal(k22().length, 0)
|
||
})
|
||
})
|
||
|
||
test('Widerruf Wechsel, bezahlter Aufbau: abo_ende_vor_einrichtung, genau ein T19 (Ticket offen), K22 vor_einrichtung, Marker, gemeldet; der Abgleich sendet danach nichts mehr', async () => {
|
||
await wartungSpiegel()
|
||
await wechselIn(3)
|
||
legeTicket()
|
||
await mitTermine(AUFBAU, async () => {
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const d = termine()
|
||
assert.deepEqual([d.state, d.disabledReason, d.purchaseStatus, d.zahlungMeldung, d.stripeSessionId], ['disabled', 'abo_ende_vor_einrichtung', 'paid', 'gemeldet', 'cs_test_1'])
|
||
assert.ok(d.aboEndeMailAt)
|
||
assert.equal(t19().length, 1)
|
||
assert.match(t19()[0].text, /Ticket noch offen/)
|
||
assert.match(t19()[0].text, /WO-4711/)
|
||
assert.match(t19()[0].text, /cs_test_1/)
|
||
assert.equal(k22().length, 1)
|
||
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
|
||
assert.deepEqual(mails.sent.filter((m) => m.subject === T19_BETREFF || /^Dein Terminplaner wurde beendet/.test(m.subject)).map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22')
|
||
assert.equal(aw.docs('workorders').get('t_wf').status, 'Open', 'das Ticket bleibt')
|
||
|
||
const vorher = { ...termine() }
|
||
const anzahl = mails.sent.length
|
||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||
assert.deepEqual(lauf, { geprueft: 1, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 })
|
||
assert.deepEqual(termine(), vorher)
|
||
assert.equal(mails.sent.length, anzahl, 'weder T19 noch K22 noch K23')
|
||
})
|
||
})
|
||
|
||
test('Widerruf Wechsel, unbezahltes ordered: abgeschaltet ohne T19, K22 und Marker; die spätere Zahlung meldet T19 genau einmal, der Abgleich K22', async () => {
|
||
await wartungSpiegel()
|
||
await wechselIn(3)
|
||
await mitTermine({ state: 'ordered', enabled: false, unlockedByPurchase: false, purchaseStatus: 'ordered', stripeSessionId: 'cs_test_1' }, async () => {
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
let d = termine()
|
||
assert.deepEqual([d.state, d.disabledReason, d.stripeSessionId], ['disabled', 'abo_ende_vor_einrichtung', 'cs_test_1'])
|
||
assert.ok(!d.aboEndeMailAt && !d.zahlungMeldung, 'kein Marker')
|
||
assert.equal(k22().length + t19().length, 0)
|
||
|
||
await handleFeatureCheckoutEvent({
|
||
type: 'checkout.session.completed',
|
||
data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: 'c1', projectId: 'wf_pt', featureKey: 'termine' } } },
|
||
})
|
||
assert.equal(t19().length, 1)
|
||
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/)
|
||
assert.equal(k22().length, 0)
|
||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||
assert.equal(lauf.mails, 1)
|
||
assert.equal(t19().length, 1, 'kein zweites T19')
|
||
assert.equal(k22().length, 1)
|
||
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
|
||
assert.equal(termine().state, 'disabled')
|
||
})
|
||
})
|
||
|
||
test('Widerruf Wechsel, bezahlter Aufbau: scheitert T19, bleibt der Widerruf 200, K22 und Marker fehlen; der Abgleich am Folgetag sendet T19 und K22 nach', async (t) => {
|
||
t.mock.method(console, 'error', () => {})
|
||
await wartungSpiegel()
|
||
await wechselIn(3)
|
||
legeTicket()
|
||
await mitTermine(AUFBAU, async () => {
|
||
setMailTransportForTests({
|
||
sendMail: async (mail) => {
|
||
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
|
||
return mails.sendMail(mail)
|
||
},
|
||
})
|
||
try {
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
} finally {
|
||
setMailTransportForTests(mails)
|
||
}
|
||
const d = termine()
|
||
assert.deepEqual([d.state, d.disabledReason], ['disabled', 'abo_ende_vor_einrichtung'])
|
||
assert.ok(!d.aboEndeMailAt && !d.zahlungMeldung)
|
||
assert.equal(t19().length + k22().length, 0)
|
||
assert.ok(k04() && t09(), 'K04 und T09 gehen trotzdem')
|
||
|
||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||
assert.equal(lauf.mails, 1)
|
||
assert.equal(t19().length, 1)
|
||
assert.equal(k22().length, 1)
|
||
assert.equal(termine().zahlungMeldung, 'gemeldet')
|
||
assert.ok(termine().aboEndeMailAt)
|
||
})
|
||
})
|
||
|
||
test('Kein Flattern: nach dem Sofortpfad (Spiegel Wartung/active, Wechsel widerrufen) bleibt der Terminplaner abgeschaltet, kein K23, kein zweites K22', async () => {
|
||
await wartungSpiegel()
|
||
await wechselIn(3)
|
||
await mitTermine({}, async () => {
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.equal(termine().state, 'disabled')
|
||
assert.equal(k22().length, 1)
|
||
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung')
|
||
assert.equal(aw.docs('subscriptions').get('sub_h').status, 'active')
|
||
const vorher = { ...termine() }
|
||
for (const tage of [1, 2, 40]) {
|
||
const lauf = await gleicheAboAb(new Date(Date.now() + tage * DAY_MS))
|
||
assert.equal(lauf.wiederEin + lauf.abgeschaltet + lauf.mails + lauf.fehler, 0, `Tag ${tage}`)
|
||
}
|
||
assert.deepEqual(termine(), vorher)
|
||
assert.equal(k23().length, 0)
|
||
assert.equal(k22().length, 1)
|
||
})
|
||
})
|
||
|
||
// Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19)
|
||
async function withActiveAnalyse(fn) {
|
||
const stops = []
|
||
const real = analyseDeps.client
|
||
analyseDeps.client = { stopSite: async (k) => { stops.push(k) } }
|
||
aw.seed('portalFeatures', { $id: 'wf_feat', customerId: 'c1', projectId: 'wf_p', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wfsitekey0123456789' })
|
||
try {
|
||
return await fn(stops)
|
||
} finally {
|
||
analyseDeps.client = real
|
||
}
|
||
}
|
||
const wechselIn = async (days) => {
|
||
const consentAt = daysAgo(days)
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||
})
|
||
}
|
||
|
||
test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, off, K19); Fehler lässt den Widerruf auf 200', async () => {
|
||
await resetSubH({ units: 0 })
|
||
await wechselIn(3)
|
||
await withActiveAnalyse(async (stops) => {
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200)
|
||
assert.deepEqual(stops, ['wfsitekey0123456789'])
|
||
const d = aw.docs('portalFeatures').get('wf_feat')
|
||
assert.equal(d.state, 'off')
|
||
assert.ok(d.offSince)
|
||
assert.ok(bySubject('Deine Website-Analyse wurde beendet'))
|
||
})
|
||
// wirft der Messdienst, bleibt der Widerruf 200
|
||
await resetSubH({ units: 0 })
|
||
await wechselIn(3)
|
||
await withActiveAnalyse(async () => {
|
||
analyseDeps.client = { stopSite: async () => { throw new Error('Messdienst weg') } }
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200)
|
||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||
})
|
||
})
|
||
|
||
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse und den Terminplaner nicht', async () => {
|
||
await resetSubH({ units: 0 })
|
||
await wechselIn(30)
|
||
await withActiveAnalyse(async (stops) => {
|
||
await mitTermine({}, async () => {
|
||
const vorher = { ...termine() }
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||
assert.deepEqual(stops, [])
|
||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
|
||
assert.deepEqual(termine(), vorher, 'Terminplaner unveraendert')
|
||
assert.equal(k22().length + t19().length, 0, 'weder K22 noch T19')
|
||
})
|
||
})
|
||
})
|
||
|
||
test('Widerruf nach Fristende: 200, nichts geändert', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const consentAt = daysAgo(30)
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt },
|
||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||
})
|
||
const before = st.calls.length
|
||
const res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200)
|
||
const evs = await listContractEvents('c1')
|
||
assert.notEqual(evs.find((e) => e.type === 'wechsel').status, 'widerrufen')
|
||
assert.equal(evs.find((e) => e.type === 'widerruf').details.eventId, null)
|
||
assert.deepEqual(evs.find((e) => e.type === 'widerruf').details.eventIds, [])
|
||
assert.equal(subscriptionUpdates(before).length, 0)
|
||
assert.match(k04().text, /prüfen/)
|
||
assert.match(t09().text, /keine offene Frist gefunden/)
|
||
assert.match(t09().text, /NACH Fristende/)
|
||
})
|
||
|
||
// F03: mehrere Speicher-Bestellungen (Plus) in der Frist -> alle widerrufen; ein Minus hat keine Frist
|
||
test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerrufen, T09 listet sie', async () => {
|
||
await resetSubH({ units: 3 })
|
||
const plus1 = await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
|
||
details: { kind: 'einrichten', fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt: daysAgo(5) }, status: 'erledigt',
|
||
})
|
||
const minus = await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
|
||
details: { kind: 'periodenende', fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd,
|
||
})
|
||
const plus2 = await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
|
||
details: { kind: 'einrichten', fromUnits: 1, toUnits: 3 }, consent: { ...consent, consentAt: daysAgo(1) }, status: 'offen', dueAt: daysAgo(1),
|
||
})
|
||
const before = st.calls.length
|
||
const res = await withdraw({ order: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const evs = await listContractEvents('c1')
|
||
const statusOf = (id) => evs.find((e) => e.$id === id).status
|
||
assert.equal(statusOf(plus1.$id), 'widerrufen')
|
||
assert.equal(statusOf(plus2.$id), 'widerrufen')
|
||
assert.equal(statusOf(minus.$id), 'offen')
|
||
const widerruf = evs.find((e) => e.type === 'widerruf')
|
||
assert.equal(widerruf.details.order, 'zusatzspeicher')
|
||
assert.deepEqual([...widerruf.details.eventIds].sort(), [plus1.$id, plus2.$id].sort())
|
||
assert.ok([plus1.$id, plus2.$id].includes(widerruf.details.eventId))
|
||
assert.equal(subscriptionUpdates(before).length, 0)
|
||
assert.match(k04().text, /E-Mail-Zusatzspeicher/)
|
||
assert.match(k04().text, /Menge/)
|
||
assert.match(t09().text, /0 → 2/)
|
||
assert.match(t09().text, /1 → 3/)
|
||
assert.match(t09().text, /Menge .*zurück/)
|
||
})
|
||
|
||
test('Widerruf Funktion: nichts automatisch, K04 „wir prüfen“, T09 nennt Funktion, Projekt und Fristende', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const orderedAt = daysAgo(3)
|
||
aw.seed('websiteProjects', { $id: 'pf1', customerId: 'c1', projectName: 'Bäckerei Funktion' })
|
||
aw.seed('portalFeatures', { $id: 'f_pf1_news', customerId: 'c1', projectId: 'pf1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt })
|
||
const before = st.calls.length
|
||
const res = await withdraw({ order: 'funktion' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
|
||
assert.deepEqual(await listContractEvents('c1'), [], 'kein Vertragsereignis, kein Abo betroffen')
|
||
assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined)
|
||
assert.equal(aw.docs('portalFeatures').get('f_pf1_news').state, 'setup')
|
||
assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/)
|
||
assert.match(k04().text, /Wir prüfen Ihren Widerruf/)
|
||
assert.doesNotMatch(k04().text, /Es erfolgen keine weiteren Abbuchungen/)
|
||
const team = t09().text
|
||
assert.match(team, /Funktion \(z\. B\. News-Pinnwand\)/)
|
||
assert.match(team, /NICHTS automatisch geändert/)
|
||
assert.match(team, /Bäckerei Funktion: in Einrichtung/)
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)))
|
||
assert.match(team, /RECHTZEITIG/)
|
||
assert.doesNotMatch(team, /KEIN aktives Abo/)
|
||
// Der Nachbau liefert aus docs() eine Kopie: geloescht wird ueber die API, sonst bliebe die Bestellung fuer die naechsten Tests
|
||
await deleteDocument('portalFeatures', 'f_pf1_news')
|
||
})
|
||
|
||
/** Zustimmungsnachweis zu einer Sitzung (featureConsents) fuer die Fristanker-Tests. */
|
||
function seedZustimmung(id, { projectId = 'pa1', stripeSessionId, acceptedAt, featureKey = 'termine' }) {
|
||
aw.seed('featureConsents', {
|
||
$id: id,
|
||
customerId: 'c1',
|
||
projectId,
|
||
featureKey,
|
||
stripeSessionId,
|
||
acceptedAt,
|
||
fassungen: '{}',
|
||
texte: '[]',
|
||
textHash: '',
|
||
})
|
||
}
|
||
|
||
/** Bezahlte Funktion mit Zustimmung zur bezahlten Sitzung (vertragAt) und neuerer, unbezahlter Zustimmung derselben Funktion. */
|
||
function seedBezahlteBestellung({ klickAt, orderedAt = daysAgo(1) }) {
|
||
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
|
||
aw.seed('portalFeatures', {
|
||
$id: 'f_pa1_termine',
|
||
customerId: 'c1',
|
||
projectId: 'pa1',
|
||
featureKey: 'termine',
|
||
state: 'setup',
|
||
enabled: false,
|
||
unlockedByPurchase: true,
|
||
// durch eine Neubestellung ueberschrieben: spaeter als der Klick der bezahlten Sitzung
|
||
orderedAt,
|
||
stripeSessionId: 'cs_bezahlt',
|
||
})
|
||
seedZustimmung('fc_bezahlt', { stripeSessionId: 'cs_bezahlt', acceptedAt: klickAt })
|
||
// spaetere, unbezahlte Zustimmung desselben Kunden und Projekts (andere Sitzung): verschiebt den Anker nicht
|
||
seedZustimmung('fc_offen', { stripeSessionId: 'cs_offen', acceptedAt: daysAgo(0.5) })
|
||
}
|
||
|
||
async function raeumeAnkerTest() {
|
||
await deleteDocument('portalFeatures', 'f_pa1_termine')
|
||
for (const id of ['fc_bezahlt', 'fc_offen']) if (aw.docs('featureConsents').has(id)) await deleteDocument('featureConsents', id)
|
||
}
|
||
|
||
test('Widerruf Terminplaner: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const klickAt = daysAgo(13)
|
||
seedBezahlteBestellung({ klickAt })
|
||
try {
|
||
const res = await withdraw({ order: 'termine' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const team = t09().text
|
||
// Frist aus dem Klick der bezahlten Sitzung, nicht aus orderedAt (vor 1 Tag) und nicht aus der spaeteren Zustimmung
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
|
||
assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt')
|
||
assert.match(team, /RECHTZEITIG/)
|
||
assert.doesNotMatch(team, /NACH Fristende/)
|
||
assert.match(team, /Studio Anker: in Einrichtung/)
|
||
} finally {
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
test('Widerruf Terminplaner: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const klickAt = daysAgo(20)
|
||
seedBezahlteBestellung({ klickAt })
|
||
try {
|
||
const res = await withdraw({ order: 'termine' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const team = t09().text
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
|
||
assert.match(team, /NACH Fristende/)
|
||
assert.doesNotMatch(team, /RECHTZEITIG/)
|
||
} finally {
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
test('Widerruf Terminplaner: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
|
||
await resetSubH({ units: 0 })
|
||
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
|
||
const base = { customerId: 'c1', projectId: 'pa1', featureKey: 'termine', state: 'setup', enabled: false, unlockedByPurchase: true, stripeSessionId: 'cs_ohne_nachweis' }
|
||
try {
|
||
// consentAt (Altbestand, News) vor orderedAt
|
||
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(1), consentAt: daysAgo(20) })
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(20)).endsAt)))
|
||
assert.match(t09().text, /NACH Fristende/)
|
||
|
||
// weder Nachweis noch consentAt: orderedAt
|
||
mails.sent.length = 0
|
||
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(3), consentAt: null })
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(3)).endsAt)))
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
} finally {
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
test('Widerruf Terminplaner: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const orderedAt = daysAgo(2)
|
||
seedBezahlteBestellung({ klickAt: daysAgo(20), orderedAt })
|
||
const warn = console.warn
|
||
const warnungen = []
|
||
console.warn = (...args) => warnungen.push(args.join(' '))
|
||
try {
|
||
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500 })
|
||
const res = await withdraw({ order: 'termine' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const team = t09().text
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)), team)
|
||
assert.match(team, /RECHTZEITIG/)
|
||
assert.ok(warnungen.some((w) => w.includes('Zustimmungsnachweis')), 'Rueckfall wird geloggt')
|
||
assert.ok(k04(), 'K04 geht trotzdem an den Kunden')
|
||
} finally {
|
||
console.warn = warn
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
/** Bezahltes Funktionsdokument (Projekt wird mit angelegt) fuer die Tests je Funktion. */
|
||
function seedBezahlt({ projectId, projektName, featureKey, orderedAt, state = 'setup' }) {
|
||
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: projektName })
|
||
aw.seed('portalFeatures', {
|
||
$id: `f_${projectId}_${featureKey}`,
|
||
customerId: 'c1',
|
||
projectId,
|
||
featureKey,
|
||
state,
|
||
enabled: false,
|
||
unlockedByPurchase: true,
|
||
orderedAt,
|
||
})
|
||
}
|
||
|
||
async function raeumeBezahlt(...ids) {
|
||
for (const id of ids) if (aw.docs('portalFeatures').has(id)) await deleteDocument('portalFeatures', id)
|
||
}
|
||
|
||
test('widerruf_termine_frist_je_funktion: Terminplaner vor 3 Tagen, News vor 20 Tagen bestellt; jede Wahl rechnet mit der eigenen Funktion', async () => {
|
||
const newsAt = daysAgo(20)
|
||
const termineAt = daysAgo(3)
|
||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' })
|
||
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: termineAt })
|
||
try {
|
||
await resetSubH({ units: 0 })
|
||
const before = st.calls.length
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
const termine = t09().text
|
||
assert.match(termine, /RECHTZEITIG/)
|
||
assert.doesNotMatch(termine, /NACH Fristende/)
|
||
assert.ok(termine.includes(fmtDate(withdrawalDeadline(termineAt).endsAt)), 'Fristende des Terminplaners')
|
||
assert.match(termine, /Studio Termine: in Einrichtung/)
|
||
assert.doesNotMatch(termine, /Bäckerei News/, 'die News-Bestellung gehört nicht zu dieser Wahl')
|
||
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
|
||
assert.equal(aw.docs('portalFeatures').get('f_pt1_termine').state, 'setup', 'nichts automatisch geändert')
|
||
|
||
// umgekehrt: Widerruf "Funktion" meint die News und rechnet mit deren Frist; der junge Terminplaner rettet sie nicht
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||
const news = t09().text
|
||
assert.match(news, /NACH Fristende/)
|
||
assert.doesNotMatch(news, /RECHTZEITIG/)
|
||
assert.ok(news.includes(fmtDate(withdrawalDeadline(newsAt).endsAt)), 'Fristende der News')
|
||
assert.match(news, /Bäckerei News: aktiv/)
|
||
assert.doesNotMatch(news, /Studio Termine/)
|
||
} finally {
|
||
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine')
|
||
}
|
||
})
|
||
|
||
test('widerruf_termine_frist_je_funktion: mehrere Projekte einer Funktion - spätestes Fristende, jede Zeile mit eigenem Fristende', async () => {
|
||
const alt = daysAgo(20)
|
||
const jung = daysAgo(3)
|
||
seedBezahlt({ projectId: 'pn1', projektName: 'Alt News', featureKey: 'news', orderedAt: alt, state: 'active' })
|
||
seedBezahlt({ projectId: 'pn2', projektName: 'Jung News', featureKey: 'news', orderedAt: jung })
|
||
try {
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||
const team = t09().text
|
||
// bisheriges Verhalten: das Maximum der Fristenden entscheidet
|
||
assert.match(team, /RECHTZEITIG/)
|
||
assert.ok(team.includes(`${fmtDate(withdrawalDeadline(jung).endsAt)}, 24 Uhr`), 'Fristende = spätestes')
|
||
assert.match(team, new RegExp(`Alt News: aktiv, bestellt am ${fmtDate(alt)}, Fristende ${fmtDate(withdrawalDeadline(alt).endsAt)}`))
|
||
assert.match(team, new RegExp(`Jung News: in Einrichtung, bestellt am ${fmtDate(jung)}, Fristende ${fmtDate(withdrawalDeadline(jung).endsAt)}`))
|
||
} finally {
|
||
await raeumeBezahlt('f_pn1_news', 'f_pn2_news')
|
||
}
|
||
})
|
||
|
||
test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 den Preis und das Projekt; bei der News unverändert', async () => {
|
||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(3) })
|
||
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: daysAgo(2) })
|
||
try {
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'termine', customerNumber: 'KD-9' })).status, 200)
|
||
const kunde = k04()
|
||
assert.match(kunde.subject, /WEBklar Terminplaner$/)
|
||
assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/)
|
||
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
|
||
assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/)
|
||
assert.match(kunde.text, /§ 357a Abs\. 2 BGB/)
|
||
assert.doesNotMatch(kunde.text, /Funktion \(z\. B\. News-Pinnwand\)|Es erfolgen keine weiteren Abbuchungen/)
|
||
const team = t09().text
|
||
assert.match(team, /Terminplaner \(Einrichtung\)/)
|
||
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.termine.label.name}, ${config.features.termine.priceLabel} einmalig)`), team)
|
||
assert.match(team, /149 €/)
|
||
assert.match(team, /Studio Termine: in Einrichtung/)
|
||
assert.match(team, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/)
|
||
assert.match(team, /NICHTS automatisch geändert/)
|
||
|
||
// "Funktion (z. B. News-Pinnwand)" bleibt wie bisher: News-Pinnwand, 30 €
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||
assert.match(k04().subject, /WEBklar News-Pinnwand$/)
|
||
assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/)
|
||
assert.match(k04().text, /Wir prüfen Ihren Widerruf/)
|
||
assert.match(t09().text, /Funktion \(z\. B\. News-Pinnwand\)/)
|
||
assert.ok(t09().text.includes(`Widerruf einer Funktion (${config.features.news.label.name}, ${config.features.news.priceLabel} einmalig)`), t09().text)
|
||
assert.match(t09().text, /Bäckerei News: in Einrichtung/)
|
||
assert.doesNotMatch(t09().text, /Studio Termine/)
|
||
} finally {
|
||
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine')
|
||
}
|
||
})
|
||
|
||
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)
|
||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' })
|
||
seedBezahlt({ projectId: 'pz1', projektName: 'Praxis Zeiten', featureKey: 'zeiten', orderedAt: zeitenAt })
|
||
try {
|
||
await resetSubH({ units: 0 })
|
||
const before = st.calls.length
|
||
assert.equal((await withdraw({ order: 'zeiten', customerNumber: 'KD-9' })).status, 200)
|
||
const kunde = k04()
|
||
assert.match(kunde.subject, /WEBklar Öffnungszeiten live$/)
|
||
assert.match(kunde.text, /Widerruf der Bestellung „Öffnungszeiten live“/)
|
||
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
|
||
assert.doesNotMatch(kunde.text, /News-Pinnwand|Es erfolgen keine weiteren Abbuchungen/)
|
||
const team = t09().text
|
||
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.zeiten.label.name}, ${config.features.zeiten.priceLabel} einmalig)`), team)
|
||
assert.match(team, /Öffnungszeiten live/)
|
||
assert.match(team, /RECHTZEITIG/, 'Frist der Zeiten (vor 3 Tagen), nicht die der News (vor 20 Tagen)')
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(zeitenAt).endsAt)), 'Fristende der Zeiten')
|
||
assert.match(team, /Praxis Zeiten: in Einrichtung/)
|
||
assert.doesNotMatch(team, /Bäckerei News/, 'die News-Bestellung gehört nicht zu dieser Wahl')
|
||
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
|
||
assert.equal(aw.docs('portalFeatures').get('f_pz1_zeiten').state, 'setup', 'nichts automatisch geändert')
|
||
|
||
// „Funktion“ bleibt die News: ihre Frist ist abgelaufen, die jungen Zeiten retten sie nicht
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||
assert.match(t09().text, /NACH Fristende/)
|
||
assert.doesNotMatch(t09().text, /Praxis Zeiten/)
|
||
} finally {
|
||
await raeumeBezahlt('f_pn1_news', 'f_pz1_zeiten')
|
||
}
|
||
})
|
||
|
||
test('widerruf_anker_vertragAt: Zustimmung zur bezahlten Sitzung früher als das überschriebene orderedAt - Frist aus vertragAt; je Funktion gefiltert', async () => {
|
||
await resetSubH({ units: 0 })
|
||
// Terminplaner: Klick vor 13 Tagen, orderedAt (Neubestellung) vor 1 Tag; eine News vor 2 Tagen bestellt (kein Einfluss)
|
||
const klickAt = daysAgo(13)
|
||
seedBezahlteBestellung({ klickAt })
|
||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) })
|
||
try {
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
let team = t09().text
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
|
||
assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt')
|
||
assert.match(team, /RECHTZEITIG/)
|
||
|
||
// Rückfall ohne featureConsents-Eintrag: orderedAt
|
||
mails.sent.length = 0
|
||
await deleteDocument('featureConsents', 'fc_bezahlt')
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
team = t09().text
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), team)
|
||
assert.match(team, /RECHTZEITIG/)
|
||
} finally {
|
||
await raeumeAnkerTest()
|
||
await raeumeBezahlt('f_pn1_news')
|
||
}
|
||
})
|
||
|
||
test('widerruf_unbekannte_funktion: kein bezahltes Dokument dieser Funktion - ungeprüft (keine bezahlte Funktion gefunden)', async () => {
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||
assert.match(t09().text, /keine Frist gefunden/)
|
||
assert.ok(k04(), 'K04 geht trotzdem an den Kunden')
|
||
|
||
// nur eine News bezahlt, Widerruf des Terminplaners: auch dann ungeprüft statt Frist der News
|
||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) })
|
||
try {
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||
assert.doesNotMatch(t09().text, /RECHTZEITIG|NACH Fristende/)
|
||
// noch nicht bezahlt (ordered) zählt nicht
|
||
aw.seed('portalFeatures', { $id: 'f_pt9_termine', customerId: 'c1', projectId: 'pt9', featureKey: 'termine', state: 'ordered', enabled: false, unlockedByPurchase: false, orderedAt: daysAgo(1) })
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||
} finally {
|
||
await raeumeBezahlt('f_pn1_news', 'f_pt9_termine')
|
||
}
|
||
})
|
||
|
||
test('Widerruf Funktion: Dokument ohne featureKey (Altbestand) zählt als News-Pinnwand', async () => {
|
||
await resetSubH({ units: 0 })
|
||
aw.seed('websiteProjects', { $id: 'pl1', customerId: 'c1', projectName: 'Altbestand' })
|
||
aw.seed('portalFeatures', { $id: 'f_pl1_alt', customerId: 'c1', projectId: 'pl1', state: 'active', enabled: true, unlockedByPurchase: true, orderedAt: daysAgo(3) })
|
||
try {
|
||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||
assert.match(t09().text, /Altbestand: aktiv/)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
mails.sent.length = 0
|
||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||
} finally {
|
||
await raeumeBezahlt('f_pl1_alt')
|
||
}
|
||
})
|
||
|
||
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
|
||
const startedAt = daysAgo(2)
|
||
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
|
||
const before = st.calls.length
|
||
const res = await withdraw({ order: 'abschluss', customerNumber: 'KD-1' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const upd = subscriptionUpdates(before)
|
||
assert.equal(upd.length, 1)
|
||
assert.equal(upd[0].body.cancel_at_period_end, 'true')
|
||
assert.equal(upd[0].body['metadata[cancelSource]'], 'widerruf')
|
||
assert.ok(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt)
|
||
const [widerruf] = await events('widerruf')
|
||
assert.deepEqual(widerruf.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: true })
|
||
assert.match(k04().text, /Abschluss Ihres Abos/)
|
||
assert.match(k04().text, /keine weiteren Abbuchungen/)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
assert.match(t09().text, /auf Kündigung gesetzt/)
|
||
assert.match(t09().text, /Abo in Stripe sofort beenden/)
|
||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(startedAt).endsAt)))
|
||
|
||
// Standard ohne order: der Abschluss; mit Ereignis abschluss zaehlt allein das Ereignis (hier 40 Tage alt: Frist vorbei)
|
||
await resetSubH({ units: 0 })
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
|
||
details: { contractAt: hostingDoc.startedAt }, consent: { ...consent, consentAt: hostingDoc.startedAt },
|
||
})
|
||
const late = st.calls.length
|
||
assert.equal((await withdraw({})).status, 200)
|
||
assert.deepEqual(subscriptionUpdates(late), [])
|
||
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
|
||
const [w2] = await events('widerruf')
|
||
assert.deepEqual(w2.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: false })
|
||
assert.match(k04().text, /prüfen/)
|
||
assert.match(t09().text, /keine offene Frist gefunden/)
|
||
})
|
||
|
||
test('Widerruf mit unbekannter E-Mail oder ungültiger Auswahl', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const before = st.calls.length
|
||
const res = await post('/api/abo/public/withdraw', { name: 'X', email: 'niemand@example.invalid', order: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200)
|
||
assert.equal(st.calls.length, before)
|
||
assert.match(t09().text, /KEIN aktives Abo/)
|
||
assert.deepEqual(await listContractEvents('c1'), [])
|
||
// Unbekannte Auswahl faellt auf den Standard zurueck; ohne Name oder E-Mail 400
|
||
assert.equal((await withdraw({ order: 'irgendwas' })).status, 200)
|
||
assert.equal((await events('widerruf'))[0].details.order, 'abschluss')
|
||
assert.equal((await post('/api/abo/public/withdraw', { name: '', email: 'k@example.invalid' })).status, 400)
|
||
assert.equal((await post('/api/abo/public/cancel', { name: 'K', email: 'kein-mail' })).status, 400)
|
||
})
|
||
|
||
// F30: Ratenbegrenzung je IP (10 je Stunde), fuer Tests zuruecksetzbar; alte Eintraege verschwinden nach einer
|
||
// Stunde, damit IP-Adressen nicht laenger als noetig im Speicher bleiben (Datenschutz Abschnitt 7)
|
||
test('Ratenbegrenzung: 11. Anfrage 429; Reset; Einträge älter als eine Stunde werden entfernt', async () => {
|
||
await resetSubH({ units: 0 })
|
||
for (let i = 0; i < 10; i += 1) assert.equal((await cancel({})).status, 200, `Anfrage ${i + 1}`)
|
||
const limited = await cancel({})
|
||
assert.equal(limited.status, 429)
|
||
assert.equal((await withdraw({})).status, 429, 'gemeinsames Limit beider Seiten')
|
||
assert.equal(publicRateLimitState().ips, 1)
|
||
resetPublicRateLimit()
|
||
assert.equal(publicRateLimitState().ips, 0)
|
||
assert.equal((await cancel({})).status, 200)
|
||
assert.equal(publicRateLimitState().ips, 1)
|
||
// Eintraege aelter als eine Stunde werden aus der Map entfernt (Zeit im Test vorgegeben), juengere bleiben
|
||
purgeRateLimit(Date.now() + 3600 * 1000 - 5000)
|
||
assert.equal(publicRateLimitState().ips, 1)
|
||
purgeRateLimit(Date.now() + 3600 * 1000 + 1)
|
||
assert.equal(publicRateLimitState().ips, 0)
|
||
})
|
||
|
||
// Fix 1 (6): eine ausserordentliche Kuendigung des Speichers wird trotzdem zum Periodenende verringert; die Pruefung
|
||
// der Art steht in K03 und T08
|
||
test('Zusatzspeicher außerordentlich gekündigt: trotzdem zum Periodenende verringert, Prüfung angekündigt', async () => {
|
||
await resetSubH({ units: 3 })
|
||
const res = await cancel({ contract: 'zusatzspeicher', packages: '2', kind: 'ausserordentlich', reason: 'Postfach dauernd voll' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(storageQuantity(), 1)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.details.toUnits, 1)
|
||
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
|
||
assert.match(k03().text, /außerordentliche Kündigung wird geprüft/)
|
||
assert.match(t08().text, /Außerordentliche Kündigung des Zusatzspeichers/)
|
||
assert.match(t08().text, /Grund: Postfach dauernd voll/)
|
||
// ordentlich: kein Pruefungshinweis
|
||
await resetSubH({ units: 3 })
|
||
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
|
||
assert.doesNotMatch(k03().text, /wird geprüft/)
|
||
assert.doesNotMatch(t08().text, /des Zusatzspeichers: Grund prüfen/)
|
||
})
|
||
|
||
// Fix 1 (4): Ereignisse nicht lesbar - Abschluss faellt auf startedAt zurueck (F14), sonst Hinweis "von Hand pruefen"
|
||
test('Widerruf bei nicht lesbaren Ereignissen: Hinweis fürs Team, Abschluss zählt weiter ab startedAt', async () => {
|
||
await resetSubH({ units: 0 })
|
||
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
|
||
let before = st.calls.length
|
||
let res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(subscriptionUpdates(before), [])
|
||
assert.match(k04().text, /prüfen/)
|
||
assert.match(t09().text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
|
||
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art/)
|
||
assert.equal((await events('widerruf')).length, 1, 'Ereignis widerruf trotzdem gespeichert')
|
||
|
||
// Abschluss in der Frist: der Rueckfall auf startedAt gilt auch, wenn die Ereignisse nicht lesbar sind
|
||
const startedAt = daysAgo(2)
|
||
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
|
||
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
|
||
before = st.calls.length
|
||
res = await withdraw({ order: 'abschluss' })
|
||
assert.equal(res.status, 200)
|
||
assert.equal(subscriptionUpdates(before).length, 1)
|
||
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, true)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
assert.match(t09().text, /Ereignisse nicht lesbar/)
|
||
|
||
// Abo-Beginn fehlt (kein Ereignis abschluss, kein startedAt): eigener Hinweis, nichts geaendert
|
||
await resetSubH({ units: 0, patch: { startedAt: null, termStartAt: null } })
|
||
before = st.calls.length
|
||
res = await withdraw({ order: 'abschluss' })
|
||
assert.equal(res.status, 200)
|
||
assert.deepEqual(subscriptionUpdates(before), [])
|
||
assert.match(t09().text, /Abo-Beginn fehlt/)
|
||
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art|NACH Fristende/)
|
||
})
|
||
|
||
// Fix 1 (5): "NACH Fristende" nur nach dem Fristende; ein zweiter Widerruf derselben Bestellung heisst "bereits widerrufen"
|
||
test('Wiederholter Widerruf: „bereits widerrufen“ statt „NACH Fristende“', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const consentAt = daysAgo(3)
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||
})
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
mails.sent.length = 0
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.match(t09().text, /bereits widerrufen/)
|
||
assert.doesNotMatch(t09().text, /NACH Fristende|RECHTZEITIG/)
|
||
assert.match(k04().text, /prüfen/)
|
||
const widerrufe = await events('widerruf')
|
||
assert.equal(widerrufe.length, 2)
|
||
assert.equal(widerrufe[0].details.eventId, null)
|
||
})
|
||
|
||
// Fix 1 (7): Widerruf eines noch nicht wirksamen Kartenwechsels bzw. eines verspaeteten Abschluss-Widerrufs
|
||
test('T09: offene Wechselrechnung stornieren; verspäteter Abschluss-Widerruf ggf. als Kündigung', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const consentAt = daysAgo(1)
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
|
||
consent: { ...consent, consentAt }, status: 'offen',
|
||
})
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||
assert.match(t09().text, /Wechselrechnung noch offen – stornieren, damit der Wechsel nicht nachträglich wirksam wird/)
|
||
|
||
// Wirksamer (bezahlter) Wechsel: kein Storno-Hinweis
|
||
await resetSubH({ units: 0 })
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||
})
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.doesNotMatch(t09().text, /Wechselrechnung noch offen/)
|
||
|
||
// Abschluss nach der Frist (Fixture: Abo seit 40 Tagen): Hinweis auf die ordentliche Kuendigung
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
|
||
assert.match(t09().text, /NACH Fristende/)
|
||
assert.match(t09().text, /ggf\. als ordentliche Kündigung behandeln/)
|
||
// Zusatzspeicher ohne jede Bestellung: keine Frist gefunden statt "–, 24 Uhr"
|
||
mails.sent.length = 0
|
||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||
assert.match(t09().text, /Widerrufsfrist bis: keine Frist gefunden/)
|
||
assert.doesNotMatch(t09().text, /–, 24 Uhr|als ordentliche Kündigung/)
|
||
assert.match(t09().text, /keine Bestellung dieser Art/)
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Formulare: Auswahl vorhanden und wortgleich (Spec 4.5); die Bedienung prueft Task 10 Schritt 4 mit Playwright
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const html = (file) => readFileSync(new URL(`../public/${file}`, import.meta.url), 'utf8')
|
||
|
||
test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext', () => {
|
||
const src = html('kuendigen.html')
|
||
assert.match(src, /name="contract" value="abo"[^>]*> Mein Abo \(Hosting- oder Wartungs-Abo, mit Zusatzspeicher\)/)
|
||
assert.match(src, /name="contract" value="zusatzspeicher"[^>]*> Nur den E-Mail-Zusatzspeicher/)
|
||
assert.match(src, /<input id="packages" name="packages" type="number"[^>]*min="1"[^>]*max="10"/)
|
||
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||
assert.match(src, /Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir Ihnen per E-Mail\./)
|
||
assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/)
|
||
})
|
||
|
||
test('widerrufen_html_radio: Radioknöpfe order', () => {
|
||
const src = html('widerrufen.html')
|
||
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
|
||
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
|
||
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', 'abNummer', 'name', 'customerNumber', 'email'\]/)
|
||
})
|
||
|
||
test('legal-form.js: Auswahl statt festem Vertrag, packages nur beim Zusatzspeicher', () => {
|
||
const src = html('legal-form.js')
|
||
assert.doesNotMatch(src, /\['Vertrag', 'WEBklar Wartungs-Abo'\]/)
|
||
assert.match(src, /packages/)
|
||
})
|