diff --git a/test/auftraegeAblauf.test.js b/test/auftraegeAblauf.test.js new file mode 100644 index 0000000..9798679 --- /dev/null +++ b/test/auftraegeAblauf.test.js @@ -0,0 +1,1139 @@ +// Durchlauf über alle neuen Endpunkte von „Aufträge und Rechnungen“ (Strang Z, Task Z1.3). +// Echter Server-Code (Router, Dienste, pdfkit); Nachbauten für Appwrite, Stripe und SMTP, nichts verlässt den Rechner. +// Aufruf: cd && free -m && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblauf.test.js +// Jeder Test hat eigene Kunden und Projekte; die AB-Nummern laufen über alle Tests (D15 prüft sie am Ende). +// Der Vertrag entscheidet (Übersicht V1–V8, Spec). Was der Vertrag offen lässt, steht als „Annahme“ im Kommentar. +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { inspect } from 'node:util' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { mailSink } from './support/mailSink.js' +import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js' + +// --- Umgebung ------------------------------------------------------------------------------------------------ +// Ablage der AB-PDFs in einem Wegwerf-Ordner und Schalter an (Vorgabe wäre off): config.js liest beides beim ersten Import. +const ABLAGE = fs.mkdtempSync(path.join(os.tmpdir(), 'auftraege-ablauf-')) +process.env.AUFTRAEGE_UPLOAD_DIR = ABLAGE +process.env.AUFTRAEGE_ENABLED = 'on' + +// Alles, was dieser Prozess ins Protokoll schreibt, wird gesammelt: D16 prüft, dass kein Token und keine Client-Adresse darin steht. +const PROTOKOLL = [] +for (const stufe of ['log', 'info', 'warn', 'error']) { + const original = console[stufe].bind(console) + console[stufe] = (...teile) => { + PROTOKOLL.push(teile.map((teil) => (typeof teil === 'string' ? teil : inspect(teil, { depth: 5 }))).join(' ')) + original(...teile) + } +} + +const felder = (liste) => liste.map((attribut) => attribut.key) +const datumsfelder = (liste) => liste.filter((attribut) => attribut.type === 'datetime').map((attribut) => attribut.key) +const aw = await startAppwriteMock({ + schema: { + auftraege: felder(AUFTRAEGE_COLLECTIONS.auftraege.attributes), + projektRechnungen: felder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes), + financeSettings: [...new Set([...schemaKeys('financeSettings'), ...felder(AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings)])], + }, + datetimes: { + auftraege: datumsfelder(AUFTRAEGE_COLLECTIONS.auftraege.attributes), + projektRechnungen: datumsfelder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes), + }, + defaultLimit: 25, // wie Appwrite: ohne limit() kommen nur 25 Dokumente zurück (die Termine-Tests prüfen es ebenso) +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { startTestApp, send, sessionCookie } = await import('./support/testApp.js') +const { kundenansichtCookie } = await import('./support/fixtures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') +const { customerStripeInvoice } = await import('../server/services/customerInvoices.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { addDays, berlinDate, formatDe } = await import('../server/services/finance/dates.js') +const { withdrawalDeadline } = await import('../server/services/deadlines.js') +const { AGB_FASSUNG, SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js') +const { formatNummer } = await import('../server/services/auftraege/nummern.js') +const { hashToken } = await import('../server/services/auftraege/token.js') +const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') +const { default: adminProjektkontenRoutes } = await import('../server/routes/admin/projektkonten.js') +const { default: adminAuftraegeRoutes } = await import('../server/routes/admin/auftraege.js') +const { default: adminFinanzenRoutes } = await import('../server/routes/admin/finanzen.js') +const { default: publicAuftragRoutes } = await import('../server/routes/auftrag.js') +const { default: kundeAuftraegeRoutes } = await import('../server/routes/auftraegeKunde.js') +const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') + +// Eingebunden wie in server/index.js (V5 „Einbindung“): Wächter der Kundenansicht zuerst, spezifische Admin-Pfade vor dem Finanzen-Router +const app = await startTestApp({ + '/': kundenansichtGuard, + '/api/admin/finanzen/projektkonten': adminProjektkontenRoutes, + '/api/admin/finanzen/auftraege': adminAuftraegeRoutes, + '/api/admin/finanzen': adminFinanzenRoutes, + '/api/auftrag': publicAuftragRoutes, + '/api/auftraege': kundeAuftraegeRoutes, + '/api/abo': aboRoutes, + '/api/team': teamRoutes, +}) +const mails = mailSink() +setMailTransportForTests(mails) +after(async () => { + setMailTransportForTests(null) + await Promise.all([app.close(), aw.close(), st.close()]) + // nur der selbst angelegte Wegwerf-Ordner, nie ein Pfad von außen + if (ABLAGE.startsWith(`${os.tmpdir()}${path.sep}auftraege-ablauf-`)) fs.rmSync(ABLAGE, { recursive: true, force: true }) +}) +beforeEach(() => { + resetInvoiceCreateGuardForTests() // 30-Sekunden-Schutz gegen gleiche Rechnungen + invalidateFinanceCache() // Stripe-Zwischenspeicher (60 s) + resetPublicRateLimit() // Widerrufsseite: 10 Erklärungen je Stunde und Adresse +}) + +// --- Hilfen -------------------------------------------------------------------------------------------------- +const ADMIN = { Authorization: 'Bearer jwt-admin' } +const STAFF = { Authorization: 'Bearer jwt-staff' } // Mitarbeiter ohne Admin-Label +const KV = 'a'.repeat(32) // Kennung der Kundenansicht (Kopfzeile X-Kundenansicht) +const UNBEKANNT = 'Dieser Link ist ungültig oder abgelaufen.' +const HEUTE = berlinDate() +const JAHR = Number(HEUTE.slice(0, 4)) +const AB = () => config.collections.auftraege +const PR = () => config.collections.projektRechnungen +const ip = (n) => ({ 'x-forwarded-for': `203.0.113.${n}` }) // je Test eine eigene Client-Adresse (Ratenbegrenzung je Adresse) +const sha256 = (daten) => crypto.createHash('sha256').update(daten).digest('hex') +const pause = (ms) => new Promise((fertig) => setTimeout(fertig, ms)) +const kundeCookie = (id) => sessionCookie({ customerId: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example` }) +const admin = (methode, pfad, body, headers = {}) => send(methode, `/api/admin/finanzen${pfad}`, { body, headers: { ...ADMIN, ...headers } }) + +function erwarte(res, status, was, meldung) { + assert.equal(res.status, status, `${was}: erwartet ${status}, bekam ${res.status} ${JSON.stringify(res.body)}`) + if (meldung instanceof RegExp) assert.match(String(res.body?.error ?? ''), meldung, `${was}: Meldung`) + else if (typeof meldung === 'string') assert.equal(res.body?.error, meldung, `${was}: Meldung`) + return res.body +} +// Annahme (nicht im Vertrag): Erfolg heißt 200 oder 201 +function erfolg(res, was) { + assert.ok(res.status === 200 || res.status === 201, `${was}: erwartet 200 oder 201, bekam ${res.status} ${JSON.stringify(res.body)}`) + return res.body +} + +async function holePdf(pfad, { headers = {}, cookie } = {}) { + const res = await fetch(app.url + pfad, { headers: { ...(cookie ? { cookie } : {}), ...headers } }) + return { status: res.status, typ: res.headers.get('content-type') || '', bytes: Buffer.from(await res.arrayBuffer()) } +} +const istPdf = (bytes) => bytes.subarray(0, 5).toString('latin1') === '%PDF-' && bytes.length > 1500 && bytes.toString('latin1', Math.max(0, bytes.length - 64)).includes('%%EOF') +const seiten = (bytes) => (bytes.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length +const pdfDateien = () => (fs.existsSync(ABLAGE) ? fs.readdirSync(ABLAGE, { recursive: true }).filter((name) => String(name).endsWith('.pdf')) : []) + +const adressen = (feld) => (feld || []).map((eintrag) => eintrag.address) +const findeMail = (vorher, muster) => mails.sent.slice(vorher).find((mail) => muster.test(mail.subject)) +async function warteBis(pruefung, { ms = 5000, was = 'Bedingung' } = {}) { + const ende = Date.now() + ms + for (;;) { + const wert = await pruefung() + if (wert) return wert + if (Date.now() > ende) assert.fail(`Zeitüberschreitung beim Warten auf: ${was}`) + await pause(25) + } +} +const GESEHEN = new Set() // alle Token aus Mails und Fehlversuchen: D16 sucht sie in Dokumenten und im Protokoll +const TOKEN_IM_LINK = /\/auftrag\.html#t=([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/ // V6: K31 verlinkt auftrag.html#t= (Fragment: landet in keinem Server- oder Proxy-Protokoll) +function tokenAus(mail) { + const treffer = String(mail.html).match(TOKEN_IM_LINK) || String(mail.text).match(TOKEN_IM_LINK) + assert.ok(treffer, `kein Bestätigungslink in der Mail "${mail.subject}"`) + GESEHEN.add(treffer[1]) + return treffer[1] +} + +// V6: K31 geht OHNE BCC an den Kunden (die Mail trägt den Bestätigungslink); das Team bekommt eine eigene Kopie ohne Link (Block teamkopie), +// auch bei Erinnerungen. k31Mails trennt beide. +const kundenAdresse = (kundenId) => `${kundenId}@kunde.example` +function k31Mails(vorher, kundenId) { + const alle = mails.sent.slice(vorher).filter((mail) => /Auftragsbestätigung AB-\d{4}-\d{4}/.test(mail.subject)) + const anKunde = alle.filter((mail) => adressen(mail.to).includes(kundenAdresse(kundenId))) + return { anKunde, teamkopien: alle.filter((mail) => !anKunde.includes(mail)) } +} +function pruefeTeamkopie(kundenMail, token, teamkopien, was) { + assert.deepEqual(adressen(kundenMail.bcc), [], `${was}: K31 an den Kunden hat keine BCC-Kopie (V6)`) + assert.ok(teamkopien.length >= 1, `${was}: das Team bekommt eine Kopie der K31`) + const empfaenger = teamkopien.flatMap((mail) => adressen(mail.to)) + for (const adresse of config.chatNotify.emails) assert.ok(empfaenger.includes(adresse), `${was}: Teamkopie an ${adresse}`) + for (const kopie of teamkopien) { + const inhalt = `${kopie.html}\n${kopie.text}` + assert.equal(inhalt.includes(token), false, `${was}: die Teamkopie enthält den Token`) + assert.equal(/auftrag\.html[?#]/.test(inhalt), false, `${was}: die Teamkopie enthält einen Bestätigungslink`) + assert.match(kopie.text, /Kopie für das Team/, `${was}: Hinweis „Kopie für das Team“ statt des Knopfes`) + } +} + +const stripeAufrufe = (ab, methode, pfad) => st.calls.slice(ab).filter((aufruf) => aufruf.method === methode && (pfad instanceof RegExp ? pfad.test(aufruf.path) : aufruf.path === pfad)) +const zeilen = (rechnungId) => st.invoices.get(rechnungId).lines.data +function bezahlen(rechnungId) { + const roh = st.invoices.get(rechnungId) + assert.ok(roh, `Rechnung ${rechnungId} fehlt im Stripe-Nachbau`) + st.invoices.set(rechnungId, { + ...roh, status: 'paid', paid: true, amount_paid: roh.total, amount_remaining: 0, + status_transitions: { ...roh.status_transitions, paid_at: Math.floor(Date.now() / 1000) }, + }) + invalidateFinanceCache() +} +async function rechnung(body, was) { + return erfolg(await admin('POST', '/rechnungen', { daysUntilDue: 14, senden: false, ...body }), was).rechnung +} +async function konto(kundenId, projektId) { + const body = erwarte(await admin('GET', `/projektkonten?kunde=${kundenId}`), 200, `Projektkonten von ${kundenId}`) + const eintrag = body.konten.find((k) => k.projektId === projektId) + assert.ok(eintrag, `Projekt ${projektId} fehlt in den Projektkonten von ${kundenId}`) + return { body, konto: eintrag } +} + +const AB_BASIS = { + titel: 'Neue Website', art: 'festpreis', projektId: '', + positionen: [{ beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 }], + stundensatzCents: null, schaetzungMinuten: null, + zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14, + auftragVom: addDays(HEUTE, -1), auftragWeg: 'telefon', startAm: addDays(HEUTE, 7), fertigBis: addDays(HEUTE, 40), zeitquelle: [], +} +async function entwurf(kundenId, ueberschreibe = {}) { + const auftrag = erfolg(await admin('POST', '/auftraege', { ...AB_BASIS, kundenId, ...ueberschreibe }), `AB-Entwurf für ${kundenId}`).auftrag + assert.equal(auftrag.status, 'entwurf') + assert.equal(auftrag.nummer, '', 'Entwürfe haben keine Nummer') + return auftrag +} +const lies = async (id) => erwarte(await admin('GET', `/auftraege/${id}`), 200, `AB ${id} lesen`).auftrag +async function sende(id, was = 'AB senden') { + const vorher = mails.sent.length + const body = erwarte(await admin('POST', `/auftraege/${id}/senden`), 200, was) + assert.equal(body.mailFehler, false, `${was}: Mail nicht zugestellt`) + const { anKunde, teamkopien } = k31Mails(vorher, body.auftrag.kundenId) + assert.equal(anKunde.length, 1, `${was}: genau eine K31 an den Kunden (${mails.sent.slice(vorher).map((m) => `${m.subject} an ${adressen(m.to)}`).join(' | ')})`) + const token = tokenAus(anKunde[0]) + pruefeTeamkopie(anKunde[0], token, teamkopien, was) + return { auftrag: body.auftrag, mail: anKunde[0], token } +} +const ansicht = (token, n) => send('GET', `/api/auftrag/${token}`, { headers: ip(n) }) +const bestaetigen = (token, n, body = { bestaetigt: true, fruehbeginn: false }, extra = {}) => + send('POST', `/api/auftrag/${token}/bestaetigen`, { body, headers: { ...ip(n), ...(extra.headers || {}) }, cookie: extra.cookie }) +const vergebeneNummern = () => [...aw.docs(AB()).values()].filter((d) => d.nummer).map((d) => d.nummer).sort() + +// --- Testdaten ----------------------------------------------------------------------------------------------- +const ADRESSE = { street: 'Hauptstraße 1', postalCode: '10115', city: 'Berlin', country: 'Deutschland' } +const kundendoc = (id, ueberschreibe = {}) => ({ + $id: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example`, code: `K-${id}`, + customerStatus: 'active', status: 'active', portalAccessEnabled: true, kundenart: 'unternehmen', ...ADRESSE, ...ueberschreibe, +}) +const KUNDEN = { + kfirma: kundendoc('kfirma', { companyName: 'Beispiel GmbH', name: 'Anna Beispiel' }), + kprivat: kundendoc('kprivat', { kundenart: 'privat', name: 'Petra Privat' }), + kfremd: kundendoc('kfremd', { companyName: 'Fremd AG', name: 'Frank Fremd' }), + kluecke: kundendoc('kluecke', { kundenart: '', name: 'Lena Lücke' }), +} +for (const doc of Object.values(KUNDEN)) aw.seed('customers', doc) +const setzeKunde = (id, patch) => aw.seed('customers', { ...KUNDEN[id], ...patch }) + +function legeProjektAn(id, kundenId, ticketId, woid, name) { + aw.seed('workorders', { $id: ticketId, woid, customerId: kundenId, topic: name, status: 'Open', type: 'Webpage' }) + aw.seed('websiteProjects', { $id: id, customerId: kundenId, ticketId, projectName: name, projectType: 'website', status: 'active' }) +} +legeProjektAn('pfest', 'kfirma', 'tkfest', '100201', 'Website Beispiel') +legeProjektAn('paufw', 'kfirma', 'tkaufw', '100202', 'Online-Shop Beispiel') +legeProjektAn('pab', 'kfirma', 'tkab', '100203', 'Relaunch Größe & Maße') +legeProjektAn('pstorno', 'kfirma', 'tkstorno', '100204', 'Landingpage Beispiel') +legeProjektAn('pzuordnen', 'kfirma', 'tkzuordnen', '100205', 'Projekt zum Zuordnen') +legeProjektAn('pfremd', 'kfremd', 'tkfremd', '100299', 'Fremdes Projekt') +const blatt = (id, ticketId, ueberschreibe) => ({ + $id: id, workorderId: ticketId, woid: ticketId === 'tkaufw' ? '100202' : '100299', serviceType: 'Remote', employeeName: 'Justin Klein', + totalTime: 60, startDate: '06.10.2026', endDate: '06.10.2026', details: 'Arbeit', isComment: false, countsToPlan: false, ...ueberschreibe, +}) +const ANDREJ = [{ employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' }] +// Zeit des Projekts „Online-Shop“: 06.10. = 60 + 75 min, 07.10. = 50 → 60 min, 08.10. = 40 + 30 Unterstützung → 75 min; zusammen 270 min = 4,5 Std. +aw.seed('worksheets', blatt('wsA', 'tkaufw', { totalTime: 60, details: 'Startseite' })) +aw.seed('worksheets', blatt('wsB', 'tkaufw', { totalTime: 75, details: 'Kontaktformular' })) +aw.seed('worksheets', blatt('wsC', 'tkaufw', { totalTime: 50, startDate: '07.10.2026', endDate: '07.10.2026', details: 'Impressum und Datenschutz' })) +aw.seed('worksheets', blatt('wsD', 'tkaufw', { totalTime: 40, startDate: '08.10.2026', endDate: '08.10.2026', details: 'Farbkonzept', supporters: JSON.stringify(ANDREJ) })) +// nie abrechenbar: Abo-Zeit, Kommentar, 0 Minuten; dazu ein Blatt eines anderen Kunden +aw.seed('worksheets', blatt('wsAbo', 'tkaufw', { totalTime: 90, countsToPlan: true, details: 'Wartung im Abo' })) +aw.seed('worksheets', blatt('wsKomm', 'tkaufw', { totalTime: 30, isComment: true, details: 'Interner Hinweis' })) +aw.seed('worksheets', blatt('wsNull', 'tkaufw', { totalTime: 0, details: 'Telefonat ohne Zeit' })) +aw.seed('worksheets', blatt('wsFremd', 'tkfremd', { totalTime: 60, details: 'Arbeit beim anderen Kunden' })) + +// --- D0: Einbindung, Seiten und Zugriffsschutz --------------------------------------------------------------- +test('D0 Einbindung: server/index.js hängt die neuen Router ein (Admin-Pfade vor dem Finanzen-Router), das Team-Gerüst hängt hinter requireStaffJwt', () => { + const index = fs.readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + const stelle = (pfad, was) => { + const treffer = new RegExp(`app\\.use\\(\\s*['"]${pfad}['"]\\s*,\\s*\\w+\\s*\\)`).exec(index) + assert.ok(treffer, `server/index.js: ${was} (${pfad}) fehlt`) + return treffer.index + } + const finanzen = stelle('/api/admin/finanzen', 'Finanzen-Router') + assert.ok(stelle('/api/admin/finanzen/projektkonten', 'Projektkonten') < finanzen, 'Projektkonten vor dem Finanzen-Router') + assert.ok(stelle('/api/admin/finanzen/auftraege', 'Aufträge (Admin)') < finanzen, 'Aufträge vor dem Finanzen-Router') + stelle('/api/auftrag', 'öffentliche Token-Routen') + stelle('/api/auftraege', 'Kundenrouten') + const team = fs.readFileSync(new URL('../server/routes/team/index.js', import.meta.url), 'utf8') + const jwt = team.indexOf('router.use(requireStaffJwt)') + assert.ok(jwt >= 0 && team.indexOf("router.use('/arbeitsblaetter'") > jwt, "Team: router.use('/arbeitsblaetter', …) steht hinter requireStaffJwt") +}) + +test('D0 Seiten: die Bestätigungsseite ist noindex, die Widerrufsseite kennt den Projektauftrag', () => { + const auftrag = fs.readFileSync(new URL('../public/auftrag.html', import.meta.url), 'utf8') + assert.match(auftrag, /]*(?:name=["']robots["'][^>]*noindex|noindex[^>]*name=["']robots["'])/i, 'auftrag.html trägt noindex') + const widerruf = fs.readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8') + assert.match(widerruf, /value=["']projekt["']/, 'Vertragsart mit order=projekt') + assert.match(widerruf, /Projektauftrag/, 'Beschriftung „Projektauftrag (Auftragsbestätigung)“') +}) + +test('D0 Zugriff: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403, Kunden-Cookie ersetzt kein JWT, unbekannte Token einheitlich 404', async () => { + const adminRouten = [ + ['GET', '/projektkonten?kunde=kfirma'], ['GET', '/projektkonten/pfest'], ['GET', '/auftraege?kunde=kfirma'], ['POST', '/auftraege'], + ['GET', '/auftraege/x'], ['PATCH', '/auftraege/x'], ['DELETE', '/auftraege/x'], ['GET', '/auftraege/x/pdf'], ['POST', '/auftraege/x/senden'], + ['POST', '/auftraege/x/erinnern'], ['POST', '/auftraege/x/neue-fassung'], ['POST', '/auftraege/x/stornieren'], ['POST', '/auftraege/x/projekt'], ['POST', '/rechnungen/vorschau'], + ] + for (const [methode, pfad] of adminRouten) { + const body = methode === 'GET' || methode === 'DELETE' ? undefined : {} + const was = `${methode} ${pfad}` + erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body }), 401, `${was} ohne JWT`) + erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, cookie: kundeCookie('kfirma') }), 401, `${was} mit Kunden-Cookie`) + erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, headers: STAFF }), 403, `${was} als Mitarbeiter ohne Admin-Label`) + } + const abgerechnet = '/api/team/arbeitsblaetter/abgerechnet?ticketId=tkaufw' + erwarte(await send('GET', abgerechnet), 401, 'Team-Route ohne JWT') + erwarte(await send('GET', abgerechnet, { cookie: kundeCookie('kfirma') }), 401, 'Team-Route mit Kunden-Cookie') + erwarte(await send('GET', abgerechnet, { headers: { Authorization: 'Bearer jwt-kunde' } }), 403, 'Team-Route mit Kundenkonto ohne Label') + assert.deepEqual(Object.keys(erwarte(await send('GET', abgerechnet, { headers: STAFF }), 200, 'Team-Route als Mitarbeiter')), ['abgerechnet']) + for (const [methode, pfad] of [['GET', '/api/auftraege'], ['GET', '/api/auftraege/x/pdf'], ['POST', '/api/auftraege/x/bestaetigen']]) { + erwarte(await send(methode, pfad, { body: methode === 'POST' ? { bestaetigt: true } : undefined }), 401, `${methode} ${pfad} ohne Sitzung`) + } + for (const token of ['x', 'kurz', 'A'.repeat(43), 'A'.repeat(44), `${'A'.repeat(42)}%2F`]) { + erwarte(await ansicht(token, 1), 404, `Ansicht mit Token ${token}`, UNBEKANNT) + erwarte(await send('GET', `/api/auftrag/${token}/pdf`, { headers: ip(1) }), 404, `PDF mit Token ${token}`, UNBEKANNT) + erwarte(await bestaetigen(token, 2, { bestaetigt: true }), 404, `Bestätigen mit Token ${token}`, UNBEKANNT) + } +}) + +// --- D1: Einstellungen --------------------------------------------------------------------------------------- +test('D1 Einstellungen: die Steuernummer wird geprüft und gespeichert (sie steht später auf dem AB-PDF)', async () => { + assert.equal(erfolg(await admin('GET', '/einstellungen'), 'Einstellungen lesen').einstellungen.steuernummer, '') + // V4: erlaubt sind Buchstaben, Ziffern, Leerzeichen, / - und . (auch eine USt-IdNr. „DE…“), höchstens 40 Zeichen + for (const falsch of ['12/345/67890