Shop G4: Reiter Artikel mit Liste (Hoch/Runter, Aktiv-Schalter, Löschen mit Rückfrage), Editor (Preis, Grundpreis, Varianten, Bilder mit Upload, Produktsicherheit, Lebensmittel) und Feldfehlern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
648d0e07fe
commit
cc668d3765
3 files changed
+1157
-5
No files matched your search
@@ -0,0 +1,578 @@
|
||||
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')
|
||||
})
|
||||
Reference in new issue
Block a user