From 7d3114d7a3c309a60833d392fa908a493b842b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:16:02 +0000 Subject: [PATCH] =?UTF-8?q?AB-Kern:=20Admin-Routen=20zum=20Lesen,=20Anlege?= =?UTF-8?q?n,=20=C3=84ndern,=20L=C3=B6schen=20und=20f=C3=BCr=20das=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/auftraege.js | 90 +++++++++++++++++++++- test/auftraegeRoutesAb.test.js | 127 +++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 test/auftraegeRoutesAb.test.js diff --git a/server/routes/admin/auftraege.js b/server/routes/admin/auftraege.js index 6ba6e83..52dadb6 100644 --- a/server/routes/admin/auftraege.js +++ b/server/routes/admin/auftraege.js @@ -1,11 +1,97 @@ /** - * Gerüst von Strang L0: Auftragsbestätigungen (Admin). Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router). - * L3 füllt: Liste, Anlegen, Ändern, Löschen, PDF, Projekt zuordnen, Senden, Erinnern, Neue Fassung, Stornieren. + * Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“). + * GET / (?kunde=), POST /, GET|PATCH|DELETE /:id, GET /:id/pdf, POST /:id/projekt, /:id/senden, /:id/erinnern, + * /:id/neue-fassung, /:id/stornieren. Nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über + * sendeFehler. Ist der Schalter für den Kunden aus, antworten alle Routen 404 mit SCHALTER_AUS_MELDUNG. */ import { Router } from 'express' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { auftraegeAktiv } from '../../services/auftraege/schalter.js' +import { SCHALTER_AUS_MELDUNG } from '../../services/auftraege/konstanten.js' +import { AuftragError, sendeFehler } from '../../services/auftraege/fehler.js' +import { auftragFuerTeam } from '../../services/auftraege/auftragSicht.js' +import { listAuftraege } from '../../services/auftraege/auftragStore.js' +import { + aendereEntwurf, erinnereAuftrag, ladeAuftrag, ladeGespeichertesPdf, ladeKunde, legeEntwurfAn, loescheEntwurf, neueFassung, + ordneProjektZu, sendeAuftrag, storniereAuftrag, vorschauPdf, +} from '../../services/auftraege/auftraege.js' const router = Router() router.use(requireAppwriteStaff) +async function ladeKundeMitSchalter(kundenId) { + const kunde = await ladeKunde(kundenId) + if (!auftraegeAktiv(kunde)) throw new AuftragError(404, SCHALTER_AUS_MELDUNG) + return kunde +} + +async function ladeMitSchalter(id) { + const auftrag = await ladeAuftrag(id) + await ladeKundeMitSchalter(auftrag.kundenId) + return auftrag +} + +const vonAdmin = (req) => req.appwriteUser?.email + +router.get('/', async (req, res) => { + try { + const kundenId = req.query.kunde + if (typeof kundenId !== 'string' || kundenId === '') return res.status(400).json({ error: 'Kunde fehlt.' }) + await ladeKundeMitSchalter(kundenId) + return res.json({ auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) }) + } catch (err) { + return sendeFehler(res, 'Auftragsbestätigungen laden', err) + } +}) + +router.post('/', async (req, res) => { + try { + const kundenId = req.body?.kundenId + if (typeof kundenId === 'string' && kundenId !== '') await ladeKundeMitSchalter(kundenId) + return res.json({ auftrag: auftragFuerTeam(await legeEntwurfAn(req.body, { von: vonAdmin(req) })) }) + } catch (err) { + return sendeFehler(res, 'Auftragsbestätigung anlegen', err) + } +}) + +router.get('/:id', async (req, res) => { + try { + return res.json({ auftrag: auftragFuerTeam(await ladeMitSchalter(req.params.id)) }) + } catch (err) { + return sendeFehler(res, 'Auftragsbestätigung laden', err) + } +}) + +router.patch('/:id', async (req, res) => { + try { + await ladeMitSchalter(req.params.id) + return res.json({ auftrag: auftragFuerTeam(await aendereEntwurf(req.params.id, req.body ?? {})) }) + } catch (err) { + return sendeFehler(res, 'Auftragsbestätigung ändern', err) + } +}) + +router.delete('/:id', async (req, res) => { + try { + await ladeMitSchalter(req.params.id) + await loescheEntwurf(req.params.id) + return res.json({ ok: true }) + } catch (err) { + return sendeFehler(res, 'Auftragsbestätigung löschen', err) + } +}) + +router.get('/:id/pdf', async (req, res) => { + try { + const auftrag = await ladeMitSchalter(req.params.id) + const entwurf = auftrag.status === 'entwurf' + const buffer = entwurf ? await vorschauPdf(auftrag.id) : await ladeGespeichertesPdf(auftrag) + const name = entwurf ? 'Auftragsbestaetigung-Entwurf.pdf' : /^AB-\d{4}-\d{4}$/.test(auftrag.nummer) ? `${auftrag.nummer}.pdf` : 'Auftragsbestaetigung.pdf' + res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${name}"`, 'Cache-Control': 'no-store' }) + return res.send(buffer) + } catch (err) { + return sendeFehler(res, 'Auftragsbestätigung-PDF laden', err) + } +}) + export default router diff --git a/test/auftraegeRoutesAb.test.js b/test/auftraegeRoutesAb.test.js new file mode 100644 index 0000000..3fbcf5c --- /dev/null +++ b/test/auftraegeRoutesAb.test.js @@ -0,0 +1,127 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, FREMD, VON, MELDUNG, festpreisEingabe, aufwandEingabe, kundeDoc, baueDeps } from './helpers/auftraegeAb.js' + +const umg = await starteUmgebung() +const { aw, config } = umg +const { startTestApp, send } = await import('./support/testApp.js') +const { default: router } = await import('../server/routes/admin/auftraege.js') +const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js') +const { legeEntwurfAn, sendeAuftrag, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js') +const app = await startTestApp({ '/api/admin/finanzen/auftraege': router }) +after(async () => { + await app.close() + await umg.schliesse() +}) + +let kontext // Attrappen für PDF, Token und Mail des aktuellen Tests +beforeEach(async () => { + await umg.zuruecksetzen() + kontext = baueDeps() + setzeDepsFuerTests(kontext.deps) +}) +const BASIS = '/api/admin/finanzen/auftraege' +const api = (methode, pfad, body, jwt = 'jwt-admin') => send(methode, BASIS + pfad, { headers: { Authorization: `Bearer ${jwt}` }, body }) +const entwurfAnlegen = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON }) + +const ZUGRIFF = [ + ['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/'], ['GET', '/a_1'], ['PATCH', '/a_1'], ['DELETE', '/a_1'], ['GET', '/a_1/pdf'], + ['POST', '/a_1/senden'], ['POST', '/a_1/erinnern'], ['POST', '/a_1/neue-fassung'], ['POST', '/a_1/stornieren'], ['POST', '/a_1/projekt'], +] + +test('Zugriff: ohne JWT 401, ohne Admin-Label 403 – bei jeder Route', async () => { + for (const [methode, pfad] of ZUGRIFF) { + const body = ['GET', 'DELETE'].includes(methode) ? undefined : {} + assert.equal((await send(methode, BASIS + pfad, { body })).status, 401, `${methode} ${pfad} ohne JWT`) + for (const jwt of ['jwt-staff', 'jwt-kunde']) assert.equal((await api(methode, pfad, body, jwt)).status, 403, `${methode} ${pfad} mit ${jwt}`) + } +}) + +test('Schalter: ist die Funktion für den Kunden aus, antworten Liste, Anlegen, Lesen, Ändern, Löschen und PDF mit 404 und der festen Meldung', async () => { + const a = await entwurfAnlegen() // angelegt, solange der Schalter an ist + config.auftraege.mode = 'off' + const routen = [['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/', festpreisEingabe()], ['GET', `/${a.id}`], ['PATCH', `/${a.id}`, { titel: 'x' }], ['DELETE', `/${a.id}`], ['GET', `/${a.id}/pdf`]] + for (const [methode, pfad, body] of routen) { + const r = await api(methode, pfad, body) + assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], `${methode} ${pfad}`) + } + assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel') // nichts wurde geändert oder gelöscht + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['anna@example.invalid'] // E-Mail des Kunden + assert.equal((await api('GET', `/${a.id}`)).status, 200) + config.auftraege.pilotEmails = ['jemand@example.invalid'] + assert.equal((await api('GET', `/${a.id}`)).status, 404) +}) + +test('GET Liste: Aufträge des Kunden, neueste zuerst, ohne interne Felder; 400 ohne Kunde, 404 bei unbekanntem Kunden', async () => { + const a = await entwurfAnlegen() + const b = await legeEntwurfAn(aufwandEingabe(), { von: VON }) + const r = await api('GET', `/?kunde=${KUNDE_ID}`) + assert.equal(r.status, 200) + assert.deepEqual(r.body.auftraege.map((x) => x.id), [b.id, a.id]) + for (const x of r.body.auftraege) { + for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in x, false, feld) + assert.equal(typeof x.pdfVorhanden, 'boolean') + } + const ohneKunde = await api('GET', '/') + assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde fehlt.' }]) + assert.equal((await api('GET', '/?kunde=c_gibtsnicht')).status, 404) + assert.equal((await api('GET', '/?kunde=../x')).status, 404) +}) + +test('POST: legt einen Entwurf an (200, AuftragTeam mit Texten); Fehler als { error } mit festen Meldungen', async () => { + const r = await api('POST', '/', festpreisEingabe()) + assert.equal(r.status, 200) + const a = r.body.auftrag + assert.deepEqual([a.status, a.titel, a.summeCents, a.erstelltVon], ['entwurf', 'Website Beispiel', 80000, 'kenso@webklar.com']) + assert.deepEqual([typeof a.zahlungsplanText, a.pdfVorhanden, 'tokenHash' in a], ['string', false, false]) + const ohnePositionen = await api('POST', '/', festpreisEingabe({ positionen: [] })) + assert.deepEqual([ohnePositionen.status, ohnePositionen.body], [400, { error: MELDUNG.positionen }]) + const ohneKunde = await api('POST', '/', festpreisEingabe({ kundenId: undefined })) + assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde: Bitte einen Kunden angeben.' }]) + assert.equal((await api('POST', '/', festpreisEingabe({ kundenId: 'c_gibtsnicht' }))).status, 404) +}) + +test('GET, PATCH, DELETE: Entwurf lesen, ändern, löschen; gesendete AB ist unveränderlich (409); ungültige Kennungen 404', async () => { + const a = await entwurfAnlegen() + const gelesen = await api('GET', `/${a.id}`) + assert.deepEqual([gelesen.status, gelesen.body.auftrag.id], [200, a.id]) + const geaendert = await api('PATCH', `/${a.id}`, { titel: 'Neu' }) + assert.deepEqual([geaendert.status, geaendert.body.auftrag.titel], [200, 'Neu']) + const falsch = await api('PATCH', `/${a.id}`, { zahlungszielTage: 99 }) + assert.deepEqual([falsch.status, falsch.body], [400, { error: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.' }]) + const geloescht = await api('DELETE', `/${a.id}`) + assert.deepEqual([geloescht.status, geloescht.body], [200, { ok: true }]) + assert.deepEqual((await api('GET', `/${a.id}`)).body, { error: MELDUNG.auftragFehlt }) + for (const kennung of ['a%20b', 'x'.repeat(40), '..%2F..']) assert.equal((await api('GET', `/${kennung}`)).status, 404, kennung) + const b = await entwurfAnlegen() + await sendeAuftrag(b.id, { von: VON }) + const patch = await api('PATCH', `/${b.id}`, { titel: 'Nachträglich' }) + assert.deepEqual([patch.status, patch.body], [409, { error: MELDUNG.nurEntwurfAendern }]) + const loeschen = await api('DELETE', `/${b.id}`) + assert.deepEqual([loeschen.status, loeschen.body], [409, { error: MELDUNG.nurEntwurfLoeschen }]) +}) + +test('GET pdf: Entwurf als Vorschau, gesendete AB aus der gespeicherten Datei, mit Kopfzeilen', async () => { + const entwurf = await entwurfAnlegen() + const vorschau = await api('GET', `/${entwurf.id}/pdf`) + assert.equal(vorschau.status, 200) + assert.equal(vorschau.body, '%PDF-1.4 Entwurf') + assert.match(vorschau.headers.get('content-type'), /^application\/pdf/) + assert.equal(vorschau.headers.get('content-disposition'), 'inline; filename="Auftragsbestaetigung-Entwurf.pdf"') + assert.equal(vorschau.headers.get('cache-control'), 'no-store') + const { auftrag } = await sendeAuftrag(entwurf.id, { von: VON }) // Attrappen aus setzeDepsFuerTests + const gespeichert = await api('GET', `/${entwurf.id}/pdf`) + assert.equal(gespeichert.body, `%PDF-1.4 ${auftrag.nummer}`) + assert.equal(gespeichert.headers.get('content-disposition'), `inline; filename="${auftrag.nummer}.pdf"`) + assert.equal((await api('GET', '/a_gibtsnicht/pdf')).status, 404) +}) + +test('Unerwartete Fehler: 500 mit fester Meldung, nichts von Appwrite gelangt an die Seite', async () => { + mock.method(console, 'error', () => {}) + const a = await entwurfAnlegen() + aw.failNext({ collection: config.collections.auftraege, method: 'GET', status: 500, message: 'GEHEIM-Appwrite-Detail' }) + const r = await api('GET', `/${a.id}`) + assert.deepEqual([r.status, r.body], [500, { error: 'Interner Fehler.' }]) +})