Terminplaner: Kunden-Schnittstelle
/api/termine: Einstellungen (Vorlage, PATCH mit Version, Feed-Link), Buchungen (Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Protokoll der Kundenansicht. Pflichtangaben fuer Gaeste (Telefon immer, Anschrift bei Widerrufsbelehrung) in normalisiereEinstellung, Entwurf aus Vorlagen, kontextPerSiteKey ohne Pflichtangaben nicht buchbar; speichereEinstellung nimmt eine Funktion unter der Sperre. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e8fd33713
commit
e3e198fb1a
10 files changed
+1766
-40
No files matched your search
@@ -36,6 +36,7 @@ const MOUNTS = [
|
||||
['/api/abo/takeover-credits', (await import('../server/routes/takeoverCredits.js')).default],
|
||||
['/api/abo', (await import('../server/routes/abo.js')).default],
|
||||
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
|
||||
['/api/termine', (await import('../server/routes/termine.js')).default],
|
||||
]
|
||||
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
|
||||
const oeffentlich = (await import('../server/routes/publicTermine.js')).default
|
||||
|
||||
@@ -305,13 +305,23 @@ test('config: ohne Widerrufsrecht (nur Unternehmer) kein widerruf und keine Bele
|
||||
assert.deepEqual(Object.keys(body.texte).sort(), ['erinnerung', 'gesundheit', 'storno', 'vorzeitig'])
|
||||
})
|
||||
|
||||
test('config: eine nicht zu erzeugende Belehrung (Anschrift fehlt) ist 503 statt einer Buchung ohne Belehrung', async () => {
|
||||
const e = einstellung()
|
||||
e.betrieb.adresse = ''
|
||||
await speichere(e)
|
||||
const res = await api('GET', `/${SITE_KEY}/config`)
|
||||
assert.equal(res.status, 503)
|
||||
assert.deepEqual(res.body, NICHT_ERREICHBAR)
|
||||
test('config: ohne Anschrift (Belehrung nicht zu erzeugen) oder Telefon ist die Funktion fuer Gaeste nicht aktiv (R41), nie eine Buchung ohne Belehrung', async () => {
|
||||
for (const felder of [{ adresse: '' }, { telefon: '' }]) {
|
||||
const e = einstellung()
|
||||
Object.assign(e.betrieb, felder) // wie ein Entwurf aus einer Vorlage, gespeichert ohne strenge Pruefung
|
||||
await speichere(e)
|
||||
for (const [methode, pfad, body] of [
|
||||
['GET', 'config'],
|
||||
['GET', `tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`],
|
||||
['GET', `zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`],
|
||||
['POST', 'buchungen', buchungBody()],
|
||||
]) {
|
||||
const res = await api(methode, `/${SITE_KEY}/${pfad}`, body ? { body } : {})
|
||||
assert.equal(res.status, 200, `${JSON.stringify(felder)} ${pfad}`)
|
||||
assert.deepEqual(res.body, AKTIV_FALSE, `${JSON.stringify(felder)} ${pfad}`)
|
||||
}
|
||||
assert.equal(aw.docs('termineBookings').size, 0, 'nichts gebucht')
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Unbekannt, aus, abgeschaltet
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
preisText,
|
||||
preisHinweis,
|
||||
zahlungText,
|
||||
fehlendePflichtangaben,
|
||||
zeigtWiderruf,
|
||||
} from '../server/services/termine/einstellungen.js'
|
||||
import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
|
||||
import { BUNDESLAENDER } from '../server/services/termine/feiertage.js'
|
||||
@@ -22,9 +24,12 @@ const R2 = 'r_aaaaaaa2'
|
||||
const S1 = 's_bbbbbbb1'
|
||||
const S2 = 's_bbbbbbb2'
|
||||
|
||||
/** Pflichtangaben fuer Gaeste (R13/R13a): Telefon immer, Anschrift bei Widerrufsbelehrung. */
|
||||
const PFLICHT = { telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler' }
|
||||
|
||||
/** Kleinste gueltige Eingabe; alles andere kommt aus den Vorgaben. */
|
||||
const basis = () => ({
|
||||
betrieb: { name: 'Salon Müller', email: 'info@salon.de' },
|
||||
betrieb: { name: 'Salon Müller', email: 'info@salon.de', ...PFLICHT },
|
||||
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']] } }],
|
||||
leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }],
|
||||
})
|
||||
@@ -142,6 +147,29 @@ test('Vorlagen: unbekannter Schluessel wirft, fehlende Betriebsangaben werfen mi
|
||||
})
|
||||
})
|
||||
|
||||
test('Vorlagen sind Entwuerfe: ohne Telefon und Anschrift gueltig, fehlendePflichtangaben nennt, was noch fehlt (R41)', () => {
|
||||
const ohne = { name: 'Salon Müller', email: 'info@salon.de' }
|
||||
const salon = ausVorlage('salon', ohne, NOW)
|
||||
assert.equal(salon.betrieb.telefon, '')
|
||||
assert.equal(salon.betrieb.adresse, '')
|
||||
assert.deepEqual(fehlendePflichtangaben(salon), ['betrieb.telefon', 'betrieb.adresse'], 'Verbraucher + sofort: Telefon und Anschrift')
|
||||
// Anfrage-Betriebe zeigen keine Widerrufsbelehrung: nur das Telefon fehlt
|
||||
assert.deepEqual(fehlendePflichtangaben(ausVorlage('handwerk', ohne, NOW)), ['betrieb.telefon'])
|
||||
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', { ...ohne, telefon: '06301 1234' }, NOW)), ['betrieb.adresse'])
|
||||
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', BETRIEB, NOW)), [])
|
||||
|
||||
// der Entwurf selbst besteht die strenge Pruefung (wie PATCH sie macht) erst mit den Angaben
|
||||
const streng = pruefe(salon, { bisher: salon })
|
||||
assert.deepEqual(Object.keys(streng.fehler).sort(), ['betrieb.adresse', 'betrieb.telefon'])
|
||||
assert.ok(pruefe({ ...salon, betrieb: { ...salon.betrieb, ...PFLICHT } }, { bisher: salon }).einstellung, 'mit den Angaben gueltig')
|
||||
|
||||
// Name und E-Mail bleiben Pflicht, auch im Entwurf
|
||||
assert.throws(() => ausVorlage('salon', { telefon: '06301 1234' }, NOW), (err) => {
|
||||
assert.ok(err.fehler['betrieb.name'] && err.fehler['betrieb.email'])
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('Vorlagen: VORLAGEN ist eingefroren', () => {
|
||||
assert.throws(() => { VORLAGEN.salon.leistungen.push({}) })
|
||||
assert.throws(() => { VORLAGEN.neu = {} })
|
||||
@@ -154,7 +182,7 @@ test('Vorgaben werden aufgefuellt', () => {
|
||||
assert.equal(e.schema, 1)
|
||||
assert.equal(e.vorlage, null)
|
||||
assert.deepEqual(e.betrieb, {
|
||||
name: 'Salon Müller', email: 'info@salon.de', telefon: '', kleinunternehmer: false, adresse: '',
|
||||
name: 'Salon Müller', email: 'info@salon.de', telefon: '06301 1234', kleinunternehmer: false, adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
||||
impressumUrl: null, datenschutzUrl: null, agbUrl: null, websiteUrl: null,
|
||||
})
|
||||
assert.equal(e.anrede, 'sie')
|
||||
@@ -199,26 +227,26 @@ test('Pflichtfelder: Betriebsname und Betriebs-E-Mail', () => {
|
||||
const f = fehlerVon({})
|
||||
assert.ok(f['betrieb.name'])
|
||||
assert.ok(f['betrieb.email'])
|
||||
assert.ok(fehlerVon({ betrieb: { name: ' ', email: 'a@b.de' } })['betrieb.name'])
|
||||
assert.ok(fehlerVon({ betrieb: { ...PFLICHT, name: ' ', email: 'a@b.de' } })['betrieb.name'])
|
||||
assert.ok(fehlerVon({ betrieb: { name: 'X' } })['betrieb.email'])
|
||||
})
|
||||
|
||||
test('Betriebsname wird auf 80 Zeichen gekuerzt, Steuerzeichen fliegen raus', () => {
|
||||
const e = gueltig({ ...basis(), betrieb: { name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } })
|
||||
const e = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } })
|
||||
assert.ok(e.betrieb.name.startsWith('Salon Müller xxx'))
|
||||
assert.equal([...e.betrieb.name].length, 80)
|
||||
assert.ok(!/[\u0000-\u001f\u202E]/.test(e.betrieb.name))
|
||||
})
|
||||
|
||||
test('E-Mail: genau ein Postfach, klein und getrimmt', () => {
|
||||
assert.equal(gueltig({ ...basis(), betrieb: { name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de')
|
||||
assert.equal(gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de')
|
||||
for (const schlecht of ['a@b.de, c@d.de', 'a@b.de;c@d.de', '<a@b.de>', '"a"@b.de', 'a b@c.de', 'ohne-at.de', 'a@b', 'a@@b.de', '@b.de', 'a@.de', `${'x'.repeat(250)}@b.de`]) {
|
||||
assert.ok(fehlerVon({ ...basis(), betrieb: { name: 'X', email: schlecht } })['betrieb.email'], schlecht)
|
||||
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: schlecht } })['betrieb.email'], schlecht)
|
||||
}
|
||||
})
|
||||
|
||||
test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeichen', () => {
|
||||
const mit = (feld, wert) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', [feld]: wert } })
|
||||
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
|
||||
const f = fehlerVon(mit('impressumUrl', 'javascript:alert(1)'))
|
||||
assert.ok(f['betrieb.impressumUrl'])
|
||||
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
|
||||
@@ -232,7 +260,7 @@ test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeic
|
||||
})
|
||||
|
||||
test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtick im Host abgelehnt', () => {
|
||||
const mit = (feld, wert) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', [feld]: wert } })
|
||||
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
|
||||
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
|
||||
for (const schlecht of [
|
||||
'https://exa"mple.com/', "https://a'b.de/", 'https://a`b.de/', "https://a'b.de/x'y", 'https://a<b.de/', 'https://a>b.de/',
|
||||
@@ -244,7 +272,7 @@ test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtic
|
||||
})
|
||||
|
||||
test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycode, IPv6 in Klammern', () => {
|
||||
const url = (wert) => gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl
|
||||
const url = (wert) => gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl
|
||||
assert.equal(url("https://ab.de/x'y"), 'https://ab.de/x%27y')
|
||||
assert.equal(url("https://ab.de/?q='a'#f'g"), 'https://ab.de/?q=%27a%27#f%27g')
|
||||
assert.equal(url('https://Salon.DE/Impressum'), 'https://salon.de/Impressum')
|
||||
@@ -256,12 +284,12 @@ test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycod
|
||||
}
|
||||
// die 300 Zeichen gelten fuer die gespeicherte Schreibweise (%27 ist laenger als ')
|
||||
const apostrophe = "'".repeat(110)
|
||||
assert.ok(fehlerVon({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl'])
|
||||
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl'])
|
||||
assert.equal(url(`https://ab.de/${"'".repeat(80)}`).length, 'https://ab.de/'.length + 80 * 3)
|
||||
})
|
||||
|
||||
test('Unsichtbare Formatzeichen: Name nur daraus ist leer (Pflichtfeld), sonst werden sie entfernt', () => {
|
||||
const name = (wert) => ({ ...basis(), betrieb: { name: wert, email: 'a@b.de' } })
|
||||
const name = (wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: wert, email: 'a@b.de' } })
|
||||
for (const leer of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u200B\u200E \uFEFF', ' \u200B\u200D\u200C ', '\u200D', '\u200C\u200D']) {
|
||||
assert.ok(fehlerVon(name(leer))['betrieb.name'], JSON.stringify(leer))
|
||||
}
|
||||
@@ -288,7 +316,7 @@ test('Unsichtbare Formatzeichen: auch Leistungs-/Ressourcennamen, Storno, E-Mail
|
||||
ok.ressourcen[0].name = 'An\u200Bna'
|
||||
ok.leistungen[0].beschreibung = 'Mit\u200E\u2060 Waschen'
|
||||
ok.storno = { text: 'Zeile\u200B 1\n\u200EZeile 2' }
|
||||
ok.betrieb = { name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' }
|
||||
ok.betrieb = { ...PFLICHT, name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' }
|
||||
const e2 = gueltig(ok)
|
||||
assert.equal(e2.ressourcen[0].name, 'Anna')
|
||||
assert.equal(e2.leistungen[0].beschreibung, 'Mit Waschen')
|
||||
@@ -791,6 +819,89 @@ test('preisText, preisHinweis, zahlungText', () => {
|
||||
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: [] } }), 'Bezahlung vor Ort')
|
||||
})
|
||||
|
||||
test('R13: Telefon ist immer Pflicht (Pfad betrieb.telefon), auch ohne Widerrufsbelehrung; Leerraum und Steuerzeichen zaehlen als leer', () => {
|
||||
const unternehmer = { ...basis(), recht: { gaesteSind: 'unternehmer' }, betrieb: { name: 'X', email: 'a@b.de', adresse: PFLICHT.adresse } }
|
||||
assert.deepEqual(Object.keys(fehlerVon(unternehmer)), ['betrieb.telefon'])
|
||||
for (const leer of [undefined, null, '', ' ', '\t\n', '\u200b\u200b', 42, {}]) {
|
||||
const f = fehlerVon({ ...basis(), betrieb: { ...basis().betrieb, telefon: leer } })
|
||||
assert.deepEqual(Object.keys(f), ['betrieb.telefon'], String(leer))
|
||||
assert.match(f['betrieb.telefon'], /Telefonnummer/)
|
||||
}
|
||||
assert.equal(gueltig({ ...unternehmer, betrieb: { ...unternehmer.betrieb, telefon: ' 0171 2345678 ' } }).betrieb.telefon, '0171 2345678')
|
||||
})
|
||||
|
||||
test('R13a: Anschrift ist Pflicht (Pfad betrieb.adresse), sobald die Widerrufsbelehrung erscheint: Verbraucher + sichtbare sofort-Leistung', () => {
|
||||
const ohneAdresse = (zusatz = {}) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', telefon: PFLICHT.telefon }, ...zusatz })
|
||||
const f = fehlerVon(ohneAdresse())
|
||||
assert.deepEqual(Object.keys(f), ['betrieb.adresse'])
|
||||
assert.match(f['betrieb.adresse'], /Anschrift/)
|
||||
for (const leer of ['', ' ', '\u200b']) {
|
||||
assert.deepEqual(Object.keys(fehlerVon({ ...ohneAdresse(), betrieb: { ...ohneAdresse().betrieb, adresse: leer } })), ['betrieb.adresse'])
|
||||
}
|
||||
// gemischte Gaeste sind auch Verbraucher
|
||||
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse({ recht: { gaesteSind: 'gemischt' } }))), ['betrieb.adresse'])
|
||||
|
||||
// ohne Widerrufsbelehrung keine Anschrift noetig: nur Unternehmer, Freizeitleistung, nur Anfragen
|
||||
assert.equal(gueltig(ohneAdresse({ recht: { gaesteSind: 'unternehmer' } })).betrieb.adresse, '')
|
||||
assert.equal(gueltig(ohneAdresse({ recht: { freizeit: true } })).betrieb.adresse, '')
|
||||
const anfragen = { leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] }
|
||||
assert.equal(gueltig(ohneAdresse(anfragen)).betrieb.adresse, '')
|
||||
// eine sofort-Leistung, die Gaeste nicht sehen (inaktiv oder nur an inaktiver Ressource), loest nichts aus
|
||||
const ruht = {
|
||||
ressourcen: [{ id: R1, name: 'Anna', plan: {} }, { id: R2, name: 'Ben', aktiv: false, plan: {} }],
|
||||
leistungen: [
|
||||
{ id: S1, name: 'Anfrage', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' },
|
||||
{ id: S2, name: 'Ruht', dauer: 30, ressourcen: [R1], buchungsart: 'sofort', aktiv: false },
|
||||
{ id: 's_bbbbbbb3', name: 'Nur Ben', dauer: 30, ressourcen: [R2], buchungsart: 'sofort', aktiv: false },
|
||||
],
|
||||
}
|
||||
assert.equal(gueltig(ohneAdresse(ruht)).betrieb.adresse, '')
|
||||
// mit einer sichtbaren sofort-Leistung daneben wieder Pflicht
|
||||
const mitSofort = { ...ruht, leistungen: [...ruht.leistungen, { id: 's_bbbbbbb4', name: 'Sofort', dauer: 30, ressourcen: [R1], buchungsart: 'sofort' }] }
|
||||
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse(mitSofort))), ['betrieb.adresse'])
|
||||
})
|
||||
|
||||
test('R13/R13a: beide Pflichtangaben fehlen => beide Fehler; Reihenfolge der Pfade Telefon, Anschrift; andere Fehler bleiben daneben', () => {
|
||||
const f = fehlerVon({ ...basis(), betrieb: { name: '', email: 'a@b.de' } })
|
||||
assert.deepEqual(Object.keys(f), ['betrieb.name', 'betrieb.telefon', 'betrieb.adresse'])
|
||||
})
|
||||
|
||||
test('R13/R13a: fehlendePflichtangaben stimmt mit oeffentlicheEinstellung.widerruf ueberein (alle Kombinationen)', () => {
|
||||
for (const gaesteSind of ['verbraucher', 'unternehmer', 'gemischt']) {
|
||||
for (const freizeit of [false, true]) {
|
||||
for (const buchungsart of ['sofort', 'anfrage']) {
|
||||
for (const aktiv of [true, false]) {
|
||||
const e = gueltig({
|
||||
...basis(),
|
||||
recht: { gaesteSind, freizeit },
|
||||
leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart, aktiv }],
|
||||
})
|
||||
const name = JSON.stringify({ gaesteSind, freizeit, buchungsart, aktiv })
|
||||
const widerruf = oeffentlicheEinstellung(e, NOW).widerruf
|
||||
assert.equal(zeigtWiderruf(e), widerruf, name)
|
||||
const ohne = { ...e, betrieb: { ...e.betrieb, adresse: '', telefon: '' } }
|
||||
assert.deepEqual(fehlendePflichtangaben(ohne), widerruf ? ['betrieb.telefon', 'betrieb.adresse'] : ['betrieb.telefon'], name)
|
||||
assert.deepEqual(fehlendePflichtangaben(e), [], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('R41: entwurf laesst nur die beiden Pflichtangaben aus; alle anderen Pruefungen gelten weiter; ohne entwurf strikt', () => {
|
||||
const roh = { ...basis(), betrieb: { name: 'Salon Müller', email: 'info@salon.de' } }
|
||||
assert.deepEqual(Object.keys(fehlerVon(roh)), ['betrieb.telefon', 'betrieb.adresse'], 'Vorgabe: strikt')
|
||||
assert.deepEqual(Object.keys(fehlerVon(roh, { entwurf: false })), ['betrieb.telefon', 'betrieb.adresse'])
|
||||
const e = gueltig(roh, { entwurf: true })
|
||||
assert.equal(e.betrieb.telefon, '')
|
||||
assert.equal(e.betrieb.adresse, '')
|
||||
|
||||
const kaputt = { betrieb: { name: '', email: 'kein-postfach', impressumUrl: 'javascript:alert(1)' }, ressourcen: [], leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1] }] }
|
||||
const f = fehlerVon(kaputt, { entwurf: true })
|
||||
assert.deepEqual(Object.keys(f).sort(), ['betrieb.email', 'betrieb.impressumUrl', 'betrieb.name', 'leistungen.0.ressourcen'])
|
||||
assert.ok(!('betrieb.telefon' in f) && !('betrieb.adresse' in f))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Oeffentliche Sicht
|
||||
|
||||
test('oeffentlicheEinstellung: genau die vereinbarten Felder, ohne feedToken und interne Angaben', () => {
|
||||
@@ -871,7 +982,7 @@ test('oeffentlicheEinstellung: storno null ohne Text, widerruf nur bei sofort +
|
||||
const freizeit = gueltig({ ...basis(), recht: { freizeit: true } })
|
||||
assert.equal(oeffentlicheEinstellung(freizeit, NOW).widerruf, false)
|
||||
|
||||
const klein = gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', kleinunternehmer: true } })
|
||||
const klein = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', kleinunternehmer: true } })
|
||||
assert.equal(oeffentlicheEinstellung(klein, NOW).preisHinweis, 'kein Ausweis der USt nach § 19 UStG')
|
||||
})
|
||||
|
||||
|
||||
+118
-7
@@ -82,17 +82,17 @@ const eingabe = (over = {}) => ({
|
||||
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
|
||||
...over,
|
||||
})
|
||||
const einstellung = (over) => {
|
||||
const erg = normalisiereEinstellung(eingabe(over), { now: JETZT })
|
||||
const einstellung = (over, optionen = {}) => {
|
||||
const erg = normalisiereEinstellung(eingabe(over), { now: JETZT, ...optionen })
|
||||
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
|
||||
return erg.einstellung
|
||||
}
|
||||
const betriebMit = (felder) => ({ ...eingabe().betrieb, ...felder })
|
||||
|
||||
const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }) => ({
|
||||
const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }, optionen = {}) => ({
|
||||
feature: feature(),
|
||||
project: projekt,
|
||||
einstellung: einstellung(over),
|
||||
einstellung: einstellung(over, optionen),
|
||||
version: 1,
|
||||
})
|
||||
|
||||
@@ -264,6 +264,88 @@ test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); feh
|
||||
})
|
||||
})
|
||||
|
||||
test('R40 speichereEinstellung mit Funktion: laeuft nach der Versionspruefung, bekommt die bisherige Einstellung (erst null), Ergebnis wird gespeichert', async () => {
|
||||
const aufrufe = []
|
||||
const erste = einstellung({ betrieb: betriebMit({ name: 'Erste' }) })
|
||||
const erg = await speichereEinstellung(feature(), async (bisher) => {
|
||||
aufrufe.push(bisher)
|
||||
return erste
|
||||
}, { version: 0, by: 'kunde', now: JETZT })
|
||||
assert.deepEqual(aufrufe, [null], 'noch nichts gespeichert')
|
||||
assert.equal(erg.version, 1)
|
||||
assert.deepEqual(erg.einstellung, erste)
|
||||
assert.equal(gespeichert().updatedBy, 'kunde')
|
||||
|
||||
const zweite = einstellung({ betrieb: betriebMit({ name: 'Zweite' }) })
|
||||
const erg2 = await speichereEinstellung(feature(), (bisher) => {
|
||||
aufrufe.push(bisher) // auch eine synchrone Funktion
|
||||
return zweite
|
||||
}, { version: 1 })
|
||||
assert.deepEqual(aufrufe[1], erste, 'bisher = die gespeicherte Einstellung')
|
||||
assert.equal(erg2.version, 2)
|
||||
assert.equal((await ladeEinstellung(FEATURE)).einstellung.betrieb.name, 'Zweite')
|
||||
})
|
||||
|
||||
test('R40 speichereEinstellung mit Funktion: veraltete Version ruft die Funktion nie auf; ein Fehler der Funktion geht unveraendert durch und schreibt nichts', async () => {
|
||||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||||
let aufrufe = 0
|
||||
await assert.rejects(
|
||||
() => speichereEinstellung(feature(), async () => { aufrufe += 1; return einstellung() }, { version: 0 }),
|
||||
(err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET',
|
||||
)
|
||||
assert.equal(aufrufe, 0, 'Version zuerst')
|
||||
|
||||
const eigener = Object.assign(new Error('Eingabe'), { status: 400, fehler: { 'leistungen.s_x': 'Noch 2 künftige Termine.' } })
|
||||
await assert.rejects(() => speichereEinstellung(feature(), async () => { throw eigener }, { version: 1 }), (err) => err === eigener)
|
||||
const geladen = await ladeEinstellung(FEATURE)
|
||||
assert.equal(geladen.version, 1, 'nichts geschrieben')
|
||||
// das Sperrschloss ist wieder frei
|
||||
assert.equal((await speichereEinstellung(feature(), async () => einstellung(), { version: 1 })).version, 2)
|
||||
})
|
||||
|
||||
test('R40 speichereEinstellung mit Funktion: Ergebnis ohne Objekt => TypeError und nichts geschrieben; Eingaben ohne Funktion bleiben TypeError', async () => {
|
||||
for (const unsinn of [null, undefined, 'x', 5, []]) {
|
||||
await assert.rejects(() => speichereEinstellung(feature(), async () => unsinn, { version: 0 }), TypeError, String(unsinn))
|
||||
}
|
||||
assert.equal(aw.docs('termineConfig').size, 0)
|
||||
await assert.rejects(() => speichereEinstellung({}, async () => einstellung(), { version: 0 }), TypeError)
|
||||
})
|
||||
|
||||
test('R40 speichereEinstellung mit version null: keine Versionspruefung, Version zaehlt trotzdem hoch (Feed-Link neu erzeugen)', async () => {
|
||||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Zwei' }) }), { version: 1 })
|
||||
const erg = await speichereEinstellung(feature(), async (bisher) => ({ ...bisher, farbe: '#112233' }), { version: null })
|
||||
assert.equal(erg.version, 3)
|
||||
const geladen = await ladeEinstellung(FEATURE)
|
||||
assert.equal(geladen.einstellung.farbe, '#112233')
|
||||
assert.equal(geladen.einstellung.betrieb.name, 'Zwei', 'baut auf dem aktuellen Stand auf')
|
||||
// nur null spart die Pruefung; undefined und falsche Versionen bleiben 409
|
||||
await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, {}), (err) => err.status === 409)
|
||||
await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, { version: 99 }), (err) => err.status === 409)
|
||||
// ohne Dokument sieht die Funktion null (mit der Einstellung als Objekt bleibt version null auch erlaubt)
|
||||
await deleteDocument('termineConfig', FEATURE)
|
||||
let gesehen
|
||||
await speichereEinstellung(feature(), async (bisher) => { gesehen = bisher; return einstellung() }, { version: null })
|
||||
assert.equal(gesehen, null)
|
||||
})
|
||||
|
||||
test('R40 speichereEinstellung mit Funktion: gleichzeitige Aufrufe laufen nacheinander, der zweite sieht das Ergebnis des ersten', async () => {
|
||||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Start' }) }), { version: 0 })
|
||||
const gesehen = []
|
||||
const aendere = (name) => async (bisher) => {
|
||||
gesehen.push(bisher.betrieb.name)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20)) // der Zweite haette Zeit, dazwischenzufunken
|
||||
return { ...bisher, betrieb: { ...bisher.betrieb, name } }
|
||||
}
|
||||
await Promise.all([
|
||||
speichereEinstellung(feature(), aendere('A'), { version: null }),
|
||||
speichereEinstellung(feature(), aendere('B'), { version: null }),
|
||||
])
|
||||
assert.equal(gesehen[0], 'Start')
|
||||
assert.ok(['A', 'B'].includes(gesehen[1]), 'der Zweite baut auf dem Ergebnis des Ersten auf')
|
||||
assert.equal((await ladeEinstellung(FEATURE)).version, 3)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Kontext
|
||||
|
||||
test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, project, einstellung, version }', async () => {
|
||||
@@ -279,6 +361,32 @@ test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, proje
|
||||
assert.equal(ctx.version, 1)
|
||||
})
|
||||
|
||||
test('R41 kontextPerSiteKey: solange Pflichtangaben fehlen (Telefon; Anschrift bei Widerrufsbelehrung) ist die Funktion oeffentlich nicht buchbar (null); das Portal sieht sie weiter', async () => {
|
||||
seedFeature()
|
||||
seedProjekt()
|
||||
const entwurf = { entwurf: true }
|
||||
const ohneTelefon = einstellung({ betrieb: betriebMit({ telefon: '' }) }, entwurf)
|
||||
await speichereEinstellung(feature(), ohneTelefon, { version: 0 })
|
||||
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'Telefon fehlt')
|
||||
const portal = await kontextPerFeature(FEATURE)
|
||||
assert.ok(portal && portal.einstellung, 'kontextPerFeature (Portal, Gaesteseiten per Token) bleibt erreichbar')
|
||||
assert.deepEqual(portal.einstellung.betrieb.telefon, '')
|
||||
|
||||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }) }, entwurf), { version: 1 })
|
||||
assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1)), null, 'Anschrift fehlt bei Verbraucher + sofort')
|
||||
|
||||
// ohne Widerrufsbelehrung (nur Unternehmer) reicht das Telefon
|
||||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, entwurf), { version: 2 })
|
||||
assert.ok(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(2)), 'Unternehmer-Betrieb ohne Anschrift ist buchbar')
|
||||
|
||||
// vollstaendig: buchbar; die Speicherung leert den gemerkten null-Eintrag sofort
|
||||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '', telefon: '' }) }, entwurf), { version: 3 })
|
||||
assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(3)), null)
|
||||
await speichereEinstellung(feature(), einstellung(), { version: 4 })
|
||||
const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(4))
|
||||
assert.equal(ctx.einstellung.betrieb.telefon, '06301 1234')
|
||||
})
|
||||
|
||||
test('kontextPerSiteKey: Modus off => null ohne Anfrage; ungueltiger siteKey => null ohne Anfrage', async () => {
|
||||
seedFeature()
|
||||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||||
@@ -604,12 +712,14 @@ test('G02 Freizeitleistung mit festem Termin: kein_widerruf statt widerruf (nich
|
||||
})
|
||||
|
||||
test('G02 mit Widerrufsrecht, aber ohne Anschrift oder Telefon des Betriebs: Fehler statt unvollstaendiger Belehrung', () => {
|
||||
// Entwurf: nur so gibt es eine Einstellung ohne die Pflichtangaben (R41); die Mail prueft sie trotzdem selbst
|
||||
const entwurf = { entwurf: true }
|
||||
for (const felder of [{ adresse: '' }, { telefon: '' }]) {
|
||||
const ctx = ctxMit({ betrieb: betriebMit(felder) })
|
||||
const ctx = ctxMit({ betrieb: betriebMit(felder) }, undefined, entwurf)
|
||||
assert.throws(() => werteFuer('G02', ctx, buchung(), {}), TypeError, JSON.stringify(felder))
|
||||
}
|
||||
// ohne Widerrufsrecht braucht die Mail beides nicht
|
||||
const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } })
|
||||
const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, undefined, entwurf)
|
||||
assert.doesNotThrow(() => werteFuer('G02', ctx, buchung({ verbraucher: false }), {}))
|
||||
})
|
||||
|
||||
@@ -686,7 +796,8 @@ test('Impressum und Datenschutz im Fuss: Angaben des Betriebs, sonst dessen Webs
|
||||
assert.equal(nackt.impressum_url, config.publicBaseUrl)
|
||||
assert.equal(nackt.datenschutz_url, config.publicBaseUrl)
|
||||
|
||||
const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }), buchung(), {}).values
|
||||
// Entwurf (R41): nur so gibt es eine Einstellung ohne Telefon und Anschrift; die Mail setzt dann "–"
|
||||
const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }, undefined, { entwurf: true }), buchung(), {}).values
|
||||
assert.equal(leer.betrieb_telefon, '–')
|
||||
assert.equal(leer.betrieb_adresse, '–')
|
||||
assert.equal(mitAngaben.farbe, '#7c3aed')
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
|
||||
// Kunden-Schnittstelle /api/termine (Aufgabe 18): Zugang (Projekt, Pilot, Funktion), Einstellungen (Vorlage, PATCH mit Version
|
||||
// und Entfernen-Pruefung, Feed-Link), Buchungen (Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Kundenansicht.
|
||||
// Aufbau wie test/termineAblaufBetrieb.test.js (Nachbau von Appwrite mit Schema, Standard-Limit 25, feste Uhr, Mail-Senke).
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
termineBookings: schemaKeys('termineBookings'),
|
||||
termineClaims: schemaKeys('termineClaims'),
|
||||
termineOutbox: schemaKeys('termineOutbox'),
|
||||
customerViewLog: schemaKeys('customerViewLog'),
|
||||
},
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { customerCookie, kundenansichtCookie, customers } = await import('./support/fixtures.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||
const { default: termineRoutes, setzeUhrFuerTests, resetTermineRoutesForTests } = await import('../server/routes/termine.js')
|
||||
const { default: publicTermineRoutes, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js')
|
||||
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
|
||||
const { speichereEinstellung, ladeEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
|
||||
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
|
||||
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
||||
const { buchungsNummer } = await import('../server/services/termine/token.js')
|
||||
|
||||
const app = await startTestApp({
|
||||
'/': kundenansichtGuard,
|
||||
'/api/public/termine/v1': publicTermineRoutes,
|
||||
'/api/termine': termineRoutes,
|
||||
})
|
||||
after(async () => {
|
||||
setMailTransportForTests(null)
|
||||
await Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ)
|
||||
const TAG = '2026-10-12' // Montag, eine Woche spaeter
|
||||
const KUNDE = 'c1'
|
||||
const P = 'p1'
|
||||
const FEATURE = 'f_p1_termine'
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
const R1 = 'r_aaaaaaa1'
|
||||
const R2 = 'r_aaaaaaa2'
|
||||
const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 + 5 Minuten, 45 Euro, sofort
|
||||
const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage
|
||||
const VIEW = 'a'.repeat(32)
|
||||
const MODUS_VORHER = config.termine.mode
|
||||
const MAILS_VORHER = [...config.termine.pilotEmails]
|
||||
const BASIS_VORHER = config.publicBaseUrl
|
||||
const ABSENDER_VORHER = config.smtp.from
|
||||
config.publicBaseUrl = app.url // die Kalender-Links zeigen auf diese Test-App
|
||||
|
||||
const cookie = customerCookie(KUNDE)
|
||||
const iso = (date) => new Date(date).toISOString()
|
||||
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
|
||||
|
||||
const eingabeEinstellung = (over = {}) => ({
|
||||
betrieb: {
|
||||
name: 'Salon Müller',
|
||||
email: 'info@salon.de',
|
||||
telefon: '06301 1234',
|
||||
adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
||||
websiteUrl: 'https://salon.de',
|
||||
impressumUrl: 'https://salon.de/impressum',
|
||||
datenschutzUrl: 'https://salon.de/datenschutz',
|
||||
},
|
||||
anrede: 'sie',
|
||||
bundesland: 'RP',
|
||||
ressourcen: [
|
||||
{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } },
|
||||
{ id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } },
|
||||
],
|
||||
leistungen: [
|
||||
{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] },
|
||||
{ id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] },
|
||||
],
|
||||
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
|
||||
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] },
|
||||
erinnerung: { anbieten: true, stundenVorher: 24 },
|
||||
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
|
||||
recht: { gaesteSind: 'verbraucher', freizeit: false },
|
||||
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
|
||||
...over,
|
||||
})
|
||||
const einstellung = (over) => {
|
||||
const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
|
||||
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
|
||||
return erg.einstellung
|
||||
}
|
||||
|
||||
const featureDoc = (over = {}) => ({
|
||||
$id: FEATURE,
|
||||
customerId: KUNDE,
|
||||
projectId: P,
|
||||
featureKey: 'termine',
|
||||
state: 'active',
|
||||
enabled: true,
|
||||
siteKey: SITE_KEY,
|
||||
activatedAt: '2026-10-01T09:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Einstellung speichern (wie das Portal es nach dem ersten Oeffnen hat). */
|
||||
async function speichere(e = einstellung(), feature = featureDoc()) {
|
||||
resetKontextForTests()
|
||||
const doc = aw.docs('termineConfig').get(feature.$id)
|
||||
await speichereEinstellung(feature, e, { version: doc ? doc.version : 0, now: JETZT })
|
||||
return e
|
||||
}
|
||||
|
||||
const buchungsDoc = (id, over = {}) => ({
|
||||
$id: id,
|
||||
featureId: FEATURE,
|
||||
customerId: KUNDE,
|
||||
projectId: P,
|
||||
leistungId: S_SOFORT,
|
||||
leistungName: 'Damenschnitt',
|
||||
ressourceId: R1,
|
||||
ressourceName: 'Anna',
|
||||
startsAt: `${TAG}T08:00:00.000+00:00`, // 10:00 Berlin
|
||||
endsAt: `${TAG}T08:45:00.000+00:00`,
|
||||
blockEndsAt: `${TAG}T08:50:00.000+00:00`,
|
||||
status: 'bestaetigt',
|
||||
quelle: 'online',
|
||||
gastName: 'Max Mustermann',
|
||||
gastEmail: 'max@example.com',
|
||||
gastTelefon: '0171 1234567',
|
||||
gastNachricht: '',
|
||||
erinnerung: false,
|
||||
preisCent: 4500,
|
||||
preisArt: 'fest',
|
||||
buchungsart: 'sofort',
|
||||
verbraucher: true,
|
||||
icsSequenz: 0,
|
||||
createdAt: '2026-10-05T07:00:00.000+00:00',
|
||||
updatedAt: '2026-10-05T07:00:00.000+00:00',
|
||||
...over,
|
||||
})
|
||||
const seedBuchung = (id, over) => aw.seed('termineBookings', buchungsDoc(id, over))
|
||||
const buchungen = () => [...aw.docs('termineBookings').values()]
|
||||
const logEintraege = () => [...aw.docs('customerViewLog').values()]
|
||||
const eintraege = () => [...aw.docs('termineOutbox').values()]
|
||||
|
||||
let sink
|
||||
beforeEach(async () => {
|
||||
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customerViewLog', 'customers', 'websiteProjects']) {
|
||||
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
||||
}
|
||||
resetKontextForTests()
|
||||
resetOutboxForTests()
|
||||
resetTermineLimitsForTests()
|
||||
resetPublicTermineForTests()
|
||||
resetTermineRoutesForTests()
|
||||
setzeUhrFuerTests(() => JETZT)
|
||||
config.termine.mode = 'pilot'
|
||||
config.termine.pilotEmails = ['*']
|
||||
config.smtp.from = 'noreply@webklar.com'
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
mock.method(console, 'error', () => {})
|
||||
mock.method(console, 'warn', () => {})
|
||||
|
||||
// Kunde c1 mit Firma, Anschrift und Telefon im Konto; Kunde c2 ohne beides
|
||||
aw.seed('customers', { ...customers.c1, companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', phone: '06301 1234' })
|
||||
aw.seed('customers', customers.c2)
|
||||
aw.seed('websiteProjects', { $id: P, customerId: KUNDE, projectName: 'Salon', subdomain: 'salon' })
|
||||
aw.seed('websiteProjects', { $id: 'p2', customerId: KUNDE, projectName: 'Studio' })
|
||||
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Fremd', subdomain: 'fremd' })
|
||||
aw.seed('websiteProjects', { $id: 'p4', customerId: KUNDE, projectName: 'Alt' })
|
||||
aw.seed('websiteProjects', { $id: 'p5', customerId: KUNDE, projectName: 'Ohne Funktion' })
|
||||
aw.seed('websiteProjects', { $id: 'p6', customerId: 'c2', projectName: 'Ohne Telefon', subdomain: 'ohne' })
|
||||
aw.seed('portalFeatures', featureDoc())
|
||||
aw.seed('portalFeatures', featureDoc({ $id: 'f_p2_termine', projectId: 'p2', state: 'off', enabled: false, siteKey: 'bcdefghijklmnopq' }))
|
||||
aw.seed('portalFeatures', featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' }))
|
||||
aw.seed('portalFeatures', featureDoc({ $id: 'f_p6_termine', customerId: 'c2', projectId: 'p6', siteKey: 'defghijklmnopqrs' }))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await warteAufVersand()
|
||||
mock.restoreAll()
|
||||
config.termine.mode = MODUS_VORHER
|
||||
config.termine.pilotEmails = [...MAILS_VORHER]
|
||||
config.smtp.from = ABSENDER_VORHER
|
||||
})
|
||||
after(() => {
|
||||
config.publicBaseUrl = BASIS_VORHER
|
||||
})
|
||||
|
||||
const einstellungenUrl = (projectId = P) => `/api/termine/einstellungen?projectId=${projectId}`
|
||||
const lade = async (projectId = P) => (await send('GET', einstellungenUrl(projectId), { cookie })).body
|
||||
|
||||
// ---------------------------------------------------------------- Zugang
|
||||
|
||||
test('ohne Sitzung 401, ohne Projekt 400, fremdes Projekt 403 (auf jeder Route)', async () => {
|
||||
await speichere()
|
||||
assert.equal((await send('GET', einstellungenUrl())).status, 401)
|
||||
assert.equal((await send('GET', '/api/termine/einstellungen', { cookie })).status, 400)
|
||||
const fremd = [
|
||||
['GET', einstellungenUrl('p3')],
|
||||
['GET', '/api/termine/buchungen?projectId=p3&von=2026-10-01&bis=2026-10-31'],
|
||||
['GET', '/api/termine/zeiten?projectId=p3'],
|
||||
['GET', '/api/termine/export.csv?projectId=p3'],
|
||||
['PATCH', '/api/termine/einstellungen', { projectId: 'p3', version: 0, einstellung: {} }],
|
||||
['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p3', vorlage: 'salon' }],
|
||||
['POST', '/api/termine/buchungen', { projectId: 'p3', art: 'sperre' }],
|
||||
['POST', '/api/termine/buchungen/x/aktion', { projectId: 'p3', aktion: 'annehmen' }],
|
||||
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p3' }],
|
||||
['DELETE', '/api/termine/buchungen/x?projectId=p3'],
|
||||
['GET', einstellungenUrl('gibt es nicht')],
|
||||
]
|
||||
for (const [method, pfad, body] of fremd) {
|
||||
const res = await send(method, pfad, { cookie, body })
|
||||
assert.equal(res.status, 403, `${method} ${pfad}`)
|
||||
assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.')
|
||||
}
|
||||
})
|
||||
|
||||
test('ohne Pilot (Kunde nicht in der Liste, Modus off) 409 "noch nicht verfuegbar", auch beim Lesen', async () => {
|
||||
await speichere()
|
||||
config.termine.pilotEmails = ['andere@example.invalid']
|
||||
for (const [method, pfad, body] of [['GET', einstellungenUrl()], ['PATCH', '/api/termine/einstellungen', { projectId: P, version: 1, einstellung: {} }]]) {
|
||||
const res = await send(method, pfad, { cookie, body })
|
||||
assert.equal(res.status, 409, `${method} ${pfad}`)
|
||||
assert.equal(res.body.error, 'Der Terminplaner ist noch nicht verfügbar.')
|
||||
}
|
||||
config.termine.pilotEmails = [customers.c1.email]
|
||||
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Adresse der Liste')
|
||||
config.termine.mode = 'off'
|
||||
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409)
|
||||
})
|
||||
|
||||
test('Funktion aus, noch nicht bestellt oder ohne Dokument: 409 "nicht aktiv"', async () => {
|
||||
for (const projectId of ['p2', 'p5']) {
|
||||
for (const [method, pfad, body] of [
|
||||
['GET', einstellungenUrl(projectId)],
|
||||
['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`],
|
||||
['GET', `/api/termine/export.csv?projectId=${projectId}`],
|
||||
['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }],
|
||||
['PATCH', '/api/termine/einstellungen', { projectId, version: 0, einstellung: {} }],
|
||||
]) {
|
||||
const res = await send(method, pfad, { cookie, body })
|
||||
assert.equal(res.status, 409, `${projectId} ${method} ${pfad}`)
|
||||
assert.equal(res.body.error, 'Der Terminplaner ist für diese Website nicht aktiv.')
|
||||
}
|
||||
}
|
||||
for (const state of ['ordered', 'setup']) {
|
||||
aw.seed('portalFeatures', featureDoc({ $id: 'f_p5_termine', projectId: 'p5', state, enabled: false, siteKey: 'efghijklmnopqrst' }))
|
||||
assert.equal((await send('GET', einstellungenUrl('p5'), { cookie })).status, 409, state)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Einstellungen: lesen, Vorlage
|
||||
|
||||
test('GET einstellungen ohne Einstellung: Zustand, Vorlagen, Einbau-Code, siteKey, keine Einstellung', async () => {
|
||||
const res = await send('GET', einstellungenUrl(), { cookie })
|
||||
assert.equal(res.status, 200)
|
||||
const b = res.body
|
||||
assert.equal(b.state, 'active')
|
||||
assert.equal(b.aktivSeit, '2026-10-01T09:00:00.000Z')
|
||||
assert.equal(b.einstellung, null)
|
||||
assert.equal(b.version, 0)
|
||||
assert.deepEqual(b.vorlagen.map((v) => v.key), ['salon', 'kosmetik', 'beratung', 'werkstatt', 'handwerk', 'tattoo', 'fotograf', 'hundesalon', 'sonstiges'])
|
||||
assert.deepEqual(Object.keys(b.vorlagen[0]).sort(), ['beschreibung', 'key', 'name'])
|
||||
assert.deepEqual(b.feeds, {})
|
||||
assert.equal(b.siteKey, SITE_KEY)
|
||||
assert.equal(b.anfragenOffen, 0)
|
||||
assert.deepEqual(b.fehlt, [])
|
||||
assert.match(b.einbau.snippet, new RegExp(`<div data-webklar-termine="${SITE_KEY}" data-api="${app.url}" data-modus="inline">`))
|
||||
assert.ok(b.einbau.snippet.includes(`<script src="${app.url}/embed/termine.js" defer></script>`))
|
||||
assert.ok(b.einbau.snippet.includes('<p>Termine telefonisch vereinbaren.</p>'), 'noch kein Telefon bekannt')
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
})
|
||||
|
||||
test('GET einstellungen mit Einstellung: ohne Feed-Token, Kalender-Links je Person, Einbau-Code mit Telefon, offene Anfragen', async () => {
|
||||
const e = await speichere(einstellung({ betrieb: { ...eingabeEinstellung().betrieb, telefon: '06301 <12>' } }))
|
||||
seedBuchung('b_anfrage1', { status: 'angefragt', leistungId: S_ANFRAGE })
|
||||
seedBuchung('b_anfrage2', { status: 'angefragt', leistungId: S_ANFRAGE })
|
||||
seedBuchung('b_fest', { status: 'bestaetigt' })
|
||||
const res = await send('GET', einstellungenUrl(), { cookie })
|
||||
const b = res.body
|
||||
assert.equal(b.version, 1)
|
||||
assert.equal(b.anfragenOffen, 2)
|
||||
assert.deepEqual(Object.keys(b.feeds).sort(), [R1, R2])
|
||||
for (const r of e.ressourcen) {
|
||||
assert.equal(b.feeds[r.id], `${app.url}/api/public/termine/v1/kalender/${SITE_KEY}/${r.feedToken}.ics`)
|
||||
assert.ok(!JSON.stringify(b.einstellung).includes(r.feedToken), 'der Token steht nur im Link')
|
||||
}
|
||||
assert.ok(b.einstellung.ressourcen.every((r) => !('feedToken' in r)))
|
||||
assert.equal(b.einstellung.betrieb.name, 'Salon Müller')
|
||||
assert.ok(b.einbau.snippet.includes('<p>Termine telefonisch unter 06301 <12>.</p>'), 'Telefon im Einbau-Code maskiert')
|
||||
// der Link funktioniert an der oeffentlichen Schnittstelle
|
||||
const feed = await fetch(b.feeds[R1])
|
||||
assert.equal(feed.status, 200)
|
||||
assert.match(feed.headers.get('content-type'), /^text\/calendar/)
|
||||
})
|
||||
|
||||
test('POST vorlage: Betrieb aus dem Kundenkonto (Firma, Anschrift, E-Mail, Telefon), 201 mit Einstellung ohne Token; zweimal => 409', async () => {
|
||||
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'salon' } })
|
||||
assert.equal(res.status, 201)
|
||||
assert.equal(res.body.version, 1)
|
||||
assert.deepEqual(res.body.fehlt, [])
|
||||
const betrieb = res.body.einstellung.betrieb
|
||||
assert.equal(betrieb.name, 'Salon Müller')
|
||||
assert.equal(betrieb.email, 'k@example.invalid')
|
||||
assert.equal(betrieb.telefon, '06301 1234')
|
||||
assert.equal(betrieb.adresse, 'Hauptstr. 1, 67722 Winnweiler')
|
||||
assert.equal(res.body.einstellung.vorlage, 'salon')
|
||||
assert.ok(res.body.einstellung.leistungen.length >= 2)
|
||||
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))
|
||||
const gespeichert = await ladeEinstellung(FEATURE)
|
||||
assert.equal(gespeichert.version, 1)
|
||||
assert.ok(gespeichert.einstellung.ressourcen.every((r) => r.feedToken.length === 43))
|
||||
assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'kunde:c1')
|
||||
|
||||
const zweite = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'tattoo' } })
|
||||
assert.equal(zweite.status, 409)
|
||||
assert.equal(zweite.body.code, 'einstellung_vorhanden')
|
||||
assert.equal((await ladeEinstellung(FEATURE)).einstellung.vorlage, 'salon', 'nichts ueberschrieben')
|
||||
|
||||
// zwei gleichzeitige Anfragen auf eine frische Funktion: genau eine gewinnt
|
||||
await deleteDocument('termineConfig', FEATURE)
|
||||
const [a, b] = await Promise.all(['salon', 'kosmetik'].map((vorlage) => send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage } })))
|
||||
assert.deepEqual([a.status, b.status].sort(), [201, 409])
|
||||
})
|
||||
|
||||
test('POST vorlage: unbekannte, fehlende und Prototyp-Schluessel => 400', async () => {
|
||||
for (const vorlage of ['gibtsnicht', '__proto__', 'constructor', 'toString', '', null, 5, undefined]) {
|
||||
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage } })
|
||||
assert.equal(res.status, 400, String(vorlage))
|
||||
assert.ok(res.body.fehler.vorlage)
|
||||
}
|
||||
assert.equal(aw.docs('termineConfig').size, 0)
|
||||
})
|
||||
|
||||
test('POST vorlage ohne Telefon und Anschrift im Konto: Entwurf mit "fehlt"; oeffentlich nicht aktiv, bis die Angaben da sind (R41)', async () => {
|
||||
const c2 = customerCookie('c2')
|
||||
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie: c2, body: { projectId: 'p6', vorlage: 'salon' } })
|
||||
assert.equal(res.status, 201, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.fehlt, ['betrieb.telefon', 'betrieb.adresse'])
|
||||
assert.equal(res.body.einstellung.betrieb.name, 'Kunde Zwei', 'ohne Firma der Name')
|
||||
const gelesen = await send('GET', einstellungenUrl('p6'), { cookie: c2 })
|
||||
assert.deepEqual(gelesen.body.fehlt, ['betrieb.telefon', 'betrieb.adresse'])
|
||||
|
||||
const oeffentlich = (siteKey) => fetch(`${app.url}/api/public/termine/v1/${siteKey}/config`).then((r) => r.json())
|
||||
assert.deepEqual(await oeffentlich('defghijklmnopqrs'), { aktiv: false })
|
||||
|
||||
// PATCH prueft immer streng
|
||||
const entwurf = gelesen.body.einstellung
|
||||
const abgelehnt = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: entwurf } })
|
||||
assert.equal(abgelehnt.status, 400)
|
||||
assert.deepEqual(Object.keys(abgelehnt.body.fehler).sort(), ['betrieb.adresse', 'betrieb.telefon'])
|
||||
assert.match(abgelehnt.body.error, /Telefonnummer/)
|
||||
|
||||
const komplett = { ...entwurf, betrieb: { ...entwurf.betrieb, telefon: '0151 2345678', adresse: 'Weg 1, 12345 Ort' } }
|
||||
const ok = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: komplett } })
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||
assert.deepEqual((await send('GET', einstellungenUrl('p6'), { cookie: c2 })).body.fehlt, [])
|
||||
assert.equal((await oeffentlich('defghijklmnopqrs')).aktiv, true)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Einstellungen: PATCH
|
||||
|
||||
test('PATCH einstellungen: speichert, Version zaehlt hoch, Feed-Token bleiben (auch bei Wert vom Client), neue Personen bekommen einen', async () => {
|
||||
await speichere()
|
||||
const vorher = await lade()
|
||||
const tokenVorher = (await ladeEinstellung(FEATURE)).einstellung.ressourcen.map((r) => r.feedToken)
|
||||
|
||||
const neu = structuredClone(vorher.einstellung)
|
||||
neu.betrieb.name = 'Salon Neu'
|
||||
neu.ressourcen[0].feedToken = 'x'.repeat(43) // wird ignoriert
|
||||
neu.ressourcen.push({ name: 'Clara', typ: 'person', plan: { 2: [['09:00', '12:00']] } })
|
||||
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: vorher.version, einstellung: neu } })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'version'])
|
||||
assert.equal(res.body.version, 2)
|
||||
assert.equal(res.body.einstellung.betrieb.name, 'Salon Neu')
|
||||
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))
|
||||
|
||||
const gespeichert = (await ladeEinstellung(FEATURE)).einstellung
|
||||
assert.deepEqual(gespeichert.ressourcen.slice(0, 2).map((r) => r.feedToken), tokenVorher)
|
||||
assert.equal(gespeichert.ressourcen[2].feedToken.length, 43)
|
||||
assert.equal(Object.keys((await lade()).feeds).length, 3)
|
||||
assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'kunde:c1')
|
||||
})
|
||||
|
||||
test('PATCH einstellungen: alte Version => 409 EINSTELLUNG_VERALTET, Stand des Ersten bleibt', async () => {
|
||||
await speichere()
|
||||
const stand = await lade()
|
||||
const aendere = (name, version = stand.version) => send('PATCH', '/api/termine/einstellungen', {
|
||||
cookie,
|
||||
body: { projectId: P, version, einstellung: { ...stand.einstellung, betrieb: { ...stand.einstellung.betrieb, name } } },
|
||||
})
|
||||
assert.equal((await aendere('Erster')).status, 200)
|
||||
const zweiter = await aendere('Zweiter')
|
||||
assert.equal(zweiter.status, 409)
|
||||
assert.equal(zweiter.body.code, 'EINSTELLUNG_VERALTET')
|
||||
assert.equal(zweiter.body.error, 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.')
|
||||
assert.equal((await ladeEinstellung(FEATURE)).einstellung.betrieb.name, 'Erster')
|
||||
assert.equal((await aendere('Zukunft', 9)).status, 409)
|
||||
})
|
||||
|
||||
test('PATCH einstellungen: Version fehlt oder ist keine Ganzzahl => 400; Eingabe unbrauchbar => 400 mit Feldfehlern', async () => {
|
||||
await speichere()
|
||||
const stand = await lade()
|
||||
for (const version of [undefined, null, '1', 1.5, -1, [], {}]) {
|
||||
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version, einstellung: stand.einstellung } })
|
||||
assert.equal(res.status, 400, JSON.stringify(version))
|
||||
assert.ok(res.body.fehler.version)
|
||||
}
|
||||
for (const einstellungWert of [null, 'x', 5, [], undefined]) {
|
||||
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: 1, einstellung: einstellungWert } })
|
||||
assert.equal(res.status, 400, JSON.stringify(einstellungWert))
|
||||
assert.ok(res.body.fehler._)
|
||||
}
|
||||
const kaputt = structuredClone(stand.einstellung)
|
||||
kaputt.betrieb.impressumUrl = 'javascript:alert(1)'
|
||||
kaputt.farbe = 'rot'
|
||||
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: 1, einstellung: kaputt } })
|
||||
assert.equal(res.status, 400)
|
||||
assert.deepEqual(Object.keys(res.body.fehler).sort(), ['betrieb.impressumUrl', 'farbe'])
|
||||
assert.equal(res.body.error, res.body.fehler['betrieb.impressumUrl'])
|
||||
assert.equal((await ladeEinstellung(FEATURE)).version, 1, 'nichts gespeichert')
|
||||
})
|
||||
|
||||
test('PATCH einstellungen: Leistung oder Person mit kuenftigen Terminen loeschen => 400 mit Anzahl, Deaktivieren geht', async () => {
|
||||
await speichere()
|
||||
seedBuchung('b_a', { leistungId: S_ANFRAGE, status: 'angefragt' })
|
||||
seedBuchung('b_b', { leistungId: S_ANFRAGE, status: 'bestaetigt', ressourceId: R2 })
|
||||
seedBuchung('b_vorbei', { leistungId: S_ANFRAGE, startsAt: '2026-10-01T08:00:00.000+00:00', endsAt: '2026-10-01T09:00:00.000+00:00', blockEndsAt: '2026-10-01T09:00:00.000+00:00' })
|
||||
const stand = await lade()
|
||||
const patch = (einstellungWert, version = stand.version) => send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version, einstellung: einstellungWert } })
|
||||
|
||||
const ohneLeistung = { ...stand.einstellung, leistungen: stand.einstellung.leistungen.filter((l) => l.id !== S_ANFRAGE) }
|
||||
const res = await patch(ohneLeistung)
|
||||
assert.equal(res.status, 400)
|
||||
assert.equal(res.body.fehler[`leistungen.${S_ANFRAGE}`], 'Für diese Leistung gibt es noch 2 künftige Termine. Deaktiviere sie stattdessen.')
|
||||
assert.match(res.body.error, /2 künftige Termine/)
|
||||
|
||||
// ein abgesagter Termin zaehlt nicht mehr
|
||||
seedBuchung('b_b', { leistungId: S_ANFRAGE, status: 'abgesagt', ressourceId: R2 })
|
||||
const eins = await patch(ohneLeistung)
|
||||
assert.equal(eins.status, 400)
|
||||
assert.match(eins.body.error, /1 künftigen Termin\b/)
|
||||
|
||||
const ohnePerson = { ...stand.einstellung, ressourcen: stand.einstellung.ressourcen.filter((r) => r.id !== R1) }
|
||||
const person = await patch(ohnePerson)
|
||||
assert.equal(person.status, 400)
|
||||
assert.ok(person.body.fehler[`ressourcen.${R1}`], 'auch die Person mit Terminen bleibt')
|
||||
|
||||
// deaktivieren: erlaubt, die Termine bleiben
|
||||
const deaktiviert = { ...stand.einstellung, leistungen: stand.einstellung.leistungen.map((l) => (l.id === S_ANFRAGE ? { ...l, aktiv: false } : l)) }
|
||||
const ok = await patch(deaktiviert)
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||
assert.equal(ok.body.einstellung.leistungen.find((l) => l.id === S_ANFRAGE).aktiv, false)
|
||||
assert.equal(buchungen().length, 3)
|
||||
|
||||
// ohne Termine: loeschen erlaubt
|
||||
for (const id of [...aw.docs('termineBookings').keys()]) await deleteDocument('termineBookings', id)
|
||||
const geloescht = await patch(ohneLeistung, ok.body.version)
|
||||
assert.equal(geloescht.status, 200, JSON.stringify(geloescht.body))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Kalender-Link
|
||||
|
||||
test('POST ressourcen/:id/feed: neuer Link nur fuer diese Person, alter Link ungueltig, Version zaehlt hoch', async () => {
|
||||
await speichere()
|
||||
const vorher = await lade()
|
||||
const res = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { cookie, body: { projectId: P } })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.match(res.body.url, new RegExp(`^${app.url}/api/public/termine/v1/kalender/${SITE_KEY}/[A-Za-z0-9_-]{43}\\.ics$`))
|
||||
assert.notEqual(res.body.url, vorher.feeds[R1])
|
||||
assert.equal(res.body.version, 2, 'die Einstellungsseite braucht die neue Version fuer ihr naechstes Speichern')
|
||||
|
||||
const nachher = await lade()
|
||||
assert.equal(nachher.feeds[R1], res.body.url)
|
||||
assert.equal(nachher.feeds[R2], vorher.feeds[R2], 'die andere Person behaelt ihren Link')
|
||||
assert.equal(nachher.version, 2)
|
||||
assert.equal((await fetch(vorher.feeds[R1])).status, 404)
|
||||
assert.equal((await fetch(res.body.url)).status, 200)
|
||||
assert.equal((await fetch(vorher.feeds[R2])).status, 200)
|
||||
|
||||
// sonst bleibt alles gleich
|
||||
assert.deepEqual(nachher.einstellung, vorher.einstellung)
|
||||
})
|
||||
|
||||
test('POST ressourcen/:id/feed: unbekannte Person oder keine Einstellung => 404, nichts geschrieben', async () => {
|
||||
const ohne = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { cookie, body: { projectId: P } })
|
||||
assert.equal(ohne.status, 404)
|
||||
assert.equal(aw.docs('termineConfig').size, 0)
|
||||
await speichere()
|
||||
for (const id of ['r_unbekannt', '__proto__', 'x'.repeat(200)]) {
|
||||
const res = await send('POST', `/api/termine/ressourcen/${id}/feed`, { cookie, body: { projectId: P } })
|
||||
assert.equal(res.status, 404, id)
|
||||
assert.equal(res.body.error, 'Diese Person oder diesen Platz gibt es nicht.')
|
||||
}
|
||||
assert.equal((await ladeEinstellung(FEATURE)).version, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Buchungen lesen
|
||||
|
||||
test('GET buchungen: Felder, Sortierung, Zeitraum, Statusfilter, Sperren, Zustellfehler', async () => {
|
||||
await speichere()
|
||||
seedBuchung('b_spaet', { startsAt: '2026-10-12T13:00:00.000+00:00', endsAt: '2026-10-12T13:45:00.000+00:00', blockEndsAt: '2026-10-12T13:50:00.000+00:00', status: 'angefragt', leistungId: S_ANFRAGE, leistungName: 'Besichtigung', gastName: 'Spät', erinnerung: true })
|
||||
seedBuchung('b_frueh', { angenommenAt: '2026-10-05T07:30:00.000+00:00', abgesagtVon: '', gastNachricht: 'Bitte ohne Föhnen' })
|
||||
seedBuchung('b_abgesagt', {
|
||||
startsAt: '2026-10-13T08:00:00.000+00:00', endsAt: '2026-10-13T08:45:00.000+00:00', blockEndsAt: '2026-10-13T08:50:00.000+00:00',
|
||||
status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: 'Krank', widerrufenAt: '2026-10-06T10:00:00.000+00:00',
|
||||
})
|
||||
seedBuchung('b_sperre', {
|
||||
startsAt: '2026-10-12T10:00:00.000+00:00', endsAt: '2026-10-12T11:00:00.000+00:00', blockEndsAt: '2026-10-12T11:00:00.000+00:00',
|
||||
quelle: 'sperre', leistungId: '', leistungName: 'Mittagspause', gastName: '', gastEmail: '', gastTelefon: '', preisCent: 0, preisArt: 'keiner', verbraucher: false,
|
||||
})
|
||||
seedBuchung('b_oktober_spaet', { startsAt: '2026-11-20T08:00:00.000+00:00', endsAt: '2026-11-20T08:45:00.000+00:00', blockEndsAt: '2026-11-20T08:50:00.000+00:00' })
|
||||
seedBuchung('b_anderer', { featureId: 'f_anderer_termine' })
|
||||
aw.seed('termineOutbox', { $id: 'o1', featureId: FEATURE, bookingId: 'b_frueh', art: 'G02', status: 'fehler', an: 'max@example.com' })
|
||||
aw.seed('termineOutbox', { $id: 'o2', featureId: FEATURE, bookingId: 'b_spaet', art: 'G02', status: 'gesendet', an: 'max@example.com' })
|
||||
|
||||
const res = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-13', { cookie })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body.buchungen.map((b) => b.id), ['b_frueh', 'b_sperre', 'b_spaet', 'b_abgesagt'], 'nach Beginn, ohne fremde Funktion und ohne November')
|
||||
const [frueh, sperre, spaet, abgesagt] = res.body.buchungen
|
||||
assert.deepEqual(Object.keys(frueh), [
|
||||
'id', 'nummer', 'status', 'quelle', 'startsAt', 'endsAt', 'datum', 'zeit', 'leistungId', 'leistung', 'dauer', 'titel', 'person', 'ressourceId',
|
||||
'gastName', 'gastEmail', 'gastTelefon', 'gastNachricht', 'erinnerung', 'zustellFehler', 'angenommenAt', 'widerrufenAt', 'abgesagtVon', 'absageGrund',
|
||||
])
|
||||
assert.deepEqual(frueh, {
|
||||
id: 'b_frueh', nummer: buchungsNummer('b_frueh'), status: 'bestaetigt', quelle: 'online',
|
||||
startsAt: '2026-10-12T08:00:00.000Z', endsAt: '2026-10-12T08:45:00.000Z', datum: '2026-10-12', zeit: '10:00',
|
||||
leistungId: S_SOFORT, leistung: 'Damenschnitt', dauer: 45, titel: 'Damenschnitt: Max Mustermann', person: 'Anna', ressourceId: R1,
|
||||
gastName: 'Max Mustermann', gastEmail: 'max@example.com', gastTelefon: '0171 1234567', gastNachricht: 'Bitte ohne Föhnen',
|
||||
erinnerung: false, zustellFehler: true, angenommenAt: '2026-10-05T07:30:00.000Z', widerrufenAt: null, abgesagtVon: '', absageGrund: '',
|
||||
})
|
||||
assert.match(frueh.nummer, /^[A-Z0-9]{8}$/)
|
||||
assert.equal(spaet.zeit, '15:00')
|
||||
assert.equal(spaet.erinnerung, true)
|
||||
assert.equal(spaet.zustellFehler, false, 'gesendet ist kein Fehler')
|
||||
assert.equal(sperre.quelle, 'sperre')
|
||||
assert.equal(sperre.leistung, '')
|
||||
assert.equal(sperre.titel, 'Mittagspause')
|
||||
assert.equal(sperre.dauer, 60)
|
||||
assert.equal(sperre.zustellFehler, false)
|
||||
assert.equal(abgesagt.abgesagtVon, 'betrieb')
|
||||
assert.equal(abgesagt.absageGrund, 'Krank', 'wie gespeichert')
|
||||
assert.equal(abgesagt.widerrufenAt, '2026-10-06T10:00:00.000Z')
|
||||
|
||||
const gefiltert = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-13&status=angefragt,abgesagt', { cookie })
|
||||
assert.deepEqual(gefiltert.body.buchungen.map((b) => b.id), ['b_spaet', 'b_abgesagt'])
|
||||
const eins = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-12&status=bestaetigt', { cookie })
|
||||
assert.deepEqual(eins.body.buchungen.map((b) => b.id), ['b_frueh', 'b_sperre'])
|
||||
const leer = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-01&bis=2026-10-02', { cookie })
|
||||
assert.deepEqual(leer.body, { buchungen: [] })
|
||||
})
|
||||
|
||||
test('GET buchungen: Zeitraum hoechstens 62 Tage, von vor bis, gueltige Tage, nur bekannte Status', async () => {
|
||||
const url = (query) => `/api/termine/buchungen?projectId=p1&${query}`
|
||||
assert.equal((await send('GET', url('von=2026-10-01&bis=2026-12-02'), { cookie })).status, 200, '62 Tage Abstand')
|
||||
for (const [query, feld] of [
|
||||
['von=2026-10-01&bis=2026-12-03', 'bis'],
|
||||
['von=2026-10-10&bis=2026-10-09', 'bis'],
|
||||
['von=2026-10-01', 'von'],
|
||||
['bis=2026-10-01', 'von'],
|
||||
['von=heute&bis=morgen', 'von'],
|
||||
['von=2026-02-30&bis=2026-03-01', 'von'],
|
||||
['von=2026-10-01&bis=2026-10-02&status=bestaetigt,kaputt', 'status'],
|
||||
['von=2026-10-01&bis=2026-10-02&status=bestaetigt&status=abgesagt', 'status'],
|
||||
['von=2026-10-01&bis=2026-10-02&status=,', 'status'],
|
||||
]) {
|
||||
const res = await send('GET', url(query), { cookie })
|
||||
assert.equal(res.status, 400, query)
|
||||
assert.ok(res.body.fehler[feld], query)
|
||||
}
|
||||
})
|
||||
|
||||
test('GET zeiten: freie Zeiten ohne Vorlauf und Horizont (Betrieb), mit ohne=Buchung wieder frei; Eingabe geprueft', async () => {
|
||||
await speichere()
|
||||
const zeiten = (query) => send('GET', `/api/termine/zeiten?projectId=p1&${query}`, { cookie })
|
||||
|
||||
// Montag, 10:00 Berlin = jetzt: Gaeste sehen wegen vorlaufMin 120 erst ab 12:00, der Betrieb ab jetzt
|
||||
const heute = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=2026-10-05`)
|
||||
assert.equal(heute.status, 200)
|
||||
assert.deepEqual(Object.keys(heute.body), ['zeiten'])
|
||||
assert.equal(heute.body.zeiten[0], '10:00')
|
||||
assert.equal(heute.body.zeiten.at(-1), '17:00')
|
||||
// 'egal' ist die Vorgabe
|
||||
assert.deepEqual((await zeiten(`leistung=${S_SOFORT}&tag=2026-10-05`)).body.zeiten, heute.body.zeiten)
|
||||
// jenseits des Buchungszeitraums (90 Tage)
|
||||
assert.ok((await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=2027-03-01`)).body.zeiten.length > 0)
|
||||
|
||||
seedBuchung('b_belegt')
|
||||
const belegt = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}`)
|
||||
assert.ok(!belegt.body.zeiten.includes('10:00'))
|
||||
assert.ok(!belegt.body.zeiten.includes('10:30'))
|
||||
assert.ok(belegt.body.zeiten.includes('09:00'))
|
||||
const mit = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}&ohne=b_belegt`)
|
||||
assert.ok(mit.body.zeiten.includes('10:00'), 'die verschobene Buchung belegt ihre neue Zeit nicht selbst')
|
||||
assert.ok((await zeiten(`leistung=${S_SOFORT}&ressource=egal&tag=${TAG}`)).body.zeiten.includes('10:00'), 'bei egal ist Ben frei')
|
||||
|
||||
for (const [query, feld] of [
|
||||
[`ressource=${R1}&tag=${TAG}`, 'leistung'],
|
||||
[`leistung=s_unbekannt&tag=${TAG}`, 'leistung'],
|
||||
[`leistung=${S_SOFORT}&ressource=r_unbekannt&tag=${TAG}`, 'ressource'],
|
||||
[`leistung=${S_SOFORT}&ressource=${R1}&ressource=${R2}&tag=${TAG}`, 'ressource'],
|
||||
[`leistung=${S_SOFORT}`, 'tag'],
|
||||
[`leistung=${S_SOFORT}&tag=2026-02-30`, 'tag'],
|
||||
[`leistung=${S_SOFORT}&tag=${TAG}&ohne=a%20b`, 'ohne'],
|
||||
]) {
|
||||
const res = await zeiten(query)
|
||||
assert.equal(res.status, 400, query)
|
||||
assert.ok(res.body.fehler[feld], query)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Buchungen schreiben
|
||||
|
||||
const manuellBody = (over = {}) => ({ projectId: P, art: 'manuell', leistung: S_SOFORT, ressource: R1, tag: TAG, zeit: '11:00', name: 'Paula Telefon', telefon: '06301 555', ...over })
|
||||
|
||||
test('POST buchungen manuell: 201 mit Nummer, Handeintrag bestaetigt und belegt; Fehler 400, belegte Zeit 409; Liste zeigt ihn', async () => {
|
||||
await speichere()
|
||||
const res = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody() })
|
||||
assert.equal(res.status, 201, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['id', 'nummer', 'status'])
|
||||
assert.equal(res.body.status, 'bestaetigt')
|
||||
assert.equal(res.body.nummer, buchungsNummer(res.body.id))
|
||||
const doc = aw.docs('termineBookings').get(res.body.id)
|
||||
assert.equal(doc.quelle, 'manuell')
|
||||
assert.equal(doc.gastName, 'Paula Telefon')
|
||||
assert.equal(doc.featureId, FEATURE)
|
||||
assert.equal(aw.docs('termineClaims').size, 10, '50 Minuten in 5-Minuten-Zellen')
|
||||
|
||||
const liste = await send('GET', `/api/termine/buchungen?projectId=p1&von=${TAG}&bis=${TAG}`, { cookie })
|
||||
assert.equal(liste.body.buchungen[0].quelle, 'manuell')
|
||||
assert.equal(liste.body.buchungen[0].zeit, '11:00')
|
||||
|
||||
const doppelt = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody({ name: 'Zweite' }) })
|
||||
assert.equal(doppelt.status, 409)
|
||||
assert.equal(doppelt.body.code, 'SLOT_VERGEBEN')
|
||||
assert.ok(doppelt.body.fehler.zeit)
|
||||
|
||||
const ohneName = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody({ name: '', zeit: '13:00' }) })
|
||||
assert.equal(ohneName.status, 400)
|
||||
assert.ok(ohneName.body.fehler.name)
|
||||
for (const art of [undefined, 'online', 'Sperre', null, 5]) {
|
||||
const kaputt = await send('POST', '/api/termine/buchungen', { cookie, body: { ...manuellBody(), art } })
|
||||
assert.equal(kaputt.status, 400, String(art))
|
||||
assert.ok(kaputt.body.fehler.art)
|
||||
}
|
||||
assert.equal(buchungen().length, 1)
|
||||
})
|
||||
|
||||
test('POST buchungen sperre und DELETE: Zeit sperren (201), doppelt 409, nur Sperren loeschbar (200), alles andere 404', async () => {
|
||||
await speichere()
|
||||
seedBuchung('b_normal')
|
||||
const sperre = { projectId: P, art: 'sperre', ressource: R1, tag: '2026-10-13', von: '12:00', bis: '13:00', titel: 'Mittag' }
|
||||
const res = await send('POST', '/api/termine/buchungen', { cookie, body: sperre })
|
||||
assert.equal(res.status, 201, JSON.stringify(res.body))
|
||||
assert.equal(res.body.status, 'bestaetigt')
|
||||
assert.equal(aw.docs('termineBookings').get(res.body.id).quelle, 'sperre')
|
||||
|
||||
const nochmal = await send('POST', '/api/termine/buchungen', { cookie, body: sperre })
|
||||
assert.equal(nochmal.status, 409)
|
||||
assert.equal(nochmal.body.code, 'SLOT_VERGEBEN')
|
||||
assert.ok(nochmal.body.fehler.von)
|
||||
const zulang = await send('POST', '/api/termine/buchungen', { cookie, body: { ...sperre, von: '08:00', bis: '18:00' } })
|
||||
assert.equal(zulang.status, 400)
|
||||
assert.ok(zulang.body.fehler.bis)
|
||||
|
||||
// ein Termin ist keine Sperre
|
||||
const normal = await send('DELETE', `/api/termine/buchungen/b_normal?projectId=${P}`, { cookie })
|
||||
assert.equal(normal.status, 404)
|
||||
assert.equal(normal.body.error, 'Diese Sperre gibt es nicht.')
|
||||
assert.ok(aw.docs('termineBookings').has('b_normal'))
|
||||
|
||||
const weg = await send('DELETE', `/api/termine/buchungen/${res.body.id}?projectId=${P}`, { cookie })
|
||||
assert.equal(weg.status, 200)
|
||||
assert.deepEqual(weg.body, { status: 'entsperrt' })
|
||||
assert.ok(!aw.docs('termineBookings').has(res.body.id))
|
||||
assert.equal((await send('DELETE', `/api/termine/buchungen/${res.body.id}?projectId=${P}`, { cookie })).status, 404)
|
||||
assert.equal((await send('DELETE', `/api/termine/buchungen/b_normal`, { cookie })).status, 400, 'ohne projectId')
|
||||
})
|
||||
|
||||
test('POST buchungen und Aktionen ohne gespeicherte Einstellung => 409 "zuerst einrichten"', async () => {
|
||||
for (const [method, pfad, body] of [
|
||||
['POST', '/api/termine/buchungen', manuellBody()],
|
||||
['POST', '/api/termine/buchungen/x/aktion', { projectId: P, aktion: 'annehmen' }],
|
||||
['DELETE', `/api/termine/buchungen/x?projectId=${P}`],
|
||||
['GET', `/api/termine/zeiten?projectId=${P}&leistung=${S_SOFORT}&tag=${TAG}`],
|
||||
]) {
|
||||
const res = await send(method, pfad, { cookie, body })
|
||||
assert.equal(res.status, 409, `${method} ${pfad}`)
|
||||
assert.equal(res.body.code, 'keine_einstellung')
|
||||
}
|
||||
})
|
||||
|
||||
test('POST aktion annehmen: Anfrage bestaetigt, G02 an den Gast; zweiter Klick aendert nichts (schon)', async () => {
|
||||
await speichere()
|
||||
seedBuchung('b_anfrage', { status: 'angefragt', leistungId: S_ANFRAGE, leistungName: 'Besichtigung', preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage' })
|
||||
const res = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { cookie, body: { projectId: P, aktion: 'annehmen' } })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body, { status: 'bestaetigt' })
|
||||
assert.equal(aw.docs('termineBookings').get('b_anfrage').status, 'bestaetigt')
|
||||
await warteAufVersand()
|
||||
assert.deepEqual(eintraege().map((m) => m.art), ['G02'])
|
||||
assert.equal(sink.sent.length, 1)
|
||||
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
|
||||
|
||||
const zweiter = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { cookie, body: { projectId: P, aktion: 'annehmen' } })
|
||||
assert.equal(zweiter.status, 200)
|
||||
assert.deepEqual(zweiter.body, { status: 'bestaetigt', schon: true })
|
||||
await warteAufVersand()
|
||||
assert.equal(sink.sent.length, 1, 'keine zweite Mail')
|
||||
})
|
||||
|
||||
test('POST aktion: absagen braucht einen Grund (400), Betrieb sagt ab (abgesagt von betrieb), unbekannt und Sperre 404, falscher Zustand 409', async () => {
|
||||
await speichere()
|
||||
seedBuchung('b_fest')
|
||||
seedBuchung('b_sperre', { quelle: 'sperre', gastName: '', gastEmail: '' })
|
||||
const aktion = (id, body) => send('POST', `/api/termine/buchungen/${id}/aktion`, { cookie, body: { projectId: P, ...body } })
|
||||
|
||||
const ohneGrund = await aktion('b_fest', { aktion: 'absagen' })
|
||||
assert.equal(ohneGrund.status, 400)
|
||||
assert.ok(ohneGrund.body.fehler.grund)
|
||||
const unbekannteAktion = await aktion('b_fest', { aktion: 'loeschen' })
|
||||
assert.equal(unbekannteAktion.status, 400)
|
||||
assert.ok(unbekannteAktion.body.fehler.aktion)
|
||||
|
||||
const ok = await aktion('b_fest', { aktion: 'absagen', grund: 'Krank' })
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||
assert.deepEqual(ok.body, { status: 'abgesagt' })
|
||||
const doc = aw.docs('termineBookings').get('b_fest')
|
||||
assert.equal(doc.abgesagtVon, 'betrieb')
|
||||
assert.equal(doc.absageGrund, 'Krank')
|
||||
|
||||
assert.equal((await aktion('b_gibtsnicht', { aktion: 'annehmen' })).status, 404)
|
||||
assert.equal((await aktion('b_sperre', { aktion: 'absagen', grund: 'x' })).status, 404, 'eine Sperre entsperrt man, man sagt sie nicht ab')
|
||||
seedBuchung('b_fremd', { featureId: 'f_anderer_termine' })
|
||||
assert.equal((await aktion('b_fremd', { aktion: 'absagen', grund: 'x' })).status, 404)
|
||||
const falsch = await aktion('b_fest', { aktion: 'annehmen' })
|
||||
assert.equal(falsch.status, 409)
|
||||
assert.equal(falsch.body.code, 'nicht_moeglich')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Export
|
||||
|
||||
async function rohExport(projectId = P, c = cookie) {
|
||||
const res = await fetch(`${app.url}/api/termine/export.csv?projectId=${projectId}`, { headers: { cookie: c } })
|
||||
const bytes = Buffer.from(await res.arrayBuffer())
|
||||
return { res, bytes, text: bytes.toString('utf8') }
|
||||
}
|
||||
|
||||
test('GET export.csv: UTF-8 mit BOM, Strichpunkt, Kopfzeile, nach Beginn sortiert, ohne Sperren und fremde Funktionen', async () => {
|
||||
await speichere()
|
||||
seedBuchung('b_zwei', { startsAt: '2026-10-14T08:00:00.000+00:00', endsAt: '2026-10-14T08:45:00.000+00:00', blockEndsAt: '2026-10-14T08:50:00.000+00:00', status: 'angefragt', gastName: 'Zweiter', gastEmail: 'zwei@example.com', gastTelefon: '', gastNachricht: 'Zeile 1\nZeile 2' })
|
||||
seedBuchung('b_eins', { gastName: 'Max "Maxe" Mustermann; Jr.' })
|
||||
seedBuchung('b_sperre', { quelle: 'sperre', leistungName: 'Pause', gastName: '', gastEmail: '' })
|
||||
seedBuchung('b_fremd', { featureId: 'f_anderer_termine', gastName: 'Fremd' })
|
||||
|
||||
const { res, bytes, text } = await rohExport()
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.headers.get('content-type'), 'text/csv; charset=utf-8')
|
||||
assert.match(res.headers.get('content-disposition'), /^attachment; filename="termine\.csv"$/)
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
|
||||
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
|
||||
const zeilen = text.replace(/^/, '').split('\r\n')
|
||||
assert.equal(zeilen[0], 'Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht')
|
||||
assert.equal(zeilen[1], `${buchungsNummer('b_eins')};Bestätigt;12.10.2026;10:00;Damenschnitt;Anna;"Max ""Maxe"" Mustermann; Jr.";max@example.com;0171 1234567;`)
|
||||
assert.equal(zeilen[2], `${buchungsNummer('b_zwei')};Angefragt;14.10.2026;10:00;Damenschnitt;Anna;Zweiter;zwei@example.com;;"Zeile 1\nZeile 2"`, 'Zeilenumbruch in der Zelle gequotet')
|
||||
assert.equal(zeilen[3], '', 'Zeilen enden mit CRLF')
|
||||
assert.equal(zeilen.length, 4)
|
||||
assert.ok(!text.includes('Pause') && !text.includes('Fremd'))
|
||||
})
|
||||
|
||||
test('GET export.csv: Zellen mit = + - @ am Anfang (auch nach Leerraum, Tab, CR) bekommen ein Hochkomma, harmlose bleiben', async () => {
|
||||
await speichere()
|
||||
const namen = ['=HYPERLINK("http://x")', '+49 171 1234567', '-1+1', '@SUM(A1)', ' =1+1', '\t=1+1', '\r=1+1', '\n+1', '\tText', 'Max Mustermann', 'a=b', 'Anna-Lena', '']
|
||||
namen.forEach((name, i) => seedBuchung(`b_${i}`, {
|
||||
gastName: name,
|
||||
gastNachricht: `=cmd|' /C calc'!A0 ${i}`,
|
||||
leistungName: '=Leistung',
|
||||
ressourceName: '@Ben',
|
||||
startsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:00:00.000+00:00`,
|
||||
endsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:45:00.000+00:00`,
|
||||
blockEndsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:50:00.000+00:00`,
|
||||
}))
|
||||
const { text } = await rohExport()
|
||||
const inhalt = text.replace(/^/, '')
|
||||
assert.ok(inhalt.includes(`;"'=HYPERLINK(""http://x"")";`), 'Anfuehrungszeichen verdoppelt, Hochkomma davor')
|
||||
assert.ok(inhalt.includes(";'+49 171 1234567;"))
|
||||
assert.ok(inhalt.includes(";'-1+1;"))
|
||||
assert.ok(inhalt.includes(";'@SUM(A1);"))
|
||||
assert.ok(inhalt.includes(";' =1+1;"))
|
||||
assert.ok(inhalt.includes(";'\t=1+1;"))
|
||||
assert.ok(inhalt.includes(";\"'\r=1+1\";"), 'CR gequotet')
|
||||
assert.ok(inhalt.includes(";\"'\n+1\";"))
|
||||
assert.ok(inhalt.includes(";'\tText;"))
|
||||
assert.ok(inhalt.includes(";Max Mustermann;"))
|
||||
assert.ok(inhalt.includes(";a=b;"))
|
||||
assert.ok(inhalt.includes(";Anna-Lena;"))
|
||||
// Leistung, Person und Nachricht ebenso
|
||||
assert.ok(inhalt.includes(";'=Leistung;'@Ben;"))
|
||||
assert.ok(inhalt.includes(";'=cmd|' /C calc'!A0 0\r\n"))
|
||||
// keine Zelle beginnt mehr mit einem der vier Zeichen
|
||||
for (const zeile of inhalt.split('\r\n').slice(1)) {
|
||||
for (const zelle of zeile.split(';')) assert.ok(!/^"?\s*[=+\-@]/.test(zelle), `Zelle ${JSON.stringify(zelle)}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Abgeschaltet (disabled)
|
||||
|
||||
test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => {
|
||||
const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' })
|
||||
await speichere(einstellung(), disabled)
|
||||
aw.seed('termineBookings', buchungsDoc('b_alt', { featureId: 'f_p4_termine', projectId: 'p4' }))
|
||||
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
|
||||
assert.equal(lesen.status, 200)
|
||||
assert.equal(lesen.body.state, 'disabled')
|
||||
assert.equal(lesen.body.einstellung.betrieb.name, 'Salon Müller')
|
||||
assert.deepEqual(lesen.body.feeds, {}, 'die Kalender-Links sind bei einer abgeschalteten Funktion tot')
|
||||
assert.equal((await send('GET', `/api/termine/buchungen?projectId=p4&von=${TAG}&bis=${TAG}`, { cookie })).body.buchungen.length, 1)
|
||||
const export_ = await rohExport('p4')
|
||||
assert.equal(export_.res.status, 200)
|
||||
assert.ok(export_.text.includes('Max Mustermann'))
|
||||
assert.equal((await send('GET', `/api/termine/zeiten?projectId=p4&leistung=${S_SOFORT}&tag=${TAG}`, { cookie })).status, 200)
|
||||
|
||||
const schreiben = [
|
||||
['PATCH', '/api/termine/einstellungen', { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung }],
|
||||
['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p4', vorlage: 'salon' }],
|
||||
['POST', '/api/termine/buchungen', { ...manuellBody(), projectId: 'p4' }],
|
||||
['POST', '/api/termine/buchungen/b_alt/aktion', { projectId: 'p4', aktion: 'absagen', grund: 'x' }],
|
||||
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p4' }],
|
||||
['DELETE', '/api/termine/buchungen/b_alt?projectId=p4'],
|
||||
]
|
||||
for (const [method, pfad, body] of schreiben) {
|
||||
const res = await send(method, pfad, { cookie, body })
|
||||
assert.equal(res.status, 409, `${method} ${pfad}`)
|
||||
assert.equal(res.body.code, 'abgeschaltet')
|
||||
}
|
||||
assert.equal(aw.docs('termineBookings').get('b_alt').status, 'bestaetigt')
|
||||
assert.equal(aw.docs('termineConfig').get('f_p4_termine').version, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Kundenansicht
|
||||
|
||||
test('Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert; ohne Gastdaten im Protokoll', async () => {
|
||||
await speichere()
|
||||
seedBuchung('b_anfrage', { status: 'angefragt', leistungId: S_ANFRAGE, gastName: 'Geheim Gast', gastEmail: 'geheim@example.com' })
|
||||
const stand = await lade()
|
||||
const k = kv()
|
||||
|
||||
const patch = await send('PATCH', '/api/termine/einstellungen', {
|
||||
...k,
|
||||
body: { projectId: P, version: stand.version, einstellung: { ...stand.einstellung, farbe: '#112233' } },
|
||||
})
|
||||
assert.equal(patch.status, 200, JSON.stringify(patch.body))
|
||||
assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'team:u_staff2')
|
||||
|
||||
const feed = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { ...k, body: { projectId: P } })
|
||||
assert.equal(feed.status, 200)
|
||||
const manuell = await send('POST', '/api/termine/buchungen', { ...k, body: manuellBody({ name: 'Privat Person' }) })
|
||||
assert.equal(manuell.status, 201, JSON.stringify(manuell.body))
|
||||
const sperre = await send('POST', '/api/termine/buchungen', { ...k, body: { projectId: P, art: 'sperre', ressource: R2, tag: '2026-10-13', von: '12:00', bis: '13:00' } })
|
||||
assert.equal(sperre.status, 201)
|
||||
const aktion = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { ...k, body: { projectId: P, aktion: 'ablehnen' } })
|
||||
assert.equal(aktion.status, 200, JSON.stringify(aktion.body))
|
||||
const zweiter = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { ...k, body: { projectId: P, aktion: 'ablehnen' } })
|
||||
assert.equal(zweiter.body.schon, true)
|
||||
const weg = await send('DELETE', `/api/termine/buchungen/${sperre.body.id}?projectId=${P}`, k)
|
||||
assert.equal(weg.status, 200)
|
||||
const vorlage = await send('POST', '/api/termine/einstellungen/vorlage', { ...k, body: { projectId: P, vorlage: 'salon' } })
|
||||
assert.equal(vorlage.status, 409, 'es gibt schon Einstellungen: nichts protokolliert')
|
||||
const fehlerhaft = await send('PATCH', '/api/termine/einstellungen', { ...k, body: { projectId: P, version: 99, einstellung: stand.einstellung } })
|
||||
assert.equal(fehlerhaft.status, 409)
|
||||
|
||||
const eintraege_ = logEintraege().sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||
assert.deepEqual(eintraege_.map((e) => [e.action, e.detail]), [
|
||||
['termin_einstellungen', 'Einstellungen gespeichert'],
|
||||
['termin_einstellungen', 'Kalender-Link erneuert'],
|
||||
['termin_manuell', 'manuell'],
|
||||
['termin_manuell', 'sperre'],
|
||||
['termin_aktion', `ablehnen ${buchungsNummer('b_anfrage')}`],
|
||||
['termin_aktion', `entsperren ${buchungsNummer(sperre.body.id)}`],
|
||||
])
|
||||
assert.ok(eintraege_.every((e) => e.customerId === KUNDE && e.staffName === 'Andrej' && e.staffUserId === 'u_staff2'))
|
||||
const alles = JSON.stringify(eintraege_)
|
||||
for (const geheim of ['Geheim Gast', 'geheim@example.com', 'Privat Person', '06301 555']) assert.ok(!alles.includes(geheim), geheim)
|
||||
})
|
||||
|
||||
test('Kundenansicht ohne Header oder mit fremder Kennung: Schreiben 409 view_replaced; Lesen geht; Kunde selbst schreibt ohne Protokoll', async () => {
|
||||
await speichere()
|
||||
const stand = await lade()
|
||||
const body = { projectId: P, version: stand.version, einstellung: stand.einstellung }
|
||||
const ohneHeader = await send('PATCH', '/api/termine/einstellungen', { cookie: kundenansichtCookie(), body })
|
||||
assert.equal(ohneHeader.status, 409)
|
||||
assert.equal(ohneHeader.body.code, 'view_replaced')
|
||||
const fremd = await send('PATCH', '/api/termine/einstellungen', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) }, body })
|
||||
assert.equal(fremd.body.code, 'view_replaced')
|
||||
assert.equal((await send('GET', einstellungenUrl(), { cookie: kundenansichtCookie() })).status, 200)
|
||||
assert.equal((await send('GET', `/api/termine/buchungen?projectId=${P}&von=${TAG}&bis=${TAG}`, { cookie: kundenansichtCookie() })).status, 200)
|
||||
assert.equal(aw.docs('termineConfig').get(FEATURE).version, 1)
|
||||
|
||||
const kunde = await send('PATCH', '/api/termine/einstellungen', { cookie, body })
|
||||
assert.equal(kunde.status, 200)
|
||||
assert.equal(logEintraege().length, 0, 'nur die Kundenansicht wird protokolliert')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Limit und Fehler
|
||||
|
||||
test('Schreib-Limit: 120 Aenderungen je Kunde und Stunde, die 121. ist 429; Lesen zaehlt nicht; anderer Kunde und naechste Stunde frei', async () => {
|
||||
await speichere()
|
||||
let jetzt = JETZT.getTime()
|
||||
setzeUhrFuerTests(() => new Date(jetzt))
|
||||
for (let i = 0; i < 40; i += 1) assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200)
|
||||
for (let i = 1; i <= 120; i += 1) {
|
||||
const res = await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie })
|
||||
assert.equal(res.status, 404, `Anfrage ${i}`)
|
||||
}
|
||||
const zuViele = await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie })
|
||||
assert.equal(zuViele.status, 429)
|
||||
assert.match(zuViele.body.error, /Zu viele Änderungen/)
|
||||
assert.equal((await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody() })).status, 429, 'alle Schreib-Routen teilen das Limit')
|
||||
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Lesen bleibt frei')
|
||||
|
||||
// ein anderer Kunde hat sein eigenes Limit
|
||||
assert.equal((await send('DELETE', `/api/termine/buchungen/x?projectId=p6`, { cookie: customerCookie('c2') })).status, 409, 'nicht 429 (keine Einstellung)')
|
||||
// eine Stunde spaeter
|
||||
jetzt += 61 * 60 * 1000
|
||||
assert.equal((await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie })).status, 404)
|
||||
})
|
||||
|
||||
test('Fehler von Appwrite verlassen den Server nie als Text: 500 "Interner Serverfehler"', async () => {
|
||||
await speichere()
|
||||
const echt = globalThis.fetch
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
if (/\/collections\/termineConfig\/documents\//.test(String(url)) && (optionen?.method ?? 'GET') === 'GET') {
|
||||
return new Response(JSON.stringify({ message: 'geheimer Appwrite-Text', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
return echt(url, optionen)
|
||||
}
|
||||
try {
|
||||
const res = await send('GET', einstellungenUrl(), { cookie })
|
||||
assert.equal(res.status, 500)
|
||||
assert.deepEqual(res.body, { error: 'Interner Serverfehler' })
|
||||
} finally {
|
||||
globalThis.fetch = echt
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user