Shop G6: Reiter Einstellungen und Rechtliches (Felder, Versand, Zahlungsarten, Muster einsetzen, Entwurf, Bestätigung mit Hash)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ed96a5838
commit
648d0e07fe
5 files changed
+1148
-10
No files matched your search
@@ -0,0 +1,335 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { h, request } from '../public/shop-dom.js'
|
||||
import { ANBIETER_HINWEIS, PAYPAL_HINWEIS, SEPA_WARNUNG, STRIPE_HINWEIS, render } from '../public/shop-tab-einstellungen.js'
|
||||
|
||||
// Task G6: Reiter „Einstellungen“ mit Fake-DOM und Fake-fetch. Die Antworten des Servers sind von Hand gebaut
|
||||
// (Form: server/routes/shop.js und server/services/shop/einstellungen.js).
|
||||
|
||||
const STANDARD = {
|
||||
shopname: '',
|
||||
anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' },
|
||||
kleinunternehmer: true,
|
||||
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
|
||||
abholung: { aktiv: false, adresse: '', hinweis: '' },
|
||||
lieferzeit: '2–4 Werktage',
|
||||
zahlungsarten: ['card'],
|
||||
akzent: '#111111',
|
||||
mindestbestellCent: null,
|
||||
ruecksendekosten: 'kaeufer',
|
||||
}
|
||||
const GEFUELLT = {
|
||||
shopname: 'Mützenlädchen',
|
||||
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'erika@example.com', telefon: '0631 123456', ustId: 'DE123456789' },
|
||||
kleinunternehmer: false,
|
||||
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
|
||||
abholung: { aktiv: true, adresse: 'Hinterhof 2\n67655 Kaiserslautern', hinweis: 'Mo bis Fr 10 bis 18 Uhr' },
|
||||
lieferzeit: '1–2 Werktage',
|
||||
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
|
||||
akzent: '#ff8800',
|
||||
mindestbestellCent: 1500,
|
||||
ruecksendekosten: 'haendler',
|
||||
}
|
||||
|
||||
const ANTWORT = (einstellungen, version = 3) => ({ einstellungen, version, vollstaendig: true })
|
||||
|
||||
/** Fake-Server: GET /api/shop/einstellungen antwortet aus `welt.get()`, PUT aus `welt.put(aufruf)` (Standard: Echo mit Version + 1). */
|
||||
function startFx(welt) {
|
||||
const route = (aufruf) => {
|
||||
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/einstellungen?projectId=')) return { body: typeof welt.get === 'function' ? welt.get() : welt.get }
|
||||
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/einstellungen') {
|
||||
if (welt.put) return welt.put(aufruf)
|
||||
return { body: ANTWORT(aufruf.body.einstellungen, aufruf.body.version + 1) }
|
||||
}
|
||||
return { status: 404, body: { error: 'unbekannt' } }
|
||||
}
|
||||
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
|
||||
}
|
||||
|
||||
const mitDom = (welt, fn) => async () => {
|
||||
const fx = startFx(welt)
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function ctxVon(extra = {}) {
|
||||
const log = { meldungen: [], neuGeladen: 0 }
|
||||
const ctx = {
|
||||
projectId: 'p1',
|
||||
nurLesen: false,
|
||||
request,
|
||||
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
|
||||
neuLaden: async () => { log.neuGeladen += 1 },
|
||||
...extra,
|
||||
}
|
||||
return { ctx, log }
|
||||
}
|
||||
|
||||
async function oeffne(fx, ctxExtra) {
|
||||
const root = h('div', { id: 'panel' })
|
||||
fx.document.body.append(root)
|
||||
const { ctx, log } = ctxVon(ctxExtra)
|
||||
await render(root, ctx)
|
||||
await fx.ruhe()
|
||||
return { root, ctx, log }
|
||||
}
|
||||
|
||||
const feldVon = (root, name) => root.querySelector(`[data-feld="${name}"]`)
|
||||
const fehlerVon = (root, name) => root.querySelector(`[data-fehler="${name}"]`)
|
||||
const speichernKnopf = (root) => root.querySelector('[data-aktion="einstellungen-speichern"]')
|
||||
const puts = (fx) => fx.fetchLog.filter((a) => a.method === 'PUT')
|
||||
|
||||
/* --------------------------------------------------------------- Anzeige --- */
|
||||
test('Standardwerte: Felder gefüllt, Länder nur DE, Karte fest, Hinweise zu Klarna, Lastschrift und PayPal', mitDom({ get: ANTWORT(STANDARD, 0) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
assert.equal(fx.fetchLog[0].url, '/api/shop/einstellungen?projectId=p1')
|
||||
for (const titel of ['Shop', 'Anbieter', 'Steuer', 'Versand', 'Abholung', 'Zahlungsarten']) {
|
||||
assert.ok(fx.finde(root, (el) => el.tagName === 'H3' && el.textContent === titel), titel)
|
||||
}
|
||||
assert.equal(feldVon(root, 'shopname').value, '')
|
||||
assert.equal(feldVon(root, 'akzent').type, 'color')
|
||||
assert.equal(feldVon(root, 'akzent').value, '#111111')
|
||||
assert.equal(feldVon(root, 'anbieter.land').value, 'DE')
|
||||
assert.equal(feldVon(root, 'kleinunternehmer').checked, true)
|
||||
assert.equal(feldVon(root, 'versand.aktiv').checked, true)
|
||||
assert.equal(feldVon(root, 'versand.laender').value, 'DE')
|
||||
assert.equal(feldVon(root, 'versand.kostenCent.DE').value, '4,90')
|
||||
assert.equal(feldVon(root, 'versand.kostenCent.DE').type, 'text', 'Betrag als Text, Komma bleibt erlaubt')
|
||||
assert.equal(feldVon(root, 'versand.freiAbCent').value, '')
|
||||
assert.equal(feldVon(root, 'lieferzeit').value, '2–4 Werktage')
|
||||
assert.equal(feldVon(root, 'mindestbestellCent').value, '')
|
||||
assert.equal(feldVon(root, 'ruecksendekosten').value, 'kaeufer')
|
||||
assert.equal(fx.text(root).includes(ANBIETER_HINWEIS), true)
|
||||
assert.equal(fx.text(root).includes(SEPA_WARNUNG), true)
|
||||
assert.equal(fx.text(root).includes(STRIPE_HINWEIS), true)
|
||||
assert.equal(fx.text(root).includes(PAYPAL_HINWEIS), true)
|
||||
assert.match(SEPA_WARNUNG, /^Bei Lastschrift ist das Geld erst nach einigen Tagen sicher\. Versende erst, wenn die Bestellung als bezahlt markiert ist\.$/)
|
||||
assert.match(fx.text(root), /Kleinunternehmer nach § 19 UStG/)
|
||||
// Österreich und Abholung sind zu: ihre Felder sind versteckt
|
||||
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, true)
|
||||
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, true)
|
||||
}))
|
||||
|
||||
test('Zahlungsarten: Karte immer gesetzt und gesperrt, Klarna und Lastschrift frei wählbar', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
const karte = feldVon(root, 'zahlung.card')
|
||||
assert.equal(karte.checked, true)
|
||||
assert.equal(karte.disabled, true)
|
||||
assert.match(fx.text(root), /inklusive Apple Pay und Google Pay/)
|
||||
assert.equal(feldVon(root, 'zahlung.klarna').checked, false)
|
||||
assert.equal(feldVon(root, 'zahlung.klarna').disabled, false)
|
||||
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, false)
|
||||
await fx.klick(karte)
|
||||
assert.equal(karte.checked, true, 'ein Klick ändert nichts')
|
||||
}))
|
||||
|
||||
test('gespeicherte Werte werden angezeigt, Österreich und Abholung sind sichtbar', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen')
|
||||
assert.equal(feldVon(root, 'akzent').value, '#ff8800')
|
||||
assert.equal(feldVon(root, 'anbieter.plz').value, '67655')
|
||||
assert.equal(feldVon(root, 'anbieter.ustId').value, 'DE123456789')
|
||||
assert.equal(feldVon(root, 'kleinunternehmer').checked, false)
|
||||
assert.equal(feldVon(root, 'versand.laender').value, 'DE,AT')
|
||||
assert.equal(feldVon(root, 'versand.kostenCent.AT').value, '9,90')
|
||||
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, false)
|
||||
assert.equal(feldVon(root, 'versand.freiAbCent').value, '50,00')
|
||||
assert.equal(feldVon(root, 'abholung.aktiv').checked, true)
|
||||
assert.equal(feldVon(root, 'abholung.adresse').value, 'Hinterhof 2\n67655 Kaiserslautern')
|
||||
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, false)
|
||||
assert.equal(feldVon(root, 'mindestbestellCent').value, '15,00')
|
||||
assert.equal(feldVon(root, 'ruecksendekosten').value, 'haendler')
|
||||
assert.equal(feldVon(root, 'zahlung.klarna').checked, true)
|
||||
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, true)
|
||||
}))
|
||||
|
||||
test('Schalter blenden Österreich, Versand- und Abholfelder ein und aus', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
const at = feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at')
|
||||
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
|
||||
assert.equal(at.hidden, false)
|
||||
await fx.setze(feldVon(root, 'versand.laender'), 'DE')
|
||||
assert.equal(at.hidden, true)
|
||||
const versandDetails = feldVon(root, 'versand.laender').closest('.sh-versand-details')
|
||||
await fx.klick(feldVon(root, 'versand.aktiv'))
|
||||
assert.equal(versandDetails.hidden, true)
|
||||
const abhol = feldVon(root, 'abholung.adresse').closest('.sh-abhol-details')
|
||||
await fx.klick(feldVon(root, 'abholung.aktiv'))
|
||||
assert.equal(abhol.hidden, false)
|
||||
}))
|
||||
|
||||
/* ------------------------------------------------------------- Speichern --- */
|
||||
test('Speichern: Körper mit ganzen Cent, Version, Meldung und neuLaden; der Stand des Servers kommt zurück ins Formular', mitDom({
|
||||
get: ANTWORT(STANDARD, 2),
|
||||
put: (aufruf) => ({ body: ANTWORT({ ...aufruf.body.einstellungen, anbieter: { ...aufruf.body.einstellungen.anbieter, email: 'erika@example.com' } }, 3) }),
|
||||
}, async (fx) => {
|
||||
const { root, log } = await oeffne(fx)
|
||||
await fx.setze(feldVon(root, 'shopname'), ' Mützenlädchen ')
|
||||
await fx.setze(feldVon(root, 'akzent'), '#ff8800')
|
||||
await fx.setze(feldVon(root, 'anbieter.name'), 'Erika Muster')
|
||||
await fx.setze(feldVon(root, 'anbieter.strasse'), 'Hauptstraße 1')
|
||||
await fx.setze(feldVon(root, 'anbieter.plz'), '67655')
|
||||
await fx.setze(feldVon(root, 'anbieter.ort'), 'Kaiserslautern')
|
||||
await fx.setze(feldVon(root, 'anbieter.email'), 'Erika@Example.com')
|
||||
await fx.setze(feldVon(root, 'anbieter.ustId'), 'DE123456789')
|
||||
await fx.klick(feldVon(root, 'kleinunternehmer'))
|
||||
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
|
||||
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '4,9')
|
||||
await fx.setze(feldVon(root, 'versand.kostenCent.AT'), '9,90')
|
||||
await fx.setze(feldVon(root, 'versand.freiAbCent'), '50')
|
||||
await fx.klick(feldVon(root, 'abholung.aktiv'))
|
||||
await fx.setze(feldVon(root, 'abholung.adresse'), 'Hinterhof 2')
|
||||
await fx.setze(feldVon(root, 'abholung.hinweis'), 'Mo bis Fr')
|
||||
await fx.setze(feldVon(root, 'lieferzeit'), '1–2 Werktage')
|
||||
await fx.setze(feldVon(root, 'mindestbestellCent'), '15')
|
||||
await fx.setze(feldVon(root, 'ruecksendekosten'), 'haendler')
|
||||
await fx.klick(feldVon(root, 'zahlung.klarna'))
|
||||
await fx.klick(feldVon(root, 'zahlung.sepa_debit'))
|
||||
await fx.klick(speichernKnopf(root))
|
||||
|
||||
assert.equal(puts(fx).length, 1)
|
||||
assert.deepEqual(puts(fx)[0].body, {
|
||||
projectId: 'p1',
|
||||
version: 2,
|
||||
einstellungen: {
|
||||
shopname: 'Mützenlädchen',
|
||||
akzent: '#ff8800',
|
||||
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'Erika@Example.com', telefon: '', ustId: 'DE123456789' },
|
||||
kleinunternehmer: false,
|
||||
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
|
||||
abholung: { aktiv: true, adresse: 'Hinterhof 2', hinweis: 'Mo bis Fr' },
|
||||
lieferzeit: '1–2 Werktage',
|
||||
mindestbestellCent: 1500,
|
||||
ruecksendekosten: 'haendler',
|
||||
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
|
||||
},
|
||||
})
|
||||
assert.deepEqual(log.meldungen, [['Die Einstellungen sind gespeichert.', 'ok']])
|
||||
assert.equal(log.neuGeladen, 1)
|
||||
assert.equal(feldVon(root, 'anbieter.email').value, 'erika@example.com', 'normalisierter Stand des Servers')
|
||||
|
||||
// zweites Speichern schickt die neue Version
|
||||
await fx.klick(speichernKnopf(root))
|
||||
assert.equal(puts(fx)[1].body.version, 3)
|
||||
}))
|
||||
|
||||
test('Speichern ohne Österreich schickt nur Deutschland; ohne Versand dürfen die Kosten leer sein', mitDom({ get: ANTWORT({ ...STANDARD, versand: { ...STANDARD.versand, aktiv: false }, abholung: { aktiv: true, adresse: 'Laden', hinweis: '' } }) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
|
||||
await fx.klick(speichernKnopf(root))
|
||||
const e = puts(fx)[0].body.einstellungen
|
||||
assert.deepEqual(e.versand, { aktiv: false, laender: ['DE'], kostenCent: { DE: null }, freiAbCent: null })
|
||||
assert.deepEqual(e.zahlungsarten, ['card'])
|
||||
assert.equal(e.mindestbestellCent, null)
|
||||
}))
|
||||
|
||||
test('unlesbare oder fehlende Beträge werden im Browser gemeldet, es geht nichts an den Server', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), 'viel')
|
||||
await fx.setze(feldVon(root, 'mindestbestellCent'), '1,234')
|
||||
await fx.klick(speichernKnopf(root))
|
||||
assert.equal(puts(fx).length, 0)
|
||||
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /Betrag wie 4,90/)
|
||||
assert.match(fehlerVon(root, 'mindestbestellCent').textContent, /Betrag wie 4,90/)
|
||||
assert.equal(feldVon(root, 'versand.kostenCent.DE').getAttribute('aria-invalid'), 'true')
|
||||
|
||||
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
|
||||
await fx.setze(feldVon(root, 'mindestbestellCent'), '')
|
||||
await fx.klick(speichernKnopf(root))
|
||||
assert.equal(puts(fx).length, 0)
|
||||
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /kostenlosen Versand trage 0,00 ein/)
|
||||
assert.equal(fehlerVon(root, 'mindestbestellCent').textContent, '')
|
||||
|
||||
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '0')
|
||||
await fx.klick(speichernKnopf(root))
|
||||
assert.equal(puts(fx).length, 1)
|
||||
assert.equal(puts(fx)[0].body.einstellungen.versand.kostenCent.DE, 0)
|
||||
}))
|
||||
|
||||
test('Feldfehler des Servers stehen am Feld, Unbekanntes allgemein; der Knopf ist danach wieder frei', mitDom({
|
||||
get: ANTWORT(STANDARD),
|
||||
put: () => ({ status: 400, body: { error: 'Die Einstellungen sind nicht gültig.', fehler: { 'anbieter.plz': 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.', 'versand.aktiv': 'Bitte biete Versand oder Abholung an.', 'dings.unbekannt': 'Etwas stimmt nicht.' } } }),
|
||||
}, async (fx) => {
|
||||
const { root, log } = await oeffne(fx)
|
||||
await fx.klick(speichernKnopf(root))
|
||||
assert.equal(fehlerVon(root, 'anbieter.plz').textContent, 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.')
|
||||
assert.equal(feldVon(root, 'anbieter.plz').getAttribute('aria-invalid'), 'true')
|
||||
assert.equal(fehlerVon(root, 'versand.aktiv').textContent, 'Bitte biete Versand oder Abholung an.')
|
||||
assert.equal(fehlerVon(root, '_').textContent, 'Etwas stimmt nicht.')
|
||||
assert.equal(speichernKnopf(root).disabled, false)
|
||||
assert.deepEqual(log.meldungen, [])
|
||||
assert.equal(log.neuGeladen, 0)
|
||||
}))
|
||||
|
||||
test('409 (Einstellungen veraltet): Hinweis mit „Neu laden“, das den neuen Stand holt', async () => {
|
||||
let stand = ANTWORT(STANDARD, 1)
|
||||
const fx = startFx({
|
||||
get: () => stand,
|
||||
put: () => ({ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.', code: 'EINSTELLUNG_VERALTET' } }),
|
||||
})
|
||||
try {
|
||||
const { root } = await oeffne(fx)
|
||||
await fx.klick(speichernKnopf(root))
|
||||
const konflikt = root.querySelector('[data-konflikt]')
|
||||
assert.ok(konflikt)
|
||||
assert.match(konflikt.textContent, /inzwischen geändert/)
|
||||
assert.equal(fehlerVon(root, '_').textContent, '')
|
||||
|
||||
stand = ANTWORT({ ...STANDARD, shopname: 'Von anderer Seite' }, 2)
|
||||
await fx.klick(root.querySelector('[data-aktion="neu-laden"]'))
|
||||
await fx.ruhe()
|
||||
assert.equal(feldVon(root, 'shopname').value, 'Von anderer Seite')
|
||||
assert.equal(root.querySelector('[data-konflikt]'), null)
|
||||
await fx.klick(speichernKnopf(root))
|
||||
assert.equal(puts(fx).at(-1).body.version, 2)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('Netzwerkfehler beim Speichern: Meldung allgemein, nichts gespeichert', mitDom({
|
||||
get: ANTWORT(STANDARD),
|
||||
put: () => ({ status: 500, body: { error: 'Der Server hat Probleme.' } }),
|
||||
}, async (fx) => {
|
||||
const { root, log } = await oeffne(fx)
|
||||
await fx.klick(speichernKnopf(root))
|
||||
assert.equal(fehlerVon(root, '_').textContent, 'Der Server hat Probleme.')
|
||||
assert.deepEqual(log.meldungen, [])
|
||||
}))
|
||||
|
||||
/* -------------------------------------------------------------- nur lesen --- */
|
||||
test('nurLesen: alle Felder gesperrt, kein Speichern-Knopf, kein Versand', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
|
||||
const { root } = await oeffne(fx, { nurLesen: true })
|
||||
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
|
||||
assert.ok(steuerungen.length > 20)
|
||||
assert.deepEqual(steuerungen.filter((el) => !el.disabled).map((el) => el.dataset.feld), [])
|
||||
assert.equal(speichernKnopf(root), null)
|
||||
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').filter((b) => b.getAttribute('type') === 'submit').length, 0)
|
||||
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen', 'Werte bleiben sichtbar')
|
||||
// auch ein abgesendetes Formular schreibt nichts
|
||||
await fx.sende(root.querySelector('form'), 'submit')
|
||||
assert.equal(puts(fx).length, 0)
|
||||
}))
|
||||
|
||||
test('Ladefehler wird geworfen (die Hülle zeigt ihn mit „Noch einmal versuchen“)', async () => {
|
||||
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
|
||||
try {
|
||||
const root = h('div')
|
||||
await assert.rejects(render(root, ctxVon().ctx), /Kaputt\./)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------------------------------------------------------------- statisch --- */
|
||||
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write und kein Import aus termine*.js', () => {
|
||||
const quelle = readFileSync(new URL('../public/shop-tab-einstellungen.js', import.meta.url), 'utf8')
|
||||
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
|
||||
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { h, request } from '../public/shop-dom.js'
|
||||
import { GEWAEHRLEISTUNG_NOTIZ, INTRO, NICHT_BESTAETIGT, render } from '../public/shop-tab-rechtliches.js'
|
||||
|
||||
// Task G6: Reiter „Rechtliches“ mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut (Form: rechtstexteAntwort in server/routes/shop.js).
|
||||
|
||||
const BESTAETIGUNG = 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.'
|
||||
const MUSTER = { agb: 'MUSTER-AGB\nZeile zwei', widerrufsbelehrung: 'MUSTER-WIDERRUF', widerrufsformular: 'MUSTER-FORMULAR' }
|
||||
const VOLL = {
|
||||
agb: 'Meine AGB', widerrufsbelehrung: 'Meine Belehrung', widerrufsformular: 'Mein Formular',
|
||||
impressumUrl: 'https://example.com/impressum', datenschutzUrl: 'https://example.com/datenschutz',
|
||||
}
|
||||
const LEER = { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }
|
||||
|
||||
const antwort = (entwurf, extra = {}) => ({
|
||||
entwurf,
|
||||
bestaetigt: null,
|
||||
muster: MUSTER,
|
||||
versandUndZahlung: 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.',
|
||||
gewaehrleistung: { bild: '/img/gewaehrleistung-eu.webp', text: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre.' },
|
||||
hash: 'hash-1',
|
||||
bestaetigungText: BESTAETIGUNG,
|
||||
vollstaendig: Object.values(entwurf).every((w) => w.trim() !== ''),
|
||||
...extra,
|
||||
})
|
||||
const bestaetigt = (aktuell, extra = {}) => ({ texte: VOLL, at: '2026-10-05T12:30:00.000Z', von: 'Erika Muster', aktuell, ...extra })
|
||||
|
||||
/** Fake-Server: GET/PUT /api/shop/rechtstexte und POST …/bestaetigen aus `welt` (get(), put(aufruf), post(aufruf)). */
|
||||
function startFx(welt) {
|
||||
const route = (aufruf) => {
|
||||
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/rechtstexte?projectId=')) return { body: welt.get() }
|
||||
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/rechtstexte') return welt.put(aufruf)
|
||||
if (aufruf.method === 'POST' && aufruf.url === '/api/shop/rechtstexte/bestaetigen') return welt.post(aufruf)
|
||||
return { status: 404, body: { error: 'unbekannt' } }
|
||||
}
|
||||
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
|
||||
}
|
||||
|
||||
const mitDom = (welt, fn) => async () => {
|
||||
const fx = startFx(welt)
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
async function oeffne(fx, ctxExtra = {}) {
|
||||
const root = h('div', { id: 'panel' })
|
||||
fx.document.body.append(root)
|
||||
const log = { meldungen: [], neuGeladen: 0 }
|
||||
const ctx = {
|
||||
projectId: 'p1',
|
||||
nurLesen: false,
|
||||
request,
|
||||
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
|
||||
neuLaden: async () => { log.neuGeladen += 1 },
|
||||
...ctxExtra,
|
||||
}
|
||||
await render(root, ctx)
|
||||
await fx.ruhe()
|
||||
return { root, log }
|
||||
}
|
||||
|
||||
const text = (root, key) => root.querySelector(`[data-text="${key}"]`)
|
||||
const knopf = (root, aktion) => root.querySelector(`[data-aktion="${aktion}"]`)
|
||||
const haken = (root) => root.querySelector('[data-feld="bestaetigung"]')
|
||||
const sendet = (fx, art) => fx.fetchLog.filter((a) => a.method === art)
|
||||
|
||||
/* --------------------------------------------------------------- Anzeige --- */
|
||||
test('Anzeige: Hinweis, Texte aus dem Entwurf, erzeugte Texte nur lesbar, Gewährleistungsbild mit Hinweis', mitDom({ get: () => antwort(VOLL) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
assert.equal(fx.fetchLog[0].url, '/api/shop/rechtstexte?projectId=p1')
|
||||
assert.equal(fx.text(root.querySelector('[data-hinweis="intro"]')), INTRO)
|
||||
assert.match(INTRO, /^Du bist Verkäufer und für deine Rechtstexte verantwortlich\. Wir stellen Muster bereit, die du prüfen und anpassen musst – das ist keine Rechtsberatung\. Lass deine Texte am besten von einer Kanzlei oder einem Händlerbund prüfen\.$/)
|
||||
assert.equal(text(root, 'agb').value, 'Meine AGB')
|
||||
assert.equal(text(root, 'widerrufsbelehrung').value, 'Meine Belehrung')
|
||||
assert.equal(text(root, 'widerrufsformular').value, 'Mein Formular')
|
||||
assert.equal(text(root, 'impressumUrl').value, 'https://example.com/impressum')
|
||||
assert.equal(text(root, 'datenschutzUrl').value, 'https://example.com/datenschutz')
|
||||
for (const titel of ['Versand und Zahlung', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular']) assert.ok(fx.text(root).includes(titel), titel)
|
||||
|
||||
const versand = root.querySelector('[data-lesetext="versand-zahlung"]')
|
||||
assert.equal(versand.textContent, 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.')
|
||||
assert.equal(versand.tagName === 'TEXTAREA' || versand.tagName === 'INPUT', false, 'nur lesbar, kein Eingabefeld')
|
||||
const bild = root.querySelector('img')
|
||||
assert.equal(bild.getAttribute('src'), '/img/gewaehrleistung-eu.webp')
|
||||
assert.match(bild.getAttribute('alt'), /GESETZLICHE GEWÄHRLEISTUNG/)
|
||||
assert.equal(root.querySelector('[data-lesetext="gewaehrleistung"]').textContent.startsWith('GESETZLICHE GEWÄHRLEISTUNG'), true)
|
||||
assert.equal(fx.text(root).includes(GEWAEHRLEISTUNG_NOTIZ), true)
|
||||
assert.match(GEWAEHRLEISTUNG_NOTIZ, /automatisch vor dem Bestell-Knopf/)
|
||||
assert.equal(fx.alle(root, (el) => el.tagName === 'TEXTAREA').length, 3)
|
||||
}))
|
||||
|
||||
test('ein Gewährleistungsbild von fremder Adresse wird nicht geladen', mitDom({ get: () => antwort(VOLL, { gewaehrleistung: { bild: '//boese.example/x.webp', text: 'Text' } }) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
assert.equal(root.querySelector('img'), null)
|
||||
}))
|
||||
|
||||
/* ------------------------------------------------------------ Status, Banner --- */
|
||||
test('Status: noch nie bestätigt, aktuell bestätigt (Datum und Person) und Banner bei unbestätigten Änderungen', async () => {
|
||||
const fall = [
|
||||
[null, 'offen', /Noch nicht bestätigt/],
|
||||
[bestaetigt(true), 'bestaetigt', /Bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster\./],
|
||||
[bestaetigt(false), 'unbestaetigt', /Änderungen noch nicht bestätigt.*zuletzt bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster/],
|
||||
]
|
||||
for (const [b, art, muster] of fall) {
|
||||
const fx = startFx({ get: () => antwort(VOLL, { bestaetigt: b }) })
|
||||
try {
|
||||
const { root } = await oeffne(fx)
|
||||
const status = root.querySelector('[data-status]')
|
||||
assert.equal(status.dataset.status, art)
|
||||
assert.match(status.textContent, muster)
|
||||
assert.equal(root.querySelector('[data-status="unbestaetigt"]') !== null, art === 'unbestaetigt')
|
||||
// schon bestätigt und nichts geändert: kein Bestätigungsblock
|
||||
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, art === 'bestaetigt')
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
assert.equal(NICHT_BESTAETIGT, 'Änderungen noch nicht bestätigt')
|
||||
})
|
||||
|
||||
/* -------------------------------------------------------- Muster einsetzen --- */
|
||||
test('Muster einsetzen: leeres Feld wird sofort gefüllt, ein vorhandener Text erst nach Rückfrage', mitDom({ get: () => antwort({ ...LEER, widerrufsbelehrung: 'Mein eigener Text' }) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
// leer: sofort
|
||||
await fx.klick(knopf(root, 'muster-agb'))
|
||||
assert.equal(text(root, 'agb').value, 'MUSTER-AGB\nZeile zwei')
|
||||
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
|
||||
|
||||
// vorhandener Text: Rückfrage, Abbrechen lässt ihn stehen
|
||||
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
|
||||
const platz = root.querySelector('[data-frage-platz="widerrufsbelehrung"]')
|
||||
assert.match(platz.textContent, /Widerrufsbelehrung durch das Muster ersetzen\?/)
|
||||
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
|
||||
await fx.klick(knopf(root, 'muster-nein'))
|
||||
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
|
||||
assert.equal(platz.children.length, 0)
|
||||
|
||||
// und mit „Ja“ wird ersetzt
|
||||
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
|
||||
await fx.klick(knopf(root, 'muster-ja'))
|
||||
assert.equal(text(root, 'widerrufsbelehrung').value, 'MUSTER-WIDERRUF')
|
||||
assert.equal(platz.children.length, 0)
|
||||
|
||||
// Formular: leer ⇒ Muster
|
||||
await fx.klick(knopf(root, 'muster-widerrufsformular'))
|
||||
assert.equal(text(root, 'widerrufsformular').value, 'MUSTER-FORMULAR')
|
||||
assert.equal(sendet(fx, 'PUT').length, 0, 'Muster einsetzen speichert nichts')
|
||||
}))
|
||||
|
||||
test('Muster einsetzen: ist der Text schon das Muster, braucht es keine Rückfrage', mitDom({ get: () => antwort({ ...LEER, agb: 'MUSTER-AGB\nZeile zwei' }) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
await fx.klick(knopf(root, 'muster-agb'))
|
||||
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
|
||||
}))
|
||||
|
||||
/* ------------------------------------------------------------ Entwurf speichern --- */
|
||||
test('Entwurf speichern: Körper mit fünf Feldern, Antwort füllt die Felder, Meldung', mitDom({
|
||||
get: () => antwort(LEER),
|
||||
put: (aufruf) => ({ body: antwort({ ...aufruf.body.rechtstexte, agb: 'Serverfassung der AGB' }, { hash: 'hash-2' }) }),
|
||||
}, async (fx) => {
|
||||
const { root, log } = await oeffne(fx)
|
||||
await fx.setze(text(root, 'agb'), ' Meine AGB ')
|
||||
await fx.setze(text(root, 'widerrufsbelehrung'), 'Meine Belehrung')
|
||||
await fx.setze(text(root, 'widerrufsformular'), 'Mein Formular')
|
||||
await fx.setze(text(root, 'impressumUrl'), 'https://example.com/impressum')
|
||||
await fx.setze(text(root, 'datenschutzUrl'), 'https://example.com/datenschutz')
|
||||
await fx.klick(knopf(root, 'entwurf-speichern'))
|
||||
assert.deepEqual(sendet(fx, 'PUT').map((a) => a.body), [{ projectId: 'p1', rechtstexte: { ...VOLL, agb: 'Meine AGB' } }])
|
||||
assert.equal(text(root, 'agb').value, 'Serverfassung der AGB')
|
||||
assert.deepEqual(log.meldungen, [['Der Entwurf ist gespeichert.', 'ok']])
|
||||
}))
|
||||
|
||||
test('Entwurf speichern: Feldfehler des Servers stehen am Feld', mitDom({
|
||||
get: () => antwort(VOLL),
|
||||
put: () => ({ status: 400, body: { error: 'Ungültig.', fehler: { impressumUrl: 'Impressum: Die Adresse muss mit https:// beginnen.', agb: 'Die AGB darf höchstens 30000 Zeichen lang sein.' } } }),
|
||||
}, async (fx) => {
|
||||
const { root, log } = await oeffne(fx)
|
||||
await fx.setze(text(root, 'impressumUrl'), 'http://example.com/impressum')
|
||||
await fx.klick(knopf(root, 'entwurf-speichern'))
|
||||
assert.equal(root.querySelector('[data-fehler="impressumUrl"]').textContent, 'Impressum: Die Adresse muss mit https:// beginnen.')
|
||||
assert.equal(text(root, 'impressumUrl').getAttribute('aria-invalid'), 'true')
|
||||
assert.match(root.querySelector('[data-fehler="agb"]').textContent, /höchstens 30000/)
|
||||
assert.deepEqual(log.meldungen, [])
|
||||
assert.equal(knopf(root, 'entwurf-speichern').disabled, false)
|
||||
}))
|
||||
|
||||
/* ---------------------------------------------------------------- Bestätigen --- */
|
||||
test('Bestätigen: Haken nötig, dann POST mit dem Hash aus der letzten Antwort; danach Status bestätigt', async () => {
|
||||
let aktuell = antwort(VOLL, { hash: 'hash-abc' })
|
||||
const fx = startFx({
|
||||
get: () => aktuell,
|
||||
post: () => {
|
||||
aktuell = antwort(VOLL, { hash: 'hash-abc', bestaetigt: bestaetigt(true) })
|
||||
return { body: aktuell }
|
||||
},
|
||||
})
|
||||
try {
|
||||
const { root, log } = await oeffne(fx)
|
||||
assert.equal(root.querySelector('[data-block="bestaetigen"]').textContent.includes(BESTAETIGUNG), true)
|
||||
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
|
||||
assert.equal(knopf(root, 'bestaetigen').disabled, true, 'ohne Haken gesperrt')
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.equal(sendet(fx, 'POST').length, 0)
|
||||
|
||||
await fx.klick(haken(root))
|
||||
assert.equal(knopf(root, 'bestaetigen').disabled, false)
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.deepEqual(sendet(fx, 'POST').map((a) => [a.url, a.body]), [['/api/shop/rechtstexte/bestaetigen', { projectId: 'p1', hash: 'hash-abc' }]])
|
||||
assert.equal(root.querySelector('[data-status]').dataset.status, 'bestaetigt')
|
||||
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
|
||||
assert.deepEqual(log.meldungen, [['Die Texte sind bestätigt.', 'ok']])
|
||||
assert.equal(log.neuGeladen, 1)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('ungespeicherte Änderungen sperren das Bestätigen: „Erst speichern“, nach dem Speichern gilt der neue Hash', async () => {
|
||||
const fx = startFx({
|
||||
get: () => antwort(VOLL, { hash: 'alt', bestaetigt: bestaetigt(true) }),
|
||||
put: (aufruf) => ({ body: antwort(aufruf.body.rechtstexte, { hash: 'neu', bestaetigt: bestaetigt(false) }) }),
|
||||
post: () => ({ body: antwort(VOLL, { hash: 'neu', bestaetigt: bestaetigt(true) }) }),
|
||||
})
|
||||
try {
|
||||
const { root } = await oeffne(fx)
|
||||
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
|
||||
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
|
||||
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, false)
|
||||
assert.equal(knopf(root, 'bestaetigen').textContent, 'Erst speichern')
|
||||
assert.equal(knopf(root, 'bestaetigen').disabled, true)
|
||||
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /noch nicht gespeichert/)
|
||||
await fx.klick(haken(root))
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.equal(sendet(fx, 'POST').length, 0)
|
||||
|
||||
// zurückändern: wieder sauber
|
||||
await fx.setze(text(root, 'agb'), 'Meine AGB')
|
||||
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
|
||||
|
||||
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
|
||||
await fx.klick(knopf(root, 'entwurf-speichern'))
|
||||
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
|
||||
assert.equal(root.querySelector('[data-status]').dataset.status, 'unbestaetigt')
|
||||
assert.equal(haken(root).checked, false, 'nach neuen Texten muss der Haken neu gesetzt werden')
|
||||
assert.equal(knopf(root, 'bestaetigen').disabled, true)
|
||||
await fx.klick(haken(root))
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.deepEqual(sendet(fx, 'POST').map((a) => a.body), [{ projectId: 'p1', hash: 'neu' }])
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('unvollständige Texte lassen sich nicht bestätigen und sagen warum', mitDom({ get: () => antwort({ ...VOLL, datenschutzUrl: '' }) }, async (fx) => {
|
||||
const { root } = await oeffne(fx)
|
||||
await fx.klick(haken(root))
|
||||
assert.equal(knopf(root, 'bestaetigen').disabled, true)
|
||||
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /ausgefüllt und gespeichert/)
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.equal(sendet(fx, 'POST').length, 0)
|
||||
}))
|
||||
|
||||
test('409 beim Bestätigen (Texte inzwischen geändert): Meldung, neuer Stand, Haken wieder frei', async () => {
|
||||
let stand = antwort(VOLL, { hash: 'alt' })
|
||||
const fx = startFx({
|
||||
get: () => stand,
|
||||
post: () => {
|
||||
stand = antwort({ ...VOLL, agb: 'Von anderer Seite geändert' }, { hash: 'neu' })
|
||||
return { status: 409, body: { error: 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', code: 'texte_geaendert' } }
|
||||
},
|
||||
})
|
||||
try {
|
||||
const { root } = await oeffne(fx)
|
||||
await fx.klick(haken(root))
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.match(root.querySelector('[data-fehler="bestaetigung"]').textContent, /inzwischen geändert/)
|
||||
assert.equal(text(root, 'agb').value, 'Von anderer Seite geändert')
|
||||
assert.equal(haken(root).checked, false)
|
||||
await fx.klick(haken(root))
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.equal(sendet(fx, 'POST').at(-1).body.hash, 'neu')
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('Fehler beim Bestätigen (zum Beispiel Kundenansicht, 403) steht am Block', mitDom({
|
||||
get: () => antwort(VOLL),
|
||||
post: () => ({ status: 403, body: { error: 'In der Kundenansicht kannst Du nichts bestätigen.' } }),
|
||||
}, async (fx) => {
|
||||
const { root, log } = await oeffne(fx)
|
||||
await fx.klick(haken(root))
|
||||
await fx.klick(knopf(root, 'bestaetigen'))
|
||||
assert.equal(root.querySelector('[data-fehler="bestaetigung"]').textContent, 'In der Kundenansicht kannst Du nichts bestätigen.')
|
||||
assert.deepEqual(log.meldungen, [])
|
||||
assert.equal(sendet(fx, 'GET').length, 1, 'bei 403 wird nicht neu geladen')
|
||||
}))
|
||||
|
||||
/* -------------------------------------------------------------- nur lesen --- */
|
||||
test('nurLesen: Felder gesperrt, keine Knöpfe, kein Bestätigungsblock; Status und Lesetexte bleiben', mitDom({ get: () => antwort(VOLL, { bestaetigt: bestaetigt(false) }) }, async (fx) => {
|
||||
const { root } = await oeffne(fx, { nurLesen: true })
|
||||
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
|
||||
assert.equal(steuerungen.length, 5)
|
||||
assert.deepEqual(steuerungen.filter((el) => !el.disabled), [])
|
||||
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').length, 0)
|
||||
assert.equal(haken(root), null)
|
||||
assert.equal(text(root, 'agb').value, 'Meine AGB')
|
||||
assert.ok(root.querySelector('[data-status="unbestaetigt"]'))
|
||||
assert.equal(root.querySelector('[data-lesetext="versand-zahlung"]').textContent.includes('Zahlung per Karte'), true)
|
||||
await fx.sende(root.querySelector('form'), 'submit')
|
||||
assert.equal(sendet(fx, 'PUT').length, 0)
|
||||
}))
|
||||
|
||||
test('Ladefehler wird geworfen', async () => {
|
||||
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
|
||||
try {
|
||||
await assert.rejects(render(h('div'), { projectId: 'p1', request }), /Kaputt\./)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------------------------------------------------------------- statisch --- */
|
||||
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, confirm() und kein Import aus termine*.js', () => {
|
||||
const quelle = readFileSync(new URL('../public/shop-tab-rechtliches.js', import.meta.url), 'utf8')
|
||||
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
|
||||
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
|
||||
})
|
||||
Reference in new issue
Block a user