Merge auftraege/l5-bestaetigung: Bestätigung und Mails (Strang L5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
5d5f6333a4
21 files changed
+2040
-30
No files matched your search
@@ -0,0 +1,259 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke
|
||||
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
|
||||
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = await import('../server/services/auftraege/bestaetigung.js')
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return aw.close()
|
||||
})
|
||||
|
||||
const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ)
|
||||
const ms = (iso) => new Date(iso).getTime()
|
||||
let sink
|
||||
let protokoll
|
||||
beforeEach(() => {
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
protokoll = mock.method(console, 'error', () => {})
|
||||
})
|
||||
afterEach(() => mock.restoreAll())
|
||||
const seed = (over = {}) => {
|
||||
const ab = abDokument(over)
|
||||
aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) })
|
||||
return ab
|
||||
}
|
||||
const betreffe = () => sink.sent.map((mail) => mail.subject)
|
||||
const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen'
|
||||
const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
|
||||
const norm = (text) => text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => {
|
||||
const lage = (over) => bestaetigungsLage(abDokument(over))
|
||||
assert.equal(lage({}), 'offen')
|
||||
assert.equal(lage({ status: 'abgerechnet' }), 'offen')
|
||||
assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
|
||||
assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
|
||||
for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => {
|
||||
const ab = seed()
|
||||
const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
|
||||
assert.equal(neu.status, 'bestaetigt')
|
||||
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
|
||||
assert.equal(neu.bestaetigtUeber, 'link')
|
||||
assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256)
|
||||
assert.equal(neu.fruehbeginn, true)
|
||||
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()])
|
||||
assert.deepEqual(betreffe(), [K32, T22])
|
||||
assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder')
|
||||
assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`))
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => {
|
||||
const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.equal(neu.bestaetigtUeber, 'portal')
|
||||
assert.equal(neu.fruehbeginn, false)
|
||||
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten')
|
||||
assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
|
||||
assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026'))
|
||||
assert.ok(sink.sent[1].text.includes('Weg: Kundenportal'))
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => {
|
||||
const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
|
||||
assert.equal(neu.fruehbeginn, false)
|
||||
assert.equal(neu.fruehbeginnFassung, '')
|
||||
for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/)
|
||||
assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => {
|
||||
const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.equal(neu.status, 'abgerechnet')
|
||||
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
|
||||
assert.equal((await getAuftrag('ab1')).status, 'abgerechnet')
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => {
|
||||
const ab = seed()
|
||||
const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
|
||||
const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) })
|
||||
assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm))
|
||||
assert.equal(zweite.bestaetigtUeber, 'link')
|
||||
assert.equal(zweite.fruehbeginn, true)
|
||||
assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung')
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => {
|
||||
const ab = seed()
|
||||
const [a, b] = await Promise.all([
|
||||
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
|
||||
bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }),
|
||||
])
|
||||
assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm))
|
||||
assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt')
|
||||
assert.equal(b.bestaetigtUeber, 'link')
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => {
|
||||
const ab = seed()
|
||||
let freigeben
|
||||
const gehalten = new Promise((resolve) => {
|
||||
freigeben = resolve
|
||||
})
|
||||
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
|
||||
let fertig = false
|
||||
const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => {
|
||||
fertig = true
|
||||
return neu
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
freigeben()
|
||||
await halter
|
||||
assert.equal((await bestaetigt).status, 'bestaetigt')
|
||||
// Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück)
|
||||
seed()
|
||||
sink.sent.length = 0
|
||||
const frei = []
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))]))
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.deepEqual(frei, [true, true])
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => {
|
||||
const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']]
|
||||
for (const [status, meldung] of faelle) {
|
||||
const ab = seed({ status })
|
||||
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status)
|
||||
}
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => {
|
||||
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
|
||||
const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.equal(neu.status, 'bestaetigt')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
|
||||
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll')
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => {
|
||||
const ab = seed()
|
||||
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError)
|
||||
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError)
|
||||
await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
// gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC)
|
||||
const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z')
|
||||
|
||||
test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => {
|
||||
const ab = seed()
|
||||
const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE })
|
||||
assert.equal(ok.ergebnis, 'widerrufen')
|
||||
assert.equal(ok.auftrag.status, 'widerrufen')
|
||||
assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime())
|
||||
assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime())
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
seed()
|
||||
const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) })
|
||||
assert.equal(spaet.ergebnis, 'nach_frist')
|
||||
assert.equal(spaet.auftrag.status, 'versendet')
|
||||
assert.equal((await getAuftrag('ab1')).widerrufenAm, null)
|
||||
})
|
||||
|
||||
test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => {
|
||||
const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true })
|
||||
const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT })
|
||||
assert.equal(r.ergebnis, 'widerrufen')
|
||||
assert.equal(r.auftrag.fruehbeginn, true)
|
||||
assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime())
|
||||
const firma = seed({ kunde: kunde('unternehmen') })
|
||||
const k = await widerrufeAuftrag(firma, { jetzt: JETZT })
|
||||
assert.equal(k.ergebnis, 'kein_widerrufsrecht')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
})
|
||||
|
||||
test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => {
|
||||
const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']]
|
||||
for (const [status, ergebnis] of faelle) {
|
||||
const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null })
|
||||
const r = await widerrufeAuftrag(ab, { jetzt: JETZT })
|
||||
assert.equal(r.ergebnis, ergebnis, status)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.equal(doc.status, status, status)
|
||||
assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => {
|
||||
const veraltet = seed()
|
||||
let freigeben
|
||||
const gehalten = new Promise((resolve) => {
|
||||
freigeben = resolve
|
||||
})
|
||||
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
|
||||
let fertig = false
|
||||
const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => {
|
||||
fertig = true
|
||||
return r
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
|
||||
seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3)
|
||||
freigeben()
|
||||
await halter
|
||||
const r = await widerruf
|
||||
assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'storniert')
|
||||
assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails')
|
||||
})
|
||||
|
||||
test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => {
|
||||
const ab = seed()
|
||||
const [bestaetigt, widerrufen] = await Promise.all([
|
||||
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
|
||||
widerrufeAuftrag(ab, { jetzt: JETZT }),
|
||||
])
|
||||
assert.equal(bestaetigt.status, 'bestaetigt')
|
||||
assert.equal(widerrufen.ergebnis, 'widerrufen')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
seed()
|
||||
const umgekehrt = await Promise.allSettled([
|
||||
widerrufeAuftrag(ab, { jetzt: JETZT }),
|
||||
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
|
||||
])
|
||||
assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen')
|
||||
assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
|
||||
import { hashToken } from '../server/services/auftraege/token.js'
|
||||
|
||||
// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau.
|
||||
// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv).
|
||||
const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes
|
||||
export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) }
|
||||
export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) }
|
||||
|
||||
export const TOKEN = 'T'.repeat(43)
|
||||
export const TOKEN_NEU = 'U'.repeat(43)
|
||||
export const SHA = 'a'.repeat(64)
|
||||
export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'
|
||||
|
||||
export const kunde = (kundenart = 'privat', extra = {}) => ({
|
||||
name: 'Erika Muster',
|
||||
firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '',
|
||||
strasse: 'Hauptstr. 1',
|
||||
plz: '67722',
|
||||
ort: 'Winnweiler',
|
||||
land: 'DE',
|
||||
email: 'k@example.invalid',
|
||||
kundenart,
|
||||
kundennummer: 'K-100',
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */
|
||||
export const abDokument = (over = {}) => ({
|
||||
id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet',
|
||||
kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [],
|
||||
art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '',
|
||||
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
|
||||
pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA,
|
||||
gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '',
|
||||
tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z',
|
||||
bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
|
||||
storniertAm: null, widerrufenAm: null,
|
||||
erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Portal-Routen /api/auftraege (V5): nur eigene, gesendete ABs; Schalter; Bestätigen mit Portal-Sitzung
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kunde-'))
|
||||
process.env.AUFTRAEGE_ENABLED = 'on'
|
||||
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { seedFixtures, customerCookie, adminCookie } = await import('./support/fixtures.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
|
||||
const app = await startTestApp({ '/api/auftraege': kundeRoutes })
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close()])
|
||||
})
|
||||
seedFixtures(aw)
|
||||
|
||||
const PDF = '%PDF-1.4 Kunden-AB'
|
||||
for (const [kundenId, id] of [['c1', 'ab1'], ['c2', 'ab3'], ['c1', 'ab4']]) await speicherePdf(kundenId, id, Buffer.from(PDF))
|
||||
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
|
||||
const ENTWURF = { id: 'ab2', nummer: '', jahr: null, laufnummer: null, status: 'entwurf', gesendetAm: null, gesendetAn: '', tokenHash: '', tokenGueltigBis: null, pdfDatei: '', pdfSha256: '' }
|
||||
const AB4 = { id: 'ab4', nummer: 'AB-2026-0003', status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', pdfDatei: 'c1/ab4.pdf', tokenHash: '' }
|
||||
const AB3 = { id: 'ab3', nummer: 'AB-2026-0002', kundenId: 'c2', pdfDatei: 'c2/ab3.pdf', kunde: kunde('privat', { email: 'k2@example.invalid' }), tokenHash: '' }
|
||||
|
||||
let sink
|
||||
const seed = (over = {}) => aw.seed('auftraege', { $id: over.id || 'ab1', ...auftragToDoc(abDokument(over)) })
|
||||
beforeEach(() => {
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
for (const over of [{}, ENTWURF, AB3, AB4]) seed(over)
|
||||
})
|
||||
const cookie = customerCookie('c1')
|
||||
const get = (pfad, opts = { cookie }) => send('GET', `/api/auftraege${pfad}`, opts)
|
||||
const post = (pfad, body, opts = { cookie }) => send('POST', `/api/auftraege${pfad}`, { ...opts, body })
|
||||
|
||||
test('GET /api/auftraege: nur eigene, nur gesendete ABs, mit id und kannBestaetigen, ohne interne Felder', async () => {
|
||||
const res = await get('')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.aktiv, true)
|
||||
assert.deepEqual(res.body.auftraege.map((a) => a.id).sort(), ['ab1', 'ab4'])
|
||||
const ab1 = res.body.auftraege.find((a) => a.id === 'ab1')
|
||||
const ab4 = res.body.auftraege.find((a) => a.id === 'ab4')
|
||||
assert.deepEqual([ab1.nummer, ab1.status, ab1.kannBestaetigen, ab4.status, ab4.kannBestaetigen], ['AB-2026-0001', 'versendet', true, 'bestaetigt', false])
|
||||
for (const intern of ['kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'gesendetAn']) assert.ok(!(intern in ab1), intern)
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
})
|
||||
|
||||
test('kannBestaetigen wie in V3: versendet und abgerechnet ohne frühere Bestätigung ja, sonst nein', async () => {
|
||||
const faelle = [
|
||||
[{}, true],
|
||||
[{ status: 'abgerechnet' }, true],
|
||||
[{ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
|
||||
[{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
|
||||
[{ status: 'ersetzt' }, false],
|
||||
[{ status: 'storniert' }, false],
|
||||
[{ status: 'widerrufen' }, false],
|
||||
]
|
||||
for (const [over, erwartet] of faelle) {
|
||||
seed(over)
|
||||
const ab1 = (await get('')).body.auftraege.find((a) => a.id === 'ab1')
|
||||
assert.ok(ab1, `${JSON.stringify(over)}: auch ersetzte, stornierte und widerrufene ABs stehen in der Liste`)
|
||||
assert.equal(ab1.kannBestaetigen, erwartet, JSON.stringify(over))
|
||||
}
|
||||
})
|
||||
|
||||
test('ohne Anmeldung und mit alter Mitarbeiter-Sitzung: 401, nichts ändert sich', async () => {
|
||||
for (const opts of [{}, { cookie: adminCookie() }]) {
|
||||
assert.equal((await get('', opts)).status, 401)
|
||||
assert.equal((await get('/ab1/pdf', opts)).status, 401)
|
||||
assert.equal((await post('/ab1/bestaetigen', { bestaetigt: true }, opts)).status, 401)
|
||||
}
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
})
|
||||
|
||||
test('Schalter aus: leere Liste, PDF und Bestätigen wie bei unbekannter AB; im Pilot nur für Adressen der Liste', async () => {
|
||||
config.auftraege.mode = 'off'
|
||||
assert.deepEqual((await get('')).body, { aktiv: false, auftraege: [] })
|
||||
for (const res of [await get('/ab1/pdf'), await post('/ab1/bestaetigen', { bestaetigt: true })]) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }])
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['k@example.invalid']
|
||||
assert.equal((await get('')).body.aktiv, true)
|
||||
assert.deepEqual((await get('', { cookie: customerCookie('c2') })).body, { aktiv: false, auftraege: [] })
|
||||
})
|
||||
|
||||
test('GET /api/auftraege/:id/pdf: eigene AB (auch ersetzt), nie fremde, Entwürfe, unbekannte oder ungültige IDs', async () => {
|
||||
const eigene = await get('/ab1/pdf')
|
||||
assert.deepEqual([eigene.status, eigene.headers.get('content-type'), eigene.body], [200, 'application/pdf', PDF])
|
||||
assert.equal(eigene.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
|
||||
seed({ status: 'ersetzt' })
|
||||
assert.equal((await get('/ab1/pdf')).status, 200)
|
||||
for (const id of ['ab3', 'ab2', 'gibtsnicht', '..%2F..%2Fx', 'a'.repeat(40)]) {
|
||||
const res = await get(`/${id}/pdf`)
|
||||
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
|
||||
}
|
||||
})
|
||||
|
||||
test('POST …/bestaetigen: bestätigt über das Portal (Protokoll, K32, T22); der zweite Aufruf ändert nichts', async () => {
|
||||
const vorher = Date.now()
|
||||
const res = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: true })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual([res.body.auftrag.id, res.body.auftrag.status, res.body.auftrag.kannBestaetigen], ['ab1', 'bestaetigt', false])
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.bestaetigtUeber, doc.fruehbeginn], ['portal', true])
|
||||
assert.ok(new Date(doc.bestaetigtAm).getTime() >= vorher - 1000 && new Date(doc.bestaetigtAm).getTime() <= Date.now() + 1000)
|
||||
assert.equal(sink.sent.length, 2)
|
||||
const zweite = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: false })
|
||||
assert.deepEqual([zweite.status, zweite.body], [200, res.body])
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('POST …/bestaetigen: ohne bestaetigt: true 400; fremde, Entwurf, unbekannte AB 404; ersetzt, storniert, widerrufen 410 – nie eine Änderung', async () => {
|
||||
for (const body of [{}, { bestaetigt: 'ja' }, { bestaetigt: false }]) {
|
||||
const res = await post('/ab1/bestaetigen', body)
|
||||
assert.deepEqual([res.status, res.body], [400, { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }])
|
||||
}
|
||||
for (const id of ['ab3', 'ab2', 'gibtsnicht']) {
|
||||
const res = await post(`/${id}/bestaetigen`, { bestaetigt: true })
|
||||
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
|
||||
}
|
||||
for (const [status, text] of [['ersetzt', 'ersetzt'], ['storniert', 'storniert'], ['widerrufen', 'widerrufen']]) {
|
||||
seed({ status })
|
||||
const res = await post('/ab1/bestaetigen', { bestaetigt: true })
|
||||
assert.deepEqual([res.status, res.body], [410, { error: `Diese Auftragsbestätigung wurde ${text}.` }], status)
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null, status)
|
||||
}
|
||||
assert.equal((await getAuftrag('ab3')).bestaetigtAm, null)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Bestätigen ist eine Erklärung im Namen des Kunden und bleibt gesperrt
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kundenansicht-'))
|
||||
process.env.AUFTRAEGE_ENABLED = 'on'
|
||||
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
|
||||
const { default: tokenRoutes } = await import('../server/routes/auftrag.js')
|
||||
// Mount-Pfade und Reihenfolge wie in server/index.js: der Wächter steht vor den Routen
|
||||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/auftraege': kundeRoutes, '/api/auftrag': tokenRoutes })
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close()])
|
||||
})
|
||||
seedFixtures(aw)
|
||||
await speicherePdf('c1', 'ab1', Buffer.from('%PDF-1.4 Test'))
|
||||
|
||||
const VIEW = 'a'.repeat(32)
|
||||
const kv = () => ({ cookie: kundenansichtCookie({ customerId: 'c1', viewId: VIEW }), headers: { 'x-kundenansicht': VIEW } })
|
||||
let sink
|
||||
beforeEach(() => {
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument()) })
|
||||
})
|
||||
const unveraendert = async () => {
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
|
||||
}
|
||||
|
||||
test('kein Eintrag der Erlaubnisliste gilt für Aufträge', () => {
|
||||
for (const pfad of ['/api/auftraege', '/api/auftraege/ab1/bestaetigen', '/api/auftraege/ab1/pdf', `/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}/bestaetigen`]) {
|
||||
assert.ok(!KUNDENANSICHT_ALLOWED.some(([, muster]) => muster.test(pfad)), pfad)
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/auftraege/:id/bestaetigen in der Kundenansicht: 403 view_blocked, die AB bleibt unverändert', async () => {
|
||||
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { ...kv(), body: { bestaetigt: true, fruehbeginn: true } })
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal(res.body.code, 'view_blocked')
|
||||
await unveraendert()
|
||||
})
|
||||
|
||||
test('alle Schreib-Routen des Kunden-Routers sind in der Kundenansicht gesperrt (nur Bestätigen ist eine)', async () => {
|
||||
const schreibRouten = kundeRoutes.stack
|
||||
.filter((schicht) => schicht.route)
|
||||
.flatMap((schicht) => Object.keys(schicht.route.methods).filter((m) => !['get', 'head', 'options', '_all'].includes(m)).map((m) => [m.toUpperCase(), `/api/auftraege${String(schicht.route.path).replace(/:[A-Za-z]+/g, 'ab1')}`]))
|
||||
assert.deepEqual(schreibRouten, [['POST', '/api/auftraege/ab1/bestaetigen']])
|
||||
for (const [methode, pfad] of schreibRouten) {
|
||||
const res = await send(methode, pfad, { ...kv(), body: { bestaetigt: true } })
|
||||
assert.equal(res.status, 403, `${methode} ${pfad}`)
|
||||
}
|
||||
await unveraendert()
|
||||
})
|
||||
|
||||
test('auch der öffentliche Link bestätigt nichts, wenn der Browser eine Kundenansicht-Sitzung trägt', async () => {
|
||||
const mitKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { ...kv(), body: { bestaetigt: true } })
|
||||
assert.deepEqual([mitKennung.status, mitKennung.body.code], [403, 'view_blocked'])
|
||||
const ohneKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { cookie: kv().cookie, body: { bestaetigt: true } })
|
||||
assert.deepEqual([ohneKennung.status, ohneKennung.body.code], [409, 'view_replaced'])
|
||||
await unveraendert()
|
||||
})
|
||||
|
||||
test('Lesen bleibt erlaubt, kannBestaetigen ist in der Kundenansicht false; der Kunde selbst darf bestätigen', async () => {
|
||||
const liste = await send('GET', '/api/auftraege', kv())
|
||||
assert.equal(liste.status, 200)
|
||||
assert.deepEqual(liste.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', false]])
|
||||
assert.equal((await send('GET', '/api/auftraege/ab1/pdf', kv())).status, 200)
|
||||
const selbst = await send('GET', '/api/auftraege', { cookie: customerCookie('c1') })
|
||||
assert.deepEqual(selbst.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', true]])
|
||||
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { cookie: customerCookie('c1'), body: { bestaetigt: true } })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort.
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-'))
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
|
||||
const { euroDe } = await import('../server/services/finance/format.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js')
|
||||
|
||||
const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-'))
|
||||
const AB_PDF = Buffer.from('%PDF-1.4 AB-Test')
|
||||
const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test')
|
||||
writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF)
|
||||
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
|
||||
after(() => {
|
||||
setzeAgbVerzeichnisFuerTests(null)
|
||||
setMailTransportForTests(null)
|
||||
})
|
||||
|
||||
let sink
|
||||
let protokoll
|
||||
beforeEach(() => {
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
protokoll = mock.method(console, 'error', () => {})
|
||||
})
|
||||
afterEach(() => mock.restoreAll())
|
||||
const adressen = (liste) => liste.map((a) => a.address)
|
||||
|
||||
const AB = abDokument()
|
||||
const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') }
|
||||
const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over })
|
||||
const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei'
|
||||
const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
|
||||
const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' }
|
||||
|
||||
test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-<Fassung>.pdf; eine ungültige Fassung wirft', () => {
|
||||
setzeAgbVerzeichnisFuerTests(null)
|
||||
try {
|
||||
const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url))
|
||||
assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`))
|
||||
assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf'))
|
||||
for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung)
|
||||
} finally {
|
||||
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
|
||||
}
|
||||
})
|
||||
|
||||
test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => {
|
||||
assert.deepEqual(k31Mail(AB, { token: TOKEN }), {
|
||||
template: 'K31',
|
||||
values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` },
|
||||
blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true },
|
||||
})
|
||||
// auch wenn man das Token mitgibt: die Teamkopie enthält es nie
|
||||
assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), {
|
||||
template: 'K31',
|
||||
values: { ...WERTE_K31, link: '' },
|
||||
blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false },
|
||||
})
|
||||
const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true })
|
||||
assert.equal(nachts.values.datum, '07.10.2026')
|
||||
assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`)
|
||||
assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true })
|
||||
})
|
||||
|
||||
test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => {
|
||||
const ja = BESTAETIGT({ fruehbeginn: true })
|
||||
assert.deepEqual(k32Mail(ja), {
|
||||
template: 'K32',
|
||||
values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT },
|
||||
blocks: { fruehbeginn: true, ohne_fruehbeginn: false },
|
||||
})
|
||||
const nein = BESTAETIGT({ bestaetigtUeber: 'portal' })
|
||||
assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026')
|
||||
assert.equal(k32Mail(nein).values.fruehbeginn_text, '')
|
||||
assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true })
|
||||
const firma = BESTAETIGT({ kunde: kunde('unternehmen') })
|
||||
assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false })
|
||||
assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', ''])
|
||||
assert.deepEqual(t22Mail(nein), {
|
||||
template: 'T22',
|
||||
values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' },
|
||||
blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true },
|
||||
})
|
||||
assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH')
|
||||
assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink')
|
||||
assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false })
|
||||
})
|
||||
|
||||
test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => {
|
||||
const nachrichten = [
|
||||
k31Mail(AB, { token: TOKEN }),
|
||||
k31Mail(AB, { token: TOKEN, teamkopie: true }),
|
||||
k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }),
|
||||
k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }),
|
||||
k32Mail(BESTAETIGT({ fruehbeginn: true })),
|
||||
k32Mail(BESTAETIGT()),
|
||||
t22Mail(BESTAETIGT({ fruehbeginn: true })),
|
||||
t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })),
|
||||
]
|
||||
for (const { template, values, blocks } of nachrichten) {
|
||||
const vorlage = loadTemplate(template)
|
||||
assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template)
|
||||
assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template)
|
||||
assert.deepEqual(renderMail(template, values, blocks).problems, [], template)
|
||||
}
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => {
|
||||
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
|
||||
assert.equal(sink.sent.length, 2)
|
||||
const [kundenmail, teamkopie] = sink.sent
|
||||
assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid'])
|
||||
assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink')
|
||||
assert.equal(kundenmail.subject, K31_BETREFF)
|
||||
assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`))
|
||||
assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']])
|
||||
assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF))
|
||||
assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF))
|
||||
assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails)
|
||||
assert.equal(teamkopie.subject, K31_BETREFF)
|
||||
assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.'))
|
||||
assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem')
|
||||
const geheim = JSON.stringify(teamkopie)
|
||||
for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`)
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => {
|
||||
const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF)
|
||||
await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true })
|
||||
assert.equal(sink.sent.length, 2)
|
||||
for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt'))
|
||||
assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF))
|
||||
assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN))
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => {
|
||||
await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/)
|
||||
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError)
|
||||
await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
let versuche = 0
|
||||
setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } })
|
||||
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/)
|
||||
assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie')
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => {
|
||||
let aufrufe = 0
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
aufrufe += 1
|
||||
if (aufrufe === 2) throw new Error('SMTP 451')
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
|
||||
assert.equal(aufrufe, 2)
|
||||
assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']])
|
||||
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001')))
|
||||
})
|
||||
|
||||
test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => {
|
||||
await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true }))
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF])
|
||||
const [k32, t22] = sink.sent
|
||||
assert.deepEqual(adressen(k32.to), ['k@example.invalid'])
|
||||
assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails)
|
||||
assert.deepEqual(adressen(t22.to), config.chatNotify.emails)
|
||||
assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr'))
|
||||
assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`))
|
||||
})
|
||||
|
||||
test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => {
|
||||
let aufrufe = 0
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
aufrufe += 1
|
||||
if (aufrufe === 1) throw new Error('SMTP 451')
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
await sendeBestaetigtMails(BESTAETIGT())
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
|
||||
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001')))
|
||||
sink.sent.length = 0
|
||||
setMailTransportForTests(sink)
|
||||
await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }))
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { hashToken, neuerToken } from '../server/services/auftraege/token.js'
|
||||
|
||||
const TAG_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
test('neuerToken: 32 Zufallsbytes als base64url (43 Zeichen), Hash = SHA-256 in Hex, gültig 60 Tage ab jetzt', () => {
|
||||
const jetzt = new Date('2026-10-06T10:00:00.000Z')
|
||||
const { token, hash, gueltigBis } = neuerToken(jetzt)
|
||||
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
|
||||
assert.equal(hash, createHash('sha256').update(token).digest('hex'))
|
||||
assert.ok(gueltigBis instanceof Date)
|
||||
assert.equal(gueltigBis.toISOString(), '2026-12-05T10:00:00.000Z')
|
||||
assert.equal(jetzt.toISOString(), '2026-10-06T10:00:00.000Z', 'das übergebene Datum bleibt unverändert')
|
||||
})
|
||||
|
||||
test('neuerToken: jedes Token ist neu; ohne Argument zählt die aktuelle Zeit; ein ungültiger Zeitpunkt wirft', () => {
|
||||
const vorher = Date.now()
|
||||
const a = neuerToken()
|
||||
const b = neuerToken()
|
||||
const nachher = Date.now()
|
||||
assert.notEqual(a.token, b.token)
|
||||
assert.notEqual(a.hash, b.hash)
|
||||
assert.ok(a.gueltigBis.getTime() >= vorher + 60 * TAG_MS && a.gueltigBis.getTime() <= nachher + 60 * TAG_MS)
|
||||
assert.throws(() => neuerToken(new Date('kein Datum')), TypeError)
|
||||
assert.throws(() => neuerToken('2026-10-06'), TypeError)
|
||||
})
|
||||
|
||||
test('hashToken: gleicher Hash wie neuerToken, null bei falschem Format', () => {
|
||||
const { token, hash } = neuerToken()
|
||||
assert.equal(hashToken(token), hash)
|
||||
const falsch = [undefined, null, 42, {}, [], '', 'kurz', `${token}x`, token.slice(1), ` ${token.slice(0, 42)}`, `${token.slice(0, 42)}+`, `${token.slice(0, 42)}=`, `../${token.slice(3)}`, `${token.slice(0, 42)}\n`]
|
||||
for (const wert of falsch) assert.equal(hashToken(wert), null, JSON.stringify(wert))
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { hashToken } from '../server/services/auftraege/token.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, SHA, TOKEN, TOKEN_NEU, UNGUELTIG, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Öffentliche Link-Routen /api/auftrag/:token (V5): ansehen, PDF, bestätigen; Ratenbegrenzung, Schalter, Ablauf, Status
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-token-'))
|
||||
process.env.AUFTRAEGE_ENABLED = 'on'
|
||||
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: tokenRoutes, resetAuftragRoutesForTests, setzeUhrFuerTests } = await import('../server/routes/auftrag.js')
|
||||
const app = await startTestApp({ '/api/auftrag': tokenRoutes })
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close()])
|
||||
})
|
||||
|
||||
const PDF = '%PDF-1.4 Test-AB'
|
||||
await speicherePdf('c1', 'ab1', Buffer.from(PDF))
|
||||
const JETZT = new Date('2026-10-10T09:00:00.000Z')
|
||||
const GRUND = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' }
|
||||
const ZU_VIELE = { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }
|
||||
|
||||
let sink
|
||||
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
|
||||
beforeEach(() => {
|
||||
resetAuftragRoutesForTests()
|
||||
setzeUhrFuerTests(() => JETZT)
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
seed()
|
||||
})
|
||||
const get = (token = TOKEN, rest = '', opts = {}) => send('GET', `/api/auftrag/${token}${rest}`, opts)
|
||||
const post = (body, token = TOKEN, opts = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { ...opts, body })
|
||||
const ip = (adresse) => ({ headers: { 'x-forwarded-for': adresse } })
|
||||
|
||||
test('GET /api/auftrag/:token: öffentliche Ansicht der AB ohne interne Felder, nie zwischengespeichert', async () => {
|
||||
const res = await get()
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(Object.keys(res.body), ['auftrag'])
|
||||
const a = res.body.auftrag
|
||||
assert.deepEqual([a.nummer, a.status, a.titel, a.summeCents, a.kundenart], ['AB-2026-0001', 'versendet', 'Website für Musterbäckerei', 80000, 'privat'])
|
||||
assert.deepEqual(a.positionen, [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000, summeCents: 80000 }])
|
||||
assert.match(a.agbUrl, /^\/rechtliches\/agb-.+\.pdf$/)
|
||||
for (const intern of ['id', 'kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'bestaetigtPdfSha256', 'gesendetAn', 'zeitquelle', 'erstelltVon']) assert.ok(!(intern in a), `${intern} darf nicht öffentlich sein`)
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(res.headers.get('x-robots-tag'), 'noindex')
|
||||
})
|
||||
|
||||
test('ungültige, unbekannte und abgelaufene Links: 404 mit immer derselben Meldung; am letzten Moment gilt der Link noch', async () => {
|
||||
for (const token of [TOKEN_NEU, 'kurz', `${TOKEN}x`, `${'T'.repeat(42)}%2B`, `${'T'.repeat(40)}%2e%2e%2f`]) {
|
||||
for (const res of [await get(token), await get(token, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }], token)
|
||||
}
|
||||
seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
|
||||
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
|
||||
seed({ tokenGueltigBis: JETZT.toISOString() })
|
||||
assert.equal((await get()).status, 200)
|
||||
})
|
||||
|
||||
test('Erinnerung mit neuem Link: der alte Link gilt nicht mehr (404), der neue schon', async () => {
|
||||
seed({ tokenHash: hashToken(TOKEN_NEU) })
|
||||
assert.deepEqual([(await get(TOKEN)).status, (await get(TOKEN_NEU)).status], [404, 200])
|
||||
})
|
||||
|
||||
test('Schalter: bei aus 404 wie bei unbekanntem Link; im Pilot nur für Adressen der Liste', async () => {
|
||||
config.auftraege.mode = 'off'
|
||||
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['andere@example.invalid']
|
||||
assert.equal((await get()).status, 404)
|
||||
config.auftraege.pilotEmails = ['k@example.invalid']
|
||||
assert.equal((await get()).status, 200)
|
||||
})
|
||||
|
||||
test('ersetzte, stornierte und widerrufene AB: 410 mit dem Grund – für Ansicht und PDF, auch bei abgelaufenem Link; Entwurf 404', async () => {
|
||||
for (const [status, meldung] of Object.entries(GRUND)) {
|
||||
seed({ status, tokenGueltigBis: '2026-01-01T00:00:00.000Z' })
|
||||
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [410, { error: meldung }], status)
|
||||
}
|
||||
seed({ status: 'entwurf' })
|
||||
assert.equal((await get()).status, 404)
|
||||
})
|
||||
|
||||
test('GET …/pdf liefert die gespeicherte Datei unverändert und inline; fehlt die Datei, 404', async () => {
|
||||
const res = await get(TOKEN, '/pdf')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.headers.get('content-type'), 'application/pdf')
|
||||
assert.equal(res.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(res.body, PDF)
|
||||
seed({ pdfDatei: 'c1/fehlt.pdf' })
|
||||
const fehlt = await get(TOKEN, '/pdf')
|
||||
assert.deepEqual([fehlt.status, fehlt.body], [404, { error: 'PDF nicht gefunden.' }])
|
||||
})
|
||||
|
||||
test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine andere IP ist nicht betroffen', async () => {
|
||||
for (let i = 1; i <= 120; i += 1) assert.equal((await get(TOKEN, '', ip('203.0.113.9'))).status, 200, `Anfrage ${i}`)
|
||||
const zuViele = await get(TOKEN, '', ip('203.0.113.9'))
|
||||
assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
|
||||
assert.equal((await get(TOKEN, '', ip('203.0.113.10'))).status, 200)
|
||||
})
|
||||
|
||||
const HAEKCHEN = { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }
|
||||
|
||||
test('POST …/bestaetigen ohne bestaetigt: true → 400, nichts geändert, keine Mail', async () => {
|
||||
for (const body of [{}, { bestaetigt: false }, { bestaetigt: 'true' }, { bestaetigt: 1 }, { fruehbeginn: true }]) {
|
||||
const res = await post(body)
|
||||
assert.deepEqual([res.status, res.body], [400, HAEKCHEN], JSON.stringify(body))
|
||||
}
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm], ['versendet', null])
|
||||
assert.equal(sink.sent.length, 0)
|
||||
// die Prüfung des Links geht dem Häkchen vor: bei einem unbekannten Link 404, nie 400
|
||||
const unbekannt = await post({}, TOKEN_NEU)
|
||||
assert.deepEqual([unbekannt.status, unbekannt.body], [404, { error: UNGUELTIG }])
|
||||
})
|
||||
|
||||
test('POST mit unbekanntem, falschem, altem oder abgelaufenem Link, bei ersetzter AB oder abgeschaltetem Schalter: 404 bzw. 410, nichts ändert sich', async () => {
|
||||
const pruefe = async (erwartet, token = TOKEN) => {
|
||||
const res = await post({ bestaetigt: true }, token)
|
||||
assert.deepEqual([res.status, res.body], erwartet)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
|
||||
}
|
||||
await pruefe([404, { error: UNGUELTIG }], TOKEN_NEU)
|
||||
await pruefe([404, { error: UNGUELTIG }], 'kurz')
|
||||
seed({ tokenHash: hashToken(TOKEN_NEU) }) // wie nach „Erinnerung senden“: nur der neue Link gilt
|
||||
await pruefe([404, { error: UNGUELTIG }], TOKEN)
|
||||
seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
|
||||
await pruefe([404, { error: UNGUELTIG }])
|
||||
seed({ status: 'ersetzt' })
|
||||
const ersetzt = await post({ bestaetigt: true })
|
||||
assert.deepEqual([ersetzt.status, ersetzt.body], [410, { error: GRUND.ersetzt }])
|
||||
assert.deepEqual([(await getAuftrag('ab1')).bestaetigtAm, sink.sent.length], [null, 0])
|
||||
seed()
|
||||
config.auftraege.mode = 'off'
|
||||
await pruefe([404, { error: UNGUELTIG }])
|
||||
})
|
||||
|
||||
test('POST bestätigt über den Link: Protokoll am Dokument, öffentliche Antwort, K32 und T22; keine IP-Adresse gespeichert', async () => {
|
||||
const res = await post({ bestaetigt: true, fruehbeginn: true }, TOKEN, ip('203.0.113.77'))
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual([res.body.auftrag.status, res.body.auftrag.fruehbeginn, new Date(res.body.auftrag.bestaetigtAm).getTime()], ['bestaetigt', true, JETZT.getTime()])
|
||||
assert.ok(!('tokenHash' in res.body.auftrag) && !('pdfSha256' in res.body.auftrag))
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.bestaetigtUeber, doc.bestaetigtPdfSha256, doc.fruehbeginn], ['link', SHA, true])
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'])
|
||||
assert.ok(!JSON.stringify([...aw.docs('auftraege').values()]).includes('203.0.113.77'), 'keine IP-Adresse im Dokument')
|
||||
})
|
||||
|
||||
test('fruehbeginn zählt nur bei Privatpersonen und nur als true', async () => {
|
||||
for (const [kundenart, eingabe, erwartet] of [['privat', 'ja', false], ['privat', 1, false], ['privat', true, true], ['unternehmen', true, false]]) {
|
||||
seed({ kunde: kunde(kundenart) })
|
||||
const res = await post({ bestaetigt: true, fruehbeginn: eingabe })
|
||||
assert.equal(res.status, 200, `${kundenart} ${eingabe}`)
|
||||
assert.equal((await getAuftrag('ab1')).fruehbeginn, erwartet, `${kundenart} ${eingabe}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('zweite und gleichzeitige Bestätigungen: 200, die erste zählt, genau zwei Mails', async () => {
|
||||
const erste = await post({ bestaetigt: true, fruehbeginn: true })
|
||||
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3_600_000))
|
||||
const zweite = await post({ bestaetigt: true, fruehbeginn: false })
|
||||
assert.equal(zweite.status, 200)
|
||||
assert.deepEqual(zweite.body, erste.body)
|
||||
assert.equal(sink.sent.length, 2)
|
||||
seed()
|
||||
sink.sent.length = 0
|
||||
const [a, b] = await Promise.all([post({ bestaetigt: true }), post({ bestaetigt: true })])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(a.body.auftrag.bestaetigtAm, b.body.auftrag.bestaetigtAm)
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('abgerechnete AB ohne frühere Bestätigung: 200, der Status bleibt abgerechnet', async () => {
|
||||
seed({ status: 'abgerechnet' })
|
||||
const res = await post({ bestaetigt: true })
|
||||
assert.deepEqual([res.status, res.body.auftrag.status], [200, 'abgerechnet'])
|
||||
assert.notEqual((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
})
|
||||
|
||||
test('Mailfehler: die Bestätigung gilt trotzdem (200), der Fehler steht nur im Protokoll', async () => {
|
||||
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
|
||||
const log = mock.method(console, 'error', () => {})
|
||||
const res = await post({ bestaetigt: true })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
|
||||
assert.ok(log.mock.callCount() > 0)
|
||||
mock.restoreAll()
|
||||
})
|
||||
|
||||
test('Ratenbegrenzung: höchstens 10 POST je Stunde und IP, danach 429; GET und andere IP sind nicht betroffen', async () => {
|
||||
const kopf = ip('203.0.113.20')
|
||||
for (let i = 1; i <= 10; i += 1) assert.equal((await post({}, TOKEN, kopf)).status, 400, `Anfrage ${i}`)
|
||||
const zuViele = await post({ bestaetigt: true }, TOKEN, kopf)
|
||||
assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
assert.equal((await get(TOKEN, '', kopf)).status, 200)
|
||||
assert.equal((await post({ bestaetigt: true }, TOKEN, ip('203.0.113.21'))).status, 200)
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { config } from '../server/config.js'
|
||||
import { loadTemplate, renderMail } from '../server/services/mailTemplates.js'
|
||||
import { euroDe } from '../server/services/finance/format.js'
|
||||
|
||||
// K31, K32, T22 (Aufträge und Rechnungen, Strang L5) und der Block projekt in K04 und T09 (Task 11). Die allgemeinen
|
||||
// Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Team-Fuß) prüft test/mailTemplates.test.js.
|
||||
const eur = (cent) => euroDe(cent).replace(/\u{a0}/gu, ' ') // die Textfassung macht geschützte Leerzeichen zu normalen
|
||||
const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu
|
||||
const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u
|
||||
const alle = (id) => Object.fromEntries(loadTemplate(id).blocks.map((name) => [name, true]))
|
||||
const pruefeZeilen = (mail, erwartet) => {
|
||||
const zeilen = mail.text.split('\n')
|
||||
for (const zeile of erwartet) assert.ok(zeilen.includes(zeile), `Zeile fehlt: ${zeile}\n${mail.text}`)
|
||||
}
|
||||
|
||||
const K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: eur(80000), link: 'https://project.webklar.com/auftrag.html#t=TOKEN', gueltig_bis: '05.12.2026' }
|
||||
const K32 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '21.10.2026', fruehbeginn_text: 'Ich verlange ausdrücklich den Beginn vor Ablauf der Widerrufsfrist.' }
|
||||
const T22 = { kunde: 'Muster GmbH', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Bestätigungslink', woms_link: 'https://ticket.webklar.com/customers/c1?tab=invoices', fruehester_beginn: '21.10.2026' }
|
||||
|
||||
test('K31, K32, T22: Datei, Betreff, Platzhalter und Blöcke wie im Vertrag V6 (K31 mit dem Gegenblock kundenmail)', () => {
|
||||
const erwartet = {
|
||||
K31: { file: 'K31 Auftragsbestaetigung.html', subject: 'Ihre Auftragsbestätigung {{nummer}} – {{titel}}', placeholders: ['name', 'nummer', 'titel', 'datum', 'betrag', 'link', 'gueltig_bis'], blocks: ['erinnerung', 'privat', 'festpreis', 'aufwand', 'teamkopie', 'kundenmail'] },
|
||||
K32: { file: 'K32 Auftrag bestaetigt.html', subject: 'Ihre Bestätigung zu {{nummer}} ist eingegangen', placeholders: ['name', 'nummer', 'titel', 'zeitpunkt', 'fruehester_beginn', 'fruehbeginn_text'], blocks: ['fruehbeginn', 'ohne_fruehbeginn'] },
|
||||
T22: { file: 'T22 Auftrag bestaetigt.html', subject: '[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}', placeholders: ['kunde', 'nummer', 'titel', 'zeitpunkt', 'ueber', 'woms_link', 'fruehester_beginn'], blocks: ['privat_fruehbeginn', 'privat_ohne_fruehbeginn'] },
|
||||
}
|
||||
for (const [id, soll] of Object.entries(erwartet)) {
|
||||
const vorlage = loadTemplate(id)
|
||||
assert.equal(vorlage.file, soll.file, id)
|
||||
assert.equal(vorlage.subject, soll.subject, id)
|
||||
assert.deepEqual(vorlage.placeholders, soll.placeholders, id)
|
||||
assert.deepEqual(vorlage.blocks, soll.blocks, id)
|
||||
}
|
||||
})
|
||||
|
||||
test('K31 Festpreis an eine Privatperson: Angaben, Knopf mit Link im Fragment, Anhänge, Widerrufshinweis; keine Erinnerung, kein Teamhinweis', () => {
|
||||
const mail = renderMail('K31', K31, { festpreis: true, privat: true, kundenmail: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
assert.equal(mail.subject, 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei')
|
||||
pruefeZeilen(mail, [
|
||||
'Guten Tag Erika Muster,',
|
||||
'Auftrag: AB-2026-0001 vom 06.10.2026',
|
||||
'Projekt: Website für Musterbäckerei',
|
||||
'Gesamtpreis: 800,00 € (Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)',
|
||||
'Bestätigung möglich bis: 05.12.2026',
|
||||
'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN',
|
||||
'Viele Grüße',
|
||||
'Ihr WEBklar-Team',
|
||||
])
|
||||
assert.ok(mail.text.includes('Die Auftragsbestätigung (AB-2026-0001.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail.'))
|
||||
assert.ok(mail.text.includes('Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung;'))
|
||||
assert.ok(mail.text.includes(`Widerrufsformular auf unserer Website (${config.publicBaseUrl}/widerrufen.html)`))
|
||||
assert.doesNotMatch(mail.text, /Erinnerung|Vergütung:|Kopie für das Team/)
|
||||
})
|
||||
|
||||
test('K31 Aufwand an ein Unternehmen als Erinnerung: Stundensatz, Hinweis auf den früheren Link, kein Widerrufshinweis', () => {
|
||||
const mail = renderMail('K31', { ...K31, betrag: `${eur(10000)} pro Stunde` }, { aufwand: true, erinnerung: true, kundenmail: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
pruefeZeilen(mail, ['Vergütung: 100,00 € pro Stunde (nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)', 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN'])
|
||||
assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.'))
|
||||
assert.doesNotMatch(mail.text, /Gesamtpreis|Widerruf|Verbraucher/)
|
||||
})
|
||||
|
||||
test('K31 als Kopie für das Team: Hinweis statt Knopf, kein Link, keine Anhänge, kein Widerrufshinweis; die Erinnerung bleibt erkennbar', () => {
|
||||
const mail = renderMail('K31', { ...K31, link: '' }, { aufwand: true, erinnerung: true, teamkopie: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
pruefeZeilen(mail, ['Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.', 'Auftrag: AB-2026-0001 vom 06.10.2026', 'Bestätigung möglich bis: 05.12.2026'])
|
||||
assert.ok(mail.text.includes('Der Link in der früheren E-Mail gilt nicht mehr.'))
|
||||
assert.doesNotMatch(mail.text, /Auftrag bestätigen|auftrag\.html|hängen als PDF|Anhänge|Verbraucher/)
|
||||
assert.doesNotMatch(mail.html, /auftrag\.html|#t=|class="btn"/)
|
||||
})
|
||||
|
||||
test('K32: Eingangsbestätigung mit Datum und Uhrzeit; Frühbeginn ja (wörtliche Erklärung), nein und Unternehmen', () => {
|
||||
const ja = renderMail('K32', K32, { fruehbeginn: true })
|
||||
assert.deepEqual(ja.problems, [])
|
||||
assert.equal(ja.subject, 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen')
|
||||
pruefeZeilen(ja, ['Guten Tag Erika Muster,', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Ihre Erklärung zum früheren Beginn', K32.fruehbeginn_text, 'Viele Grüße', 'Ihr WEBklar-Team'])
|
||||
assert.ok(ja.text.includes('wir haben Ihre Bestätigung zu AB-2026-0001 am 07.10.2026, 10:30 Uhr erhalten.'))
|
||||
assert.doesNotMatch(ja.text, /21\.10\.2026|nicht verlangt|Widerrufsrecht bleibt/)
|
||||
const nein = renderMail('K32', K32, { ohne_fruehbeginn: true })
|
||||
assert.ok(nein.text.includes('Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
|
||||
assert.doesNotMatch(nein.text, /Erklärung zum früheren Beginn/)
|
||||
assert.doesNotMatch(renderMail('K32', K32, {}).text, /Beginn der Arbeiten|21\.10\.2026|Widerruf|Erklärung/)
|
||||
})
|
||||
|
||||
test('T22: Betreff mit Präfix, Angaben, Knopf ins Ticketsystem; Hinweise nur bei Privatpersonen', () => {
|
||||
const ja = renderMail('T22', T22, { privat_fruehbeginn: true })
|
||||
assert.deepEqual(ja.problems, [])
|
||||
assert.equal(ja.subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
|
||||
pruefeZeilen(ja, ['Kunde: Muster GmbH', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Weg: Bestätigungslink', 'Im Ticketsystem öffnen: https://ticket.webklar.com/customers/c1?tab=invoices'])
|
||||
assert.ok(ja.text.includes('Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).'))
|
||||
assert.doesNotMatch(ja.text, /keinen früheren Beginn|21\.10\.2026/)
|
||||
const nein = renderMail('T22', T22, { privat_ohne_fruehbeginn: true })
|
||||
assert.ok(nein.text.includes('Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am 21.10.2026, dem Tag nach Ablauf der Widerrufsfrist.'))
|
||||
assert.doesNotMatch(renderMail('T22', T22, {}).text, /Privatperson|Wertersatz|frühestens/)
|
||||
})
|
||||
|
||||
test('K31 und K32 stehen in der Sie-Form; Werte werden HTML-sicher eingesetzt', () => {
|
||||
for (const [id, werte] of [['K31', K31], ['K32', K32]]) {
|
||||
const mail = renderMail(id, werte, alle(id))
|
||||
assert.doesNotMatch(mail.text, DU_FORM, `${id}: Du-Form\n${mail.text}`)
|
||||
assert.match(mail.text, SIE_FORM, `${id}: spricht den Kunden mit Sie an`)
|
||||
}
|
||||
const mail = renderMail('K31', { ...K31, titel: 'Café <b>&"' }, { festpreis: true })
|
||||
assert.match(mail.html, /Café <b>&"/)
|
||||
assert.doesNotMatch(mail.html, /<b>&/)
|
||||
})
|
||||
|
||||
const K04 = { name: 'Erika Muster', produkt: 'Projektauftrag', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', eingang_zeitpunkt: '07.10.2026, 10:30', email: 'k@example.invalid', kundennummer: 'KD-9' }
|
||||
const T09 = { email: 'k@example.invalid', name: 'Erika Muster', produkt: 'Projektauftrag', preis: '', kundennummer: 'KD-9', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', stripe_abo_id: '', abo_beginn: '–', fristende: '20.10.2026, 24 Uhr', rechtzeitig: 'RECHTZEITIG', ereignisse: 'Auftragsbestätigung auf „widerrufen“ gesetzt', pruefhinweis: '' }
|
||||
|
||||
test('K04 und T09: Block projekt ist deklariert (K04 mit projekt_unternehmen), die Platzhalter bleiben, die anderen Blöcke auch', () => {
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
|
||||
assert.deepEqual(loadTemplate('K04').placeholders, ['name', 'produkt', 'bestellung', 'eingang_zeitpunkt', 'email', 'kundennummer'])
|
||||
assert.deepEqual(loadTemplate('T09').blocks.slice(-2), ['bereits_widerrufen', 'projekt'])
|
||||
assert.deepEqual(loadTemplate('T09').placeholders, ['email', 'name', 'produkt', 'preis', 'kundennummer', 'bestellung', 'stripe_abo_id', 'abo_beginn', 'fristende', 'rechtzeitig', 'ereignisse', 'pruefhinweis'])
|
||||
})
|
||||
|
||||
test('K04 Block projekt: Vertragsart, „wir prüfen“; bei Unternehmen zusätzlich der Hinweis ohne gesetzliches Widerrufsrecht; keine Zusagen', () => {
|
||||
const privat = renderMail('K04', K04, { kundennummer: true, projekt: true })
|
||||
assert.deepEqual(privat.problems, [])
|
||||
assert.equal(privat.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
|
||||
pruefeZeilen(privat, [
|
||||
'wir haben Ihren Widerruf der Bestellung „Projektauftrag (Auftragsbestätigung AB-2026-0001)“ am 07.10.2026, 10:30 Uhr erhalten.',
|
||||
'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
|
||||
'Kundennummer: KD-9',
|
||||
'Wie es weitergeht',
|
||||
'Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.',
|
||||
])
|
||||
assert.doesNotMatch(privat.text, /Unternehmen|gesetzliches Widerrufsrecht|Abbuchungen|erstatt|Wertersatz|Widerrufsfrist läuft/)
|
||||
const firma = renderMail('K04', K04, { projekt: true, projekt_unternehmen: true })
|
||||
assert.deepEqual(firma.problems, [])
|
||||
pruefeZeilen(firma, ['Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'])
|
||||
// der Zusatz gehört zum Block projekt: allein erscheint er nie, und ohne projekt gibt es keinen Kasten
|
||||
assert.doesNotMatch(renderMail('K04', K04, { projekt_unternehmen: true }).text, /Unternehmen|Wie es weitergeht/)
|
||||
assert.doesNotMatch(renderMail('K04', K04, {}).text, /Wie es weitergeht|Projektauftrags/)
|
||||
})
|
||||
|
||||
test('T09 Block projekt: Ergebnis, Widerrufsfrist und Eingang als eigene Zeilen, Hinweis auf die Handarbeit; ohne den Block nichts davon', () => {
|
||||
const mail = renderMail('T09', T09, { kundennummer: true, projekt: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
assert.equal(mail.subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
|
||||
pruefeZeilen(mail, [
|
||||
'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
|
||||
'Abo: Projektauftrag',
|
||||
'Kundennummer: KD-9',
|
||||
'Zu tun',
|
||||
'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt',
|
||||
'Widerrufsfrist bis: 20.10.2026, 24 Uhr',
|
||||
'Eingang: RECHTZEITIG',
|
||||
])
|
||||
assert.ok(mail.text.includes('Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).'))
|
||||
assert.doesNotMatch(mail.text, /KEIN aktives Abo|Stripe-Abo|Abo in Stripe/)
|
||||
assert.doesNotMatch(renderMail('T09', T09, { kundennummer: true }).text, /Ergebnis:|Eingang:|Handarbeit|Zu tun/)
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt
|
||||
process.env.AUFTRAEGE_ENABLED = 'on'
|
||||
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, post, send } = await import('./support/testApp.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js')
|
||||
const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js')
|
||||
const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes })
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString()
|
||||
const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
|
||||
let protokoll
|
||||
beforeEach(() => {
|
||||
resetPublicRateLimit()
|
||||
resetAuftragRoutesForTests()
|
||||
sink.sent.length = 0
|
||||
config.auftraege.mode = 'on'
|
||||
protokoll = mock.method(console, 'error', () => {})
|
||||
})
|
||||
afterEach(() => mock.restoreAll())
|
||||
const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body })
|
||||
const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs'))
|
||||
const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen'))
|
||||
const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang))
|
||||
const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)'
|
||||
const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)'
|
||||
const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'
|
||||
const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen'
|
||||
const pruefeT09 = (ergebnis, frist, eingang) => {
|
||||
assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`)
|
||||
assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`)
|
||||
assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`)
|
||||
}
|
||||
|
||||
test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => {
|
||||
const gesendetAm = vor(3)
|
||||
seed({ gesendetAm })
|
||||
const stripeVorher = st.calls.length
|
||||
const res = await widerruf({ customerNumber: 'KD-9' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'])
|
||||
assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf')
|
||||
const ab = await getAuftrag('ab1')
|
||||
assert.equal(ab.status, 'widerrufen')
|
||||
assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime())
|
||||
// K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen
|
||||
assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
|
||||
assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text)
|
||||
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`)
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'))
|
||||
assert.ok(k04().text.includes('Kundennummer: KD-9'))
|
||||
assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/)
|
||||
// T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang
|
||||
assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
|
||||
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
|
||||
assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag')
|
||||
pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG')
|
||||
assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/)
|
||||
})
|
||||
|
||||
test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => {
|
||||
seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
const ab = await getAuftrag('ab1')
|
||||
assert.equal(ab.status, 'widerrufen')
|
||||
assert.notEqual(ab.bestaetigtAm, null)
|
||||
assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)')
|
||||
})
|
||||
|
||||
test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => {
|
||||
seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text)
|
||||
pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft')
|
||||
})
|
||||
|
||||
test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => {
|
||||
const gesendetAm = vor(20)
|
||||
seed({ gesendetAm })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen')
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
|
||||
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS))
|
||||
})
|
||||
|
||||
test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => {
|
||||
const faelle = [
|
||||
[{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
|
||||
[{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
|
||||
[{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
[{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
[{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
[{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
]
|
||||
seed({ gesendetAm: vor(3) })
|
||||
for (const [body, label, ergebnis] of faelle) {
|
||||
sink.sent.length = 0
|
||||
resetPublicRateLimit()
|
||||
const res = await widerruf(body)
|
||||
const was = JSON.stringify(body)
|
||||
assert.equal(res.status, 200, was)
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet', was)
|
||||
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was)
|
||||
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was)
|
||||
pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft')
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was)
|
||||
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was)
|
||||
}
|
||||
})
|
||||
|
||||
test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
|
||||
})
|
||||
|
||||
test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => {
|
||||
const faelle = [
|
||||
['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'],
|
||||
['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'],
|
||||
['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'],
|
||||
['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'],
|
||||
]
|
||||
for (const [status, ergebnis, eingang] of faelle) {
|
||||
const gesendetAm = vor(3)
|
||||
seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null })
|
||||
sink.sent.length = 0
|
||||
resetPublicRateLimit()
|
||||
assert.equal((await widerruf()).status, 200, status)
|
||||
pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang)
|
||||
assert.equal((await getAuftrag('ab1')).status, status, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
const lesen = await send('GET', `/api/auftrag/${TOKEN}`)
|
||||
assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }])
|
||||
assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410)
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
})
|
||||
|
||||
test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
config.auftraege.mode = 'off'
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404)
|
||||
})
|
||||
|
||||
test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft')
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
|
||||
assert.ok(protokoll.mock.callCount() > 0)
|
||||
})
|
||||
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge])
|
||||
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
|
||||
const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)]
|
||||
const EXPECTED = [...ids('G', 15), ...ids('K', 32), ...ids('T', 22)]
|
||||
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
|
||||
const GUEST = ids('G', 8)
|
||||
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
|
||||
@@ -27,7 +27,7 @@ for (const id of EXPECTED) {
|
||||
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
|
||||
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
|
||||
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage)
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Aufträge)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop] oder [Aufträge] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
|
||||
|
||||
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
|
||||
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
|
||||
@@ -446,7 +446,7 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
|
||||
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
|
||||
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
|
||||
assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
|
||||
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
|
||||
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
|
||||
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
|
||||
@@ -613,13 +613,13 @@ test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeiche
|
||||
})
|
||||
|
||||
// F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion
|
||||
test('F9: T09 deklariert preis, Blöcke von K04 und T09 unverändert', () => {
|
||||
test('F9: T09 deklariert preis, Blöcke von K04 und T09 wie festgelegt (dazu je der Block projekt aus Strang L5)', () => {
|
||||
assert.ok(loadTemplate('T09').placeholders.includes('preis'))
|
||||
assert.ok(loadTemplate('T09').placeholders.includes('produkt'))
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
|
||||
assert.deepEqual(loadTemplate('T09').blocks, [
|
||||
'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel',
|
||||
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen',
|
||||
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen', 'projekt',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -715,7 +715,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
|
||||
})
|
||||
|
||||
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
|
||||
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21']
|
||||
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21', 'T22']
|
||||
|
||||
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
|
||||
for (const id of EXPECTED) {
|
||||
|
||||
Reference in new issue
Block a user