Files
Webklar-Kundenbereich/test/shopTabArtikel.test.js
T

579 lines
32 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { euroText, h, request } from '../public/shop-dom.js'
import { render } from '../public/shop-tab-artikel.js'
// Task G4: Reiter „Artikel“ (public/shop-tab-artikel.js) mit Fake-DOM und Fake-fetch: Liste, Aktiv-Schalter, Sortierung, Editor (Anlegen, Varianten,
// Bilder mit rohem Upload, Feldfehler am richtigen Feld), Loeschen mit Rueckfrage, nurLesen. Die Server-Antworten sind von Hand gebaut
// (Form: server/routes/shop.js produktAnsicht, server/services/shop/produkte.js).
const P = 'p1'
const vorschau = (id) => ({ id, klein: `/api/shop/bild/${P}/${id}-s.webp`, gross: `/api/shop/bild/${P}/${id}-l.webp` })
/** Artikel wie ihn GET /api/shop/produkte liefert (alle gespeicherten Felder plus id, sort, bildUrls). */
const artikel = (id, name, extra = {}) => {
const a = {
id,
name,
kategorie: '',
kurz: '',
beschreibung: '',
preisCent: 1250,
steuersatz: 19,
grundpreis: null,
bestand: null,
aktiv: true,
sort: 0,
varianten: null,
bilder: [],
lieferart: 'beides',
widerruf: 'gesetzlich',
sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' },
lebensmittel: { aktiv: false, pflichtangaben: '' },
...extra,
}
a.bildUrls = a.bilder.map(vorschau)
return a
}
/** Der Artikel so, wie er beim Speichern zum Server geht (ohne id, sort, bildUrls). */
const koerper = (a, extra = {}) => {
const { id, sort, bildUrls, ...rest } = a
void id; void sort; void bildUrls
return { ...rest, ...extra }
}
const LEER_KOERPER = {
name: '', kategorie: '', kurz: '', beschreibung: '', preisCent: null, steuersatz: 19, grundpreis: null, bestand: null, aktiv: true, varianten: null, bilder: [],
lieferart: 'beides', widerruf: 'gesetzlich', sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, lebensmittel: { aktiv: false, pflichtangaben: '' },
}
/**
* Fake-Server: GET produkte und einstellungen aus den Parametern, alles andere aus sonst(aufruf) (Standard 200 {}).
* Der Upload (POST /api/shop/bild, rohe Bytes) laeuft am Fake-fetch vorbei: Aufrufe stehen in uploads, die Antwort kommt aus upload(aufruf)
* (Objekt oder Promise davon; Standard 201 { imageId: 'neu1' }), weil fakeDom nur JSON-Koerper kennt.
*/
function starte({ produkte = [], einstellungen = { kleinunternehmer: false }, nurLesen = false, sonst = () => null, upload, maxBilder = 4 } = {}) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/produkte?projectId=${P}`) return { body: { produkte, maxArtikel: 500, maxBilder } }
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/einstellungen?projectId=${P}`) return { body: { einstellungen, version: 1, vollstaendig: true } }
return sonst(aufruf) || { body: {} }
}
const fx = installFakeDom({ antworten: Array.from({ length: 80 }, () => route) })
const uploads = []
const echterFetch = globalThis.fetch
let zaehler = 0
globalThis.fetch = async (url, optionen = {}) => {
if (optionen.headers?.['Content-Type'] !== 'application/octet-stream') return echterFetch(url, optionen)
const aufruf = { method: optionen.method, url: String(url), datei: optionen.body, credentials: optionen.credentials }
uploads.push(aufruf)
zaehler += 1
const antwort = await (upload ? upload(aufruf, zaehler) : { status: 201, body: { imageId: `neu${zaehler}` } })
const { status = 201, body = {} } = antwort
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
const meldungen = []
const panel = h('div', { class: 'sh-panel' })
fx.document.body.append(panel)
const ctx = { projectId: P, nurLesen, aktuell: () => true, request, zeigeMeldung: (text, art) => meldungen.push({ text, art: art || 'ok' }) }
return { fx, panel, ctx, uploads, meldungen }
}
const mit = (optionen, fn) => async () => {
const t = starte(optionen)
try {
await render(t.panel, t.ctx)
await t.fx.ruhe()
await fn(t)
} finally {
t.fx.restore()
}
}
const aenderungen = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const knopf = (fx, wurzel, aktion) => fx.finde(wurzel, `[data-aktion="${aktion}"]`)
const feldVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-feld="${name}"]`)
const fehlerVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-fehler="${name}"]`)
const zeile = (fx, wurzel, id) => fx.finde(wurzel, `[data-artikel="${id}"]`)
const bereich = (fx, wurzel, name) => fx.finde(wurzel, `[data-bereich="${name}"]`)
const datei = (name, typ, groesse) => ({ name, type: typ, size: groesse })
const DREI = () => [
artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'] }),
artikel('a2', 'Schal', { kategorie: 'Winter', sort: 1, aktiv: false, bestand: 0 }),
artikel('a3', 'Handschuhe', { kategorie: 'Sommer', sort: 2, preisCent: null, varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'L', preisCent: 1000, bestand: null }] } }),
]
/* ------------------------------------------------------------------ Liste --- */
test('Liste: Name, Preis, Bestand, Schalter „aktiv“, Hoch/Runter, Bearbeiten und Löschen je Artikel', mit({ produkte: DREI() }, async ({ fx, panel }) => {
assert.deepEqual(fx.fetchLog.map((a) => a.url).sort(), [`/api/shop/einstellungen?projectId=${P}`, `/api/shop/produkte?projectId=${P}`])
const reihenfolge = fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel'))
assert.deepEqual(reihenfolge, ['a1', 'a2', 'a3'])
const a1 = zeile(fx, panel, 'a1')
assert.match(fx.text(a1), /Mütze/)
assert.equal(fx.text(a1).includes(euroText(1250)), true)
assert.equal(fx.text(fx.finde(a1, '[data-bestand]')), '5 Stück')
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a2'), '[data-bestand]')), 'ausverkauft')
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a1'), '.sh-artikel-preis')), euroText(1250))
const a3 = zeile(fx, panel, 'a3')
assert.equal(fx.text(fx.finde(a3, '.sh-artikel-preis')), `ab ${euroText(800)}`, 'Varianten: ab dem kleinsten Preis')
assert.equal(fx.text(fx.finde(a3, '[data-bestand]')), 'unbegrenzt', 'ein Wert ohne Bestandsgrenze ⇒ unbegrenzt')
assert.equal(knopf(fx, a1, 'aktiv').checked, true)
assert.equal(knopf(fx, zeile(fx, panel, 'a2'), 'aktiv').checked, false)
assert.equal(knopf(fx, a1, 'hoch').disabled, true, 'der erste kann nicht höher')
assert.equal(knopf(fx, a3, 'runter').disabled, true, 'der letzte kann nicht tiefer')
assert.equal(knopf(fx, a1, 'runter').disabled, false)
for (const a of ['bearbeiten', 'loeschen']) assert.ok(knopf(fx, a1, a), a)
assert.equal(fx.finde(a1, 'img').getAttribute('src'), `/api/shop/bild/${P}/b1-s.webp`, 'Vorschau des Titelbilds')
assert.ok(knopf(fx, panel, 'artikel-neu'))
assert.match(fx.text(panel), /3 von höchstens 500 Artikeln/)
assert.deepEqual(aenderungen(fx), [])
}))
test('Liste ohne Artikel: Hinweis und Knopf „Artikel anlegen“, der den Editor öffnet', mit({ produkte: [] }, async ({ fx, panel }) => {
assert.match(fx.text(panel), /Du hast noch keine Artikel/)
assert.equal(fx.text(knopf(fx, panel, 'artikel-neu')), 'Artikel anlegen')
assert.equal(fx.finde(panel, '[data-artikel]'), null)
await fx.klick(knopf(fx, panel, 'artikel-neu'))
assert.ok(fx.finde(panel, 'form'), 'Editor statt Liste')
assert.equal(knopf(fx, panel, 'artikel-neu'), null)
assert.match(fx.text(panel), /Artikel anlegen/)
await fx.klick(knopf(fx, panel, 'abbrechen'))
assert.equal(fx.finde(panel, 'form'), null)
assert.ok(knopf(fx, panel, 'artikel-neu'))
}))
test('Laden schlägt fehl: Fehlerkarte mit „Noch einmal versuchen“', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Interner Serverfehler' } }, { status: 500, body: { error: 'Interner Serverfehler' } }] })
try {
const panel = h('div')
fx.document.body.append(panel)
await render(panel, { projectId: P, nurLesen: false, request, zeigeMeldung() {} })
assert.match(fx.text(panel), /Interner Serverfehler/)
assert.match(fx.text(panel), /Noch einmal versuchen/)
} finally {
fx.restore()
}
})
/* ------------------------------------------------------------------ Schalter --- */
test('Schalter „aktiv“: PUT mit dem ganzen Artikel und umgedrehtem aktiv, danach zeigt die Liste den neuen Stand', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'], aktiv: false }) } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv'))
const [put, ...rest] = aenderungen(fx)
assert.equal(rest.length, 0)
assert.equal(put.method, 'PUT')
assert.equal(put.url, '/api/shop/produkte/a1')
assert.deepEqual(put.body, { projectId: P, produkt: koerper(DREI()[0], { aktiv: false }) })
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, false)
assert.match(meldungen.at(-1).text, /Mütze.*inaktiv/)
}))
test('Schalter „aktiv“ schlägt fehl: Fehlermeldung, Schalter springt zurück', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { status: 500, body: { error: 'Interner Serverfehler' } } : null),
}, async ({ fx, panel, meldungen }) => {
const schalter = knopf(fx, zeile(fx, panel, 'a1'), 'aktiv')
await fx.klick(schalter)
assert.deepEqual(meldungen.at(-1), { text: 'Interner Serverfehler', art: 'fehler' })
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, true)
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').disabled, false)
}))
/* ---------------------------------------------------------------- Sortierung --- */
test('Runter und Hoch: POST sortierung mit der neuen Reihenfolge, die Liste folgt der Antwort', mit({
produkte: DREI(),
sonst: (a) => {
if (a.url !== '/api/shop/produkte/sortierung') return null
const nach = (id) => DREI().find((x) => x.id === id)
return { body: { produkte: a.body.ids.map(nach) } }
},
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'runter'))
assert.deepEqual(aenderungen(fx).at(-1), { method: 'POST', url: '/api/shop/produkte/sortierung', body: { projectId: P, ids: ['a2', 'a1', 'a3'] } })
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a1', 'a3'])
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'hoch'))
assert.deepEqual(aenderungen(fx).at(-1).body.ids, ['a2', 'a3', 'a1'])
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a3', 'a1'])
}))
/* -------------------------------------------------------------------- Anlegen --- */
test('Anlegen: Felder, Preis aus „12,5“, Grundpreis, Datalist mit vorhandenen Kategorien, POST mit allem', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Kerze', { sort: 3, preisCent: 1250 }) } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const kategorienOptionen = fx.alle(form, 'datalist option').map((o) => o.getAttribute('value'))
assert.deepEqual(kategorienOptionen, ['Winter', 'Sommer'])
assert.equal(feldVon(fx, form, 'kategorie').getAttribute('list'), fx.finde(form, 'datalist').id)
assert.equal(bereich(fx, form, 'steuersatz').hidden, false, 'kein Kleinunternehmer ⇒ Steuersatz sichtbar')
assert.equal(bereich(fx, form, 'varianten').hidden, true)
await fx.setze(feldVon(fx, form, 'name'), 'Kerze')
await fx.setze(feldVon(fx, form, 'kategorie'), 'Deko')
await fx.setze(feldVon(fx, form, 'kurz'), 'Duftet gut')
await fx.setze(feldVon(fx, form, 'beschreibung'), 'Erster Absatz\n\nZweiter Absatz')
await fx.setze(feldVon(fx, form, 'preisCent'), '12,5')
assert.equal(feldVon(fx, form, 'preisCent').value, '12,50')
await fx.setze(feldVon(fx, form, 'steuersatz'), '7')
await fx.setze(feldVon(fx, form, 'grundpreis.menge'), '500')
await fx.setze(feldVon(fx, form, 'grundpreis.einheit'), 'g')
await fx.setze(feldVon(fx, form, 'bestand'), '8')
await fx.setze(feldVon(fx, form, 'lieferart'), 'abholung')
await fx.setze(feldVon(fx, form, 'widerruf'), 'verderblich')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx), [{
method: 'POST',
url: '/api/shop/produkte',
body: {
projectId: P,
produkt: {
...LEER_KOERPER,
name: 'Kerze',
kategorie: 'Deko',
kurz: 'Duftet gut',
beschreibung: 'Erster Absatz\n\nZweiter Absatz',
preisCent: 1250,
steuersatz: 7,
grundpreis: { menge: 500, einheit: 'g' },
bestand: 8,
lieferart: 'abholung',
widerruf: 'verderblich',
},
},
}])
assert.equal(fx.finde(panel, 'form'), null, 'zurück zur Liste')
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a2', 'a3', 'neu'])
assert.deepEqual(meldungen.at(-1), { text: 'Der Artikel wurde angelegt.', art: 'ok' })
}))
test('Kleinunternehmer: Steuersatz ausgeblendet, gesendet wird 0; Bestand leer ⇒ unbegrenzt (null)', mit({
einstellungen: { kleinunternehmer: true },
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'X') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
assert.equal(bereich(fx, form, 'steuersatz').hidden, true)
await fx.setze(feldVon(fx, form, 'name'), 'X')
await fx.setze(feldVon(fx, form, 'preisCent'), '3')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.equal(produkt.steuersatz, 0)
assert.equal(produkt.bestand, null)
assert.equal(produkt.preisCent, 300)
}))
test('Produktsicherheit und Lebensmittel: Hersteller „ein anderer“, EU-Verantwortlicher nur mit Haken, Pflichtangaben nur für Lebensmittel', mit({
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Honig') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
assert.equal(bereich(fx, form, 'hersteller').hidden, true)
assert.equal(bereich(fx, form, 'eu').hidden, true)
assert.equal(bereich(fx, form, 'lebensmittel').hidden, true)
await fx.setze(feldVon(fx, form, 'sicherheit.herstellerArt'), 'andere')
assert.equal(bereich(fx, form, 'hersteller').hidden, false)
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.name'), 'Imkerei Berg')
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.anschrift'), 'Dorfstr. 1, 12345 Berg')
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.kontakt'), 'berg@example.test')
await fx.klick(feldVon(fx, form, 'sicherheit.ausserhalbEu'))
assert.equal(bereich(fx, form, 'eu').hidden, false)
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.name'), 'EU Vertretung GmbH')
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.anschrift'), 'Hauptstr. 2, 10115 Berlin')
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.kontakt'), 'eu@example.test')
await fx.setze(feldVon(fx, form, 'sicherheit.hinweise'), 'Nicht für Kinder unter 1 Jahr.')
await fx.klick(feldVon(fx, form, 'lebensmittel.aktiv'))
assert.equal(bereich(fx, form, 'lebensmittel').hidden, false)
assert.match(fx.text(form), /Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, ggf\. Hersteller/)
await fx.setze(feldVon(fx, form, 'lebensmittel.pflichtangaben'), 'Honig, 250 g, enthält keine Allergene')
await fx.setze(feldVon(fx, form, 'name'), 'Honig')
await fx.setze(feldVon(fx, form, 'preisCent'), '6,90')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.deepEqual(produkt.sicherheit, {
hersteller: { name: 'Imkerei Berg', anschrift: 'Dorfstr. 1, 12345 Berg', kontakt: 'berg@example.test' },
euVerantwortlicher: { name: 'EU Vertretung GmbH', anschrift: 'Hauptstr. 2, 10115 Berlin', kontakt: 'eu@example.test' },
hinweise: 'Nicht für Kinder unter 1 Jahr.',
})
assert.deepEqual(produkt.lebensmittel, { aktiv: true, pflichtangaben: 'Honig, 250 g, enthält keine Allergene' })
}))
/* ------------------------------------------------------------------ Varianten --- */
test('Varianten: Schalter blendet Preis und Bestand des Artikels aus, Zeilen hinzufügen und entfernen, Körper mit Werten', mit({
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Shirt') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
await fx.setze(feldVon(fx, form, 'preisCent'), '99')
await fx.setze(feldVon(fx, form, 'bestand'), '4')
await fx.klick(feldVon(fx, form, 'variantenAn'))
assert.equal(bereich(fx, form, 'varianten').hidden, false)
assert.equal(bereich(fx, form, 'einzelpreis').hidden, true)
assert.equal(bereich(fx, form, 'einzelbestand').hidden, true)
assert.equal(fx.alle(form, '[data-wert]').length, 1, 'eine leere Zeile zum Start')
await fx.setze(feldVon(fx, form, 'varianten.name'), 'Größe')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.name'), 'S')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.preisCent'), '10')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.bestand'), '5')
await fx.klick(knopf(fx, form, 'wert-neu'))
await fx.klick(knopf(fx, form, 'wert-neu'))
assert.equal(fx.alle(form, '[data-wert]').length, 3)
await fx.setze(feldVon(fx, form, 'varianten.werte.1.name'), 'M')
await fx.setze(feldVon(fx, form, 'varianten.werte.1.preisCent'), '12,5')
await fx.setze(feldVon(fx, form, 'varianten.werte.2.name'), 'weg')
await fx.klick(fx.alle(form, '[data-aktion="wert-weg"]')[2])
assert.equal(fx.alle(form, '[data-wert]').length, 2)
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'M', 'Eingaben bleiben beim Neuzeichnen erhalten')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt, {
...LEER_KOERPER,
name: 'Shirt',
preisCent: null,
bestand: null,
varianten: { name: 'Größe', werte: [{ name: 'S', preisCent: 1000, bestand: 5 }, { name: 'M', preisCent: 1250, bestand: null }] },
})
}))
test('Varianten ausschalten: der Körper trägt wieder Preis und Bestand des Artikels, varianten = null', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
assert.equal(feldVon(fx, form, 'variantenAn').checked, true)
assert.equal(feldVon(fx, form, 'varianten.name').value, 'Größe')
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'L')
assert.equal(feldVon(fx, form, 'varianten.werte.0.preisCent').value, '8,00')
await fx.klick(feldVon(fx, form, 'variantenAn'))
await fx.setze(feldVon(fx, form, 'preisCent'), '9,99')
await fx.klick(knopf(fx, form, 'speichern'))
const put = aenderungen(fx)[0]
assert.equal(put.url, '/api/shop/produkte/a3')
assert.equal(put.body.produkt.varianten, null)
assert.equal(put.body.produkt.preisCent, 999)
}))
test('Bearbeiten: Werte-IDs der Varianten bleiben im Körper erhalten', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
await fx.klick(knopf(fx, fx.finde(panel, 'form'), 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt.varianten.werte.map((w) => w.id), ['aaaaaaaa', 'bbbbbbbb'])
assert.deepEqual(aenderungen(fx)[0].body.produkt, koerper(DREI()[2]))
}))
/* --------------------------------------------------------------------- Bilder --- */
test('Bilder: roher Upload, Vorschau, „wird hochgeladen“, Titelbild, Entfernen, Bild-IDs im Körper', async () => {
let freigabe
const t = starte({
produkte: [],
upload: (aufruf, n) => (n === 1 ? new Promise((ok) => { freigabe = () => ok({ status: 201, body: { imageId: 'bild1', w: 800, h: 600 } }) }) : { status: 201, body: { imageId: `bild${n}` } }),
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Tasche') } } : null),
})
const { fx, panel, uploads } = t
try {
await render(panel, t.ctx)
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
assert.match(eingabe.getAttribute('accept'), /image\/jpeg,image\/png,image\/webp/)
const erste = datei('a.jpg', 'image/jpeg', 1000)
eingabe.files = [erste]
const lauf = fx.sende(eingabe, 'change')
await fx.ruhe()
assert.match(fx.text(fx.finde(form, '[data-bild-status]')), /wird hochgeladen/)
assert.equal(eingabe.disabled, true)
assert.equal(knopf(fx, form, 'speichern').disabled, true, 'während des Uploads nicht speichern')
freigabe()
await lauf
assert.equal(uploads.length, 1)
assert.deepEqual(uploads[0], { method: 'POST', url: `/api/shop/bild?projectId=${P}`, datei: erste, credentials: 'same-origin' }, 'rohe Datei, kein JSON')
assert.equal(fx.text(fx.finde(form, '[data-bild-status]')), '')
assert.equal(eingabe.disabled, false)
assert.equal(knopf(fx, form, 'speichern').disabled, false)
assert.equal(fx.finde(form, '[data-bild="bild1"] img').getAttribute('src'), `/api/shop/bild/${P}/bild1-s.webp`)
assert.match(fx.text(fx.finde(form, '[data-bild="bild1"]')), /Titelbild/)
for (const name of ['b.png', 'c.webp']) {
eingabe.files = [datei(name, name.endsWith('png') ? 'image/png' : 'image/webp', 2000)]
await fx.sende(eingabe, 'change')
}
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild1', 'bild2', 'bild3'])
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'))
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild1', 'bild2'], 'erstes = Titelbild')
assert.equal(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'), null, 'das Titelbild hat den Knopf nicht')
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild1"]'), 'bild-weg'))
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild2'])
await fx.setze(feldVon(fx, form, 'name'), 'Tasche')
await fx.setze(feldVon(fx, form, 'preisCent'), '20')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt.bilder, ['bild3', 'bild2'])
} finally {
fx.restore()
}
})
test('Bilder: höchstens vier, danach verschwindet das Feld; zu großes oder falsches Bild wird nicht gesendet', mit({ maxBilder: 2 }, async ({ fx, panel, uploads }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
eingabe.files = [datei('riesig.jpg', 'image/jpeg', 9 * 1024 * 1024)]
await fx.sende(eingabe, 'change')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
eingabe.files = [datei('x.gif', 'image/gif', 100)]
await fx.sende(eingabe, 'change')
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
assert.equal(uploads.length, 0)
for (const n of [1, 2]) {
eingabe.files = [datei(`${n}.jpg`, 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
}
assert.equal(fx.alle(form, '[data-bild]').length, 2)
assert.equal(eingabe.parentNode.hidden, true, 'Limit erreicht ⇒ kein weiteres Bild')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, '', 'ein neuer Versuch löscht den alten Fehler')
}))
test('Bilder: Serverfehler 413 und 415 stehen am Bilder-Abschnitt, kein Bild kommt dazu', async () => {
const antworten = [{ status: 413, body: { error: 'Das Bild ist größer als 8 MB.' } }, { status: 415, body: {} }]
const t = starte({ upload: () => antworten.shift() })
try {
await render(t.panel, t.ctx)
const { fx, panel } = t
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
eingabe.files = [datei('a.jpg', 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
eingabe.files = [datei('b.jpg', 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
assert.equal(fx.alle(form, '[data-bild]').length, 0)
assert.equal(eingabe.disabled, false)
} finally {
t.fx.restore()
}
})
/* ----------------------------------------------------------------- Feldfehler --- */
test('Feldfehler des Servers: Punkt-Pfade stehen am richtigen Feld, zugeklappte Bereiche gehen auf, der Fokus geht zum ersten Fehler', mit({
produkte: [artikel('a1', 'Shirt', {
preisCent: null,
grundpreis: { menge: 500, einheit: 'g' },
varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: null }, { id: 'cccccccc', name: 'L', preisCent: 1000, bestand: null }] },
sicherheit: { hersteller: { name: 'Fabrik', anschrift: '', kontakt: 'f@example.test' }, euVerantwortlicher: null, hinweise: '' },
})],
sonst: (a) => (a.method === 'PUT'
? {
status: 400,
body: {
error: 'Bitte prüfe deine Eingaben.',
fehler: {
name: 'Bitte gib einen Namen für den Artikel an.',
'grundpreis.menge': 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.',
'varianten.werte.2.preisCent': 'Bitte gib den Preis des Werts als Betrag über 0 € an.',
'sicherheit.hersteller.anschrift': 'Bitte gib die Anschrift bei den Hersteller an.',
bilder: 'Eines der Bilder gibt es nicht (mehr).',
unbekannt: 'Irgendetwas stimmt nicht.',
},
},
}
: null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
const sicherheit = fx.finde(form, 'details')
assert.equal(sicherheit.hasAttribute('open'), true, 'Hersteller gesetzt ⇒ Produktsicherheit offen')
const lebensmittel = fx.alle(form, 'details')[1]
assert.equal(lebensmittel.hasAttribute('open'), false)
await fx.klick(knopf(fx, form, 'speichern'))
assert.equal(fehlerVon(fx, form, 'name').textContent, 'Bitte gib einen Namen für den Artikel an.')
assert.equal(fehlerVon(fx, form, 'grundpreis.menge').textContent, 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.')
assert.equal(fehlerVon(fx, form, 'varianten.werte.2.preisCent').textContent, 'Bitte gib den Preis des Werts als Betrag über 0 € an.')
assert.equal(fehlerVon(fx, form, 'sicherheit.hersteller.anschrift').textContent, 'Bitte gib die Anschrift bei den Hersteller an.')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Eines der Bilder gibt es nicht (mehr).')
assert.equal(fehlerVon(fx, form, '_').textContent, 'Irgendetwas stimmt nicht.', 'Fehler ohne Feld kommen allgemein oben')
for (const n of ['name', 'grundpreis.menge', 'varianten.werte.2.preisCent', 'sicherheit.hersteller.anschrift']) {
assert.equal(feldVon(fx, form, n).getAttribute('aria-invalid'), 'true', n)
}
assert.equal(feldVon(fx, form, 'varianten.werte.0.name').getAttribute('aria-invalid'), null)
assert.equal(fx.document.activeElement, feldVon(fx, form, 'name'), 'Fokus auf dem ersten Fehler')
assert.ok(fx.finde(panel, 'form'), 'der Editor bleibt offen, die Eingaben auch')
assert.equal(knopf(fx, form, 'speichern').disabled, false)
// ein neuer Versuch räumt die alten Fehler weg
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
fx.antworten.unshift(() => ({ body: { produkt: artikel('a1', 'Shirt') } }))
await fx.klick(knopf(fx, form, 'speichern'))
assert.equal(fx.finde(panel, 'form'), null)
}))
test('Höchstzahl der Artikel: die Meldung des Servers steht im Editor', mit({
sonst: (a) => (a.method === 'POST' ? { status: 409, body: { error: 'Du hast die Höchstzahl von 500 Artikeln erreicht. Lösche einen Artikel, bevor du einen neuen anlegst.' } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'name'), 'Noch einer')
await fx.setze(feldVon(fx, form, 'preisCent'), '1')
await fx.klick(knopf(fx, form, 'speichern'))
assert.match(fehlerVon(fx, form, '_').textContent, /Höchstzahl von 500 Artikeln/)
}))
/* -------------------------------------------------------------------- Löschen --- */
test('Löschen: Rückfrage in der Seite, Abbrechen tut nichts, „Ja, löschen“ schickt DELETE und entfernt die Zeile', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'DELETE' ? { body: { ok: true } } : null),
}, async ({ fx, panel, meldungen }) => {
const a2 = zeile(fx, panel, 'a2')
await fx.klick(knopf(fx, a2, 'loeschen'))
assert.match(fx.text(a2), /„Schal“ wirklich löschen\?/)
assert.deepEqual(aenderungen(fx), [], 'noch nichts gelöscht')
await fx.klick(knopf(fx, a2, 'loeschen-nein'))
assert.equal(fx.finde(a2, '.sh-frage-box'), null)
assert.deepEqual(aenderungen(fx), [])
await fx.klick(knopf(fx, a2, 'loeschen'))
await fx.klick(knopf(fx, a2, 'loeschen-ja'))
assert.deepEqual(aenderungen(fx), [{ method: 'DELETE', url: `/api/shop/produkte/a2?projectId=${P}`, body: undefined }])
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a3'])
assert.match(meldungen.at(-1).text, /Schal.*gelöscht/)
}))
test('Löschen schlägt fehl: Meldung, Zeile bleibt', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'DELETE' ? { status: 404, body: { error: 'Diesen Artikel gibt es nicht (mehr).' } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen'))
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen-ja'))
assert.deepEqual(meldungen.at(-1), { text: 'Diesen Artikel gibt es nicht (mehr).', art: 'fehler' })
assert.ok(zeile(fx, panel, 'a2'))
}))
/* ------------------------------------------------------------------- nurLesen --- */
test('nurLesen: nur die Liste, keine Knöpfe und keine Schalter', mit({ produkte: DREI(), nurLesen: true }, async ({ fx, panel }) => {
assert.equal(fx.finde(panel, 'button'), null)
assert.equal(fx.finde(panel, 'input'), null)
assert.match(fx.text(zeile(fx, panel, 'a1')), /Mütze/)
assert.match(fx.text(zeile(fx, panel, 'a1')), /aktiv/)
assert.match(fx.text(zeile(fx, panel, 'a2')), /inaktiv/)
}))
test('nurLesen ohne Artikel: Hinweis ohne „Artikel anlegen“', mit({ produkte: [], nurLesen: true }, async ({ fx, panel }) => {
assert.match(fx.text(panel), /Es gibt noch keine Artikel\./)
assert.equal(fx.finde(panel, 'button'), null)
}))
/* --------------------------------------------------------------------- Statik --- */
test('Statik: kein innerHTML, outerHTML, insertAdjacentHTML oder document.write; Fremdtext nur über h()', () => {
const quelle = readFileSync(new URL('../public/shop-tab-artikel.js', import.meta.url), 'utf8')
const code = quelle.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').filter((z) => !z.trim().startsWith('//')).join('\n')
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function']) {
assert.equal(code.includes(verboten), false, verboten)
}
assert.doesNotMatch(code, /from '\.\/termine/, 'keine Importe aus termine*.js')
})