Funktionen/News: Schlussdurchgang (Webhook-Retry, Checkout-Sitzung, Rechnungslink, Widerruf Funktion, UI- und Rechtstext-Korrekturen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0dc57fe5e8
commit
4b7f73abba
22 files changed
+400
-57
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
@@ -98,16 +99,26 @@ test('checkout_legt_session_an', async () => {
|
||||
assert.equal(doc.consentText, config.funktionen.consentText)
|
||||
assert.ok(doc.consentAt)
|
||||
assert.ok(doc.consentUserAgent)
|
||||
// zweite Bestellung waehrend die erste offen ist: 409, keine neue Sitzung
|
||||
assert.equal((await checkout('p1')).status, 409)
|
||||
// zweite Bestellung waehrend die erste offen ist: dieselbe Zahlungsseite, keine neue Sitzung
|
||||
const again = await checkout('p1')
|
||||
assert.equal(again.status, 200)
|
||||
assert.equal(again.body.url, res.body.url)
|
||||
assert.equal(sessions().length, 1)
|
||||
})
|
||||
|
||||
test('abgelaufene_bestellung_darf_neu_gestartet_werden', async () => {
|
||||
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
|
||||
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: new Date(Date.now() - 25 * 3600 * 1000).toISOString() })
|
||||
assert.equal((await checkout('p1')).status, 200)
|
||||
test('abgelaufene_sitzung_darf_neu_gestartet_werden', async () => {
|
||||
sessions().at(-1).status = 'expired'
|
||||
const res = await checkout('p1')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(sessions().length, 2)
|
||||
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).stripeSessionId, sessions().at(-1).id)
|
||||
})
|
||||
|
||||
test('abgeschlossene_sitzung_409', async () => {
|
||||
sessions().at(-1).status = 'complete'
|
||||
const before = sessions().length
|
||||
assert.equal((await checkout('p1')).status, 409)
|
||||
assert.equal(sessions().length, before)
|
||||
})
|
||||
|
||||
test('checkout_bei_setup_409', async () => {
|
||||
@@ -192,7 +203,8 @@ test('parallele_checkouts_eine_sitzung', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Gärtnerei' })
|
||||
const before = sessions().length
|
||||
const results = await Promise.all([checkout('p8'), checkout('p8')])
|
||||
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409])
|
||||
assert.deepEqual(results.map((r) => r.status), [200, 200])
|
||||
assert.equal(results[0].body.url, results[1].body.url)
|
||||
assert.equal(sessions().length, before + 1)
|
||||
})
|
||||
|
||||
@@ -300,3 +312,86 @@ test('ticket_fehlt_markiert', async () => {
|
||||
assert.equal(missing.ticketMissing, true)
|
||||
assert.equal(missing.ticketWoid, null)
|
||||
})
|
||||
|
||||
// ------------------------------------------------ Schlussdurchgang ---
|
||||
test('webhook_retry_setzt_ticketlose_einrichtung_fort', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'pr1', customerId: 'c1', projectName: 'Retry' })
|
||||
await checkout('pr1')
|
||||
const id = sessions().at(-1).id
|
||||
const wosBefore = workorders().length
|
||||
const mailsBefore = mails.sent.length
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 400 })
|
||||
const first = await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))
|
||||
assert.equal(first.status, 500)
|
||||
const stuck = await sf.getFeatureDoc('c1', 'pr1', 'news')
|
||||
assert.equal(stuck.state, 'setup')
|
||||
assert.ok(!stuck.ticketId)
|
||||
assert.equal(mails.sent.length, mailsBefore)
|
||||
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))).status, 200)
|
||||
const fixed = await sf.getFeatureDoc('c1', 'pr1', 'news')
|
||||
assert.ok(fixed.ticketId)
|
||||
assert.equal(workorders().length, wosBefore + 1)
|
||||
assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand'])
|
||||
// weiterer Retry: nichts mehr
|
||||
await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))
|
||||
assert.equal(workorders().length, wosBefore + 1)
|
||||
assert.equal(mails.sent.length - mailsBefore, 2)
|
||||
})
|
||||
|
||||
test('rechnungslink_wird_nachgetragen', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'pi1', customerId: 'c1', projectName: 'Rechnung' })
|
||||
await checkout('pi1')
|
||||
const session = sessions().at(-1)
|
||||
await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pi1')))
|
||||
const listed = async () => (await get('/api/funktionen', { cookie })).projects.find((p) => p.projectId === 'pi1').features.news
|
||||
assert.equal((await listed()).invoiceUrl, null)
|
||||
st.invoices.set('in_late', { id: 'in_late', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/late' })
|
||||
session.invoice = 'in_late'
|
||||
assert.equal((await listed()).invoiceUrl, 'https://invoice.stripe.test/late')
|
||||
const doc = await sf.getFeatureDoc('c1', 'pi1', 'news')
|
||||
assert.equal(doc.stripeInvoiceId, 'in_late')
|
||||
// Stripe-Fehler lassen die Liste nicht scheitern
|
||||
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), invoiceUrl: '' })
|
||||
st.failNext({ method: 'GET', path: /checkout\/sessions/, status: 400 })
|
||||
assert.equal((await get('/api/funktionen', { cookie })).projects.length > 0, true)
|
||||
})
|
||||
|
||||
test('bezahlt_ohne_passende_bestellung_meldet_dem_team', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'pu1', customerId: 'c1', projectName: 'Unklar' })
|
||||
const mailsBefore = mails.sent.length
|
||||
const res = await postWebhook(event('checkout.session.completed', paidSession('cs_verwaist', 'pu1')))
|
||||
assert.equal(res.status, 200)
|
||||
const notice = mails.sent.slice(mailsBefore)
|
||||
assert.equal(notice.length, 1)
|
||||
assert.equal(notice[0].subject, 'Neue Funktion bestellt: News-Pinnwand')
|
||||
assert.match(notice[0].text, /cs_verwaist/)
|
||||
assert.match(notice[0].text, /nicht zuzuordnen/)
|
||||
assert.doesNotMatch(notice[0].text, /Zahlung fehlgeschlagen/)
|
||||
assert.equal(workorders().filter((t) => t.topic.includes('Unklar')).length, 0)
|
||||
})
|
||||
|
||||
test('abgeschaltete_funktion_nicht_buchbar', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'pd1', customerId: 'c1', projectName: 'Abgeschaltet' })
|
||||
const doc = await sf.adminActivate('c1', 'pd1', 'news', { by: 'test' })
|
||||
await sf.disable(doc.$id, { by: 'test' })
|
||||
const before = sessions().length
|
||||
const res = await checkout('pd1')
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.')
|
||||
assert.equal(sessions().length, before)
|
||||
})
|
||||
|
||||
test('statische_quellen: news_label, minmax_grid, ticket_anleitung', () => {
|
||||
const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')
|
||||
assert.match(read('public/app.js'), /FEATURE_LABELS = \{[^}]*news: 'News-Pinnwand'/)
|
||||
assert.doesNotMatch(read('public/app.js'), /FEATURE_LABELS\[f\.featureKey\] \|\| f\.featureKey/)
|
||||
const embed = read('public/embed/news.js')
|
||||
assert.ok(embed.includes('grid-template-columns:minmax(0,1.25fr) minmax(0,1fr)'))
|
||||
assert.ok(embed.includes('.wkn-combo-in{grid-template-columns:minmax(0,1fr)}'))
|
||||
assert.ok(!read('public/dashboard.html').includes('id="view-news" aria-live'))
|
||||
})
|
||||
|
||||
test('ticket_details_nennen_die_anleitung', () => {
|
||||
const doc = [...aw.docs('workorders').values()].find((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
|
||||
assert.ok(doc.details.includes('Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)'))
|
||||
})
|
||||
@@ -473,12 +473,20 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)',
|
||||
for (const s of ['News-Beiträge und Fotos', 'Metadaten', 'GPS', 'Falkenstein', 'Art. 6 Abs. 1 lit. b DSGVO']) assert.ok(news.includes(s), s)
|
||||
assert.ok(html.slice(Math.max(0, a - 120), a).includes('ENTWURF, Freigabe Nutzer ausstehend'))
|
||||
assert.ok(html.includes('<h2>7. Abos und Zahlung über Stripe</h2>'), 'keine Umnummerierung')
|
||||
assert.ok(news.includes('nach etwa 24 Stunden') && !news.includes('spätestens nach 24 Stunden'))
|
||||
assert.ok(news.includes('nach den Fristen in Abschnitt 7') && !news.includes('Abschnitt 6 und 7'))
|
||||
assert.ok(!news.includes('Endet die Zusammenarbeit, schalten wir'))
|
||||
|
||||
const lb = read('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
|
||||
assert.ok(lb.includes('ENTWURF, Freigabe Nutzer ausstehend'))
|
||||
const text = strip(lb)
|
||||
assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir'))
|
||||
assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß'))
|
||||
for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s)
|
||||
|
||||
const muster = read('docs/funktionen/website-datenschutz-absatz.md')
|
||||
// 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt
|
||||
assert.ok(!muster.includes('nach 14 Tagen gelöscht') || strip(html).includes('Die Protokolle löschen wir nach 14 Tagen'))
|
||||
assert.ok(muster.includes('[Prüfen: AVV mit WEBklar erforderlich]'))
|
||||
assert.ok(!/[–—]/.test(muster.slice(muster.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche')
|
||||
})
|
||||
@@ -260,11 +260,11 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
|
||||
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
|
||||
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
|
||||
assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'pruefen'])
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
|
||||
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
|
||||
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
|
||||
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
|
||||
for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`)
|
||||
for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`)
|
||||
|
||||
const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' }
|
||||
const speicher = renderMail('K03', k03, { zusatzspeicher: true })
|
||||
|
||||
@@ -452,6 +452,31 @@ test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerr
|
||||
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/)
|
||||
aw.docs('portalFeatures').delete('f_pf1_news')
|
||||
})
|
||||
|
||||
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 } })
|
||||
@@ -656,6 +681,7 @@ test('widerrufen.html: Radioknöpfe order', () => {
|
||||
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.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||||
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
|
||||
})
|
||||
|
||||
@@ -314,10 +314,22 @@ export async function startFakeStripe() {
|
||||
subscription: null,
|
||||
metadata: data.metadata || {},
|
||||
subscription_data: data.subscription_data || {},
|
||||
status: 'open',
|
||||
payment_status: 'unpaid',
|
||||
invoice: null,
|
||||
}
|
||||
checkoutSessions.set(id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
// Sitzung abrufen (Funktions-Checkout); expand=invoice ersetzt die Rechnungs-ID durch das Objekt.
|
||||
// Tests setzen status/payment_status/invoice direkt im Objekt aus st.checkoutSessions.
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
|
||||
const found = checkoutSessions.get(m[1])
|
||||
if (!found) return noSuch('checkout.session', m[1])
|
||||
const expandInvoice = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'invoice')
|
||||
const inv = typeof found.invoice === 'string' ? invoices.get(found.invoice) : null
|
||||
return [200, expandInvoice && inv ? { ...found, invoice: inv } : found]
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/billing_portal/configurations') {
|
||||
const active = query.get('active')
|
||||
const found = [...billingPortalConfigurations.values()].filter(
|
||||
|
||||
@@ -34,6 +34,7 @@ const SCENARIOS = {
|
||||
off: [proj('p1', 'Bäckerei Berg', 'off')],
|
||||
setup: [proj('p1', 'Bäckerei Berg', 'setup')],
|
||||
active: [proj('p1', 'Bäckerei Berg', 'active')],
|
||||
disabled: [proj('p1', 'Bäckerei Berg', 'disabled')],
|
||||
multi: [proj('p1', 'Bäckerei Berg', 'active'), proj('p2', 'Café Nord', 'off')],
|
||||
}
|
||||
const CONSENT = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt.'
|
||||
@@ -173,6 +174,17 @@ try {
|
||||
const back = await page.textContent('#view-news')
|
||||
check(`${label} bestellt_hinweis`, back.includes('Danke! Sobald die Zahlung da ist, legen wir los.') && back.includes('1234') && back.includes('Rechnung ansehen') && back.includes('Frage dazu? Chat öffnen'))
|
||||
check(`${label} bestellt_router`, await page.evaluate(() => document.getElementById('view-news').classList.contains('active')))
|
||||
check(`${label} bestellt_flag_aus_hash`, await page.evaluate(() => !window.location.hash.includes('bestellt')), await page.evaluate(() => window.location.hash))
|
||||
// Flag ohne bestellte Funktion (Zustand off): kein Dankeshinweis
|
||||
await open('#news?bestellt=1', 'off')
|
||||
await page.waitForSelector('#news-order')
|
||||
check(`${label} bestellt_flag_ignoriert_bei_off`, !(await page.textContent('#view-news')).includes('Danke!'))
|
||||
// Abgeschaltet: Pille und Knopf der Kachel, Status statt Buchung
|
||||
await open('#funktionen', 'disabled')
|
||||
await page.waitForSelector('[data-fn-tile="news"] .fn-pill')
|
||||
const dis = await page.evaluate(() => ({ pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(), btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim() }))
|
||||
check(`${label} kachel_abgeschaltet`, dis.pill === 'Abgeschaltet' && dis.btn === 'Status ansehen', JSON.stringify(dis))
|
||||
check(`${label} news_ohne_aria_live`, await page.evaluate(() => !document.getElementById('view-news').hasAttribute('aria-live')))
|
||||
|
||||
// Editor
|
||||
await open('#news', 'active')
|
||||
@@ -188,6 +200,15 @@ try {
|
||||
check(`${label} editor_foto_sichtbar`, await page.isVisible('#nw-photo-field'))
|
||||
const accepts = await page.$$eval('#nw-photo-field input[type=file]', (els) => els.map((e) => `${e.accept}|${e.getAttribute('capture') || ''}`))
|
||||
check(`${label} editor_foto_accept`, accepts.length >= 1 && accepts.every((a) => a.startsWith('image/jpeg,image/png,image/webp|')) && accepts.filter((a) => a.endsWith('|environment')).length === 1, accepts.join(' ; '))
|
||||
// Zurueck zum Tab und wieder in News (gleiches Projekt, gleicher Zustand): Entwurf bleibt stehen
|
||||
await page.fill('#nw-title', 'Entwurf bleibt')
|
||||
await page.evaluate(() => document.querySelector('.kb-nav-item[data-view="funktionen"]').click())
|
||||
await page.waitForSelector('[data-fn-tile="news"] .fn-tile-cta')
|
||||
await page.click('[data-fn-tile="news"] .fn-tile-cta')
|
||||
await page.waitForSelector('#nw-title')
|
||||
await page.waitForTimeout(200)
|
||||
check(`${label} editor_entwurf_bleibt`, (await page.inputValue('#nw-title')) === 'Entwurf bleibt')
|
||||
await page.fill('#nw-title', 'Neue Winterjacken')
|
||||
const lists = await page.textContent('#view-news')
|
||||
check(`${label} editor_listen`, lists.includes('Hängt gerade') && lists.includes('Abgelaufen') && lists.includes('Freitag geschlossen') && lists.includes('Auf meiner Website ansehen'))
|
||||
await page.click('[data-del]')
|
||||
|
||||
Reference in new issue
Block a user