From 846059b4555648a39e3bbb237394e4457dda3334 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:40 +0000 Subject: [PATCH] =?UTF-8?q?Projektkonto:=20Admin-Routen=20f=C3=BCr=20Konte?= =?UTF-8?q?n=20je=20Kunde=20und=20Detail=20je=20Projekt?= 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/projektkonten.js | 31 ++++- test/auftraegeRoutesProjektkonten.test.js | 133 ++++++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 test/auftraegeRoutesProjektkonten.test.js diff --git a/server/routes/admin/projektkonten.js b/server/routes/admin/projektkonten.js index 2c51ca8..ee7351d 100644 --- a/server/routes/admin/projektkonten.js +++ b/server/routes/admin/projektkonten.js @@ -1,11 +1,38 @@ /** - * Gerüst von Strang L0: Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router). - * L1 ergänzt GET / (?kunde=) und GET /:projektId. + * Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router). + * GET /?kunde= Projektkonten eines Kunden samt Zeile "Ohne Projekt" + * GET /:projektId Konto, ABs, Rechnungen und offene Arbeitsblaetter eines Projekts + * Nur lesen (Strang L1). */ import { Router } from 'express' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { sendeFehler } from '../../services/auftraege/fehler.js' +import { projektkontenFuerKunde, projektkontoDetail } from '../../services/auftraege/projektkonto.js' const router = Router() router.use(requireAppwriteStaff) +// Appwrite-UID: erst pruefen, dann an Appwrite geben +const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +router.get('/', async (req, res) => { + const kunde = req.query.kunde + if (typeof kunde !== 'string' || !KENNUNG.test(kunde)) return res.status(400).json({ error: 'Kunde fehlt oder ist ungültig.' }) + try { + res.json(await projektkontenFuerKunde(kunde)) + } catch (err) { + sendeFehler(res, 'Projektkonten laden', err) + } +}) + +router.get('/:projektId', async (req, res) => { + const { projektId } = req.params + if (!KENNUNG.test(projektId)) return res.status(404).json({ error: 'Projekt nicht gefunden.' }) + try { + res.json(await projektkontoDetail(projektId)) + } catch (err) { + sendeFehler(res, 'Projektkonto laden', err) + } +}) + export default router diff --git a/test/auftraegeRoutesProjektkonten.test.js b/test/auftraegeRoutesProjektkonten.test.js new file mode 100644 index 0000000..5807f52 --- /dev/null +++ b/test/auftraegeRoutesProjektkonten.test.js @@ -0,0 +1,133 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Routen des Projektkontos (Strang L1): Admin (requireAppwriteStaff) und Team (requireStaffJwt). Muster: financeRoutesBank.test.js +// (JWT-Köpfe des Appwrite-Nachbaus), shopTeam.test.js (Team-Eingang) und den Gerüsttest von L0 (auftraegeGeruest.test.js). +const aw = await startAppwriteMock({ + schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') }, + datetimes: AUFTRAEGE_DATETIMES, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { seedFixtures } = await import('./support/fixtures.js') +const { startTestApp, rawGet } = await import('./support/testApp.js') +const { default: projektkontenRoutes } = await import('../server/routes/admin/projektkonten.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js') + +const app = await startTestApp({ '/api/admin/finanzen/projektkonten': projektkontenRoutes, '/api/team': teamRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +seedFixtures(aw) + +const admin = { headers: { Authorization: 'Bearer jwt-admin' } } +const staff = { headers: { Authorization: 'Bearer jwt-staff' } } +const ohneLabel = { headers: { Authorization: 'Bearer jwt-kunde' } } +const BASIS = '/api/admin/finanzen/projektkonten' + +const COLLECTIONS = ['websiteProjects', 'workorders', 'worksheets', 'auftraege', 'projektRechnungen'] +async function reset() { + mock.restoreAll() + invalidateFinanceCache() + st.calls.length = 0 + st.invoices.clear() + config.auftraege.mode = 'on' + config.auftraege.pilotEmails = [] + for (const name of COLLECTIONS) { + for (const id of aw.docs(config.collections[name]).keys()) await deleteDocument(config.collections[name], id) + } +} +beforeEach(reset) + +const blatt = (id, minuten) => ({ + $id: id, wsid: `10000${id.slice(1)}`, woid: '100001', workorderId: 't1', employeeId: 'e1', employeeName: 'Kenso Grimm', employeeShort: 'KNSO', + serviceType: 'Remote', totalTime: minuten, supporters: '', startDate: '06.10.2026', endDate: '06.10.2026', details: 'Startseite', + isComment: false, countsToPlan: false, +}) +function seedBasis() { + aw.seed(config.collections.workorders, { $id: 't1', woid: '100001', customerId: 'c1', topic: 'Website', status: 'open' }) + aw.seed(config.collections.websiteProjects, { $id: 'p1', customerId: 'c1', projectName: 'Website Müller', ticketId: 't1', kind: 'website', archived: false }) + aw.seed(config.collections.worksheets, blatt('w1', 60)) + aw.seed(config.collections.worksheets, blatt('w2', 30)) +} + +test('Projektkonto-Routen: ohne JWT 401, ohne Admin-Label 403', async () => { + for (const path of [`${BASIS}?kunde=c1`, `${BASIS}/p1`]) { + assert.equal((await rawGet(path)).status, 401, `${path} ohne JWT`) + assert.equal((await rawGet(path, { headers: { Authorization: 'Bearer jwt-unbekannt' } })).status, 401, `${path} mit falschem JWT`) + assert.equal((await rawGet(path, staff)).status, 403, `${path} als Mitarbeiter ohne Admin-Label`) + assert.equal((await rawGet(path, ohneLabel)).status, 403, `${path} als Konto ohne Label`) + } +}) + +test('GET ?kunde=: ohne, leer, doppelt oder mit unmöglicher Kennung -> 400', async () => { + for (const query of ['', '?kunde=', '?kunde=c1&kunde=c2', '?kunde=../x', `?kunde=${'c'.repeat(37)}`, '?kunde[a]=b']) { + const res = await rawGet(`${BASIS}${query}`, admin) + assert.equal(res.status, 400, query) + assert.deepEqual(res.body, { error: 'Kunde fehlt oder ist ungültig.' }, query) + } +}) + +test('GET ?kunde= -> 404', async () => { + const res = await rawGet(`${BASIS}?kunde=gibtsnicht`, admin) + assert.equal(res.status, 404) + assert.deepEqual(res.body, { error: 'Kunde nicht gefunden.' }) +}) + +test('GET ?kunde=c1: Projektkonten mit Zeit, Antwort mit genau den vereinbarten Schlüsseln', async () => { + seedBasis() + const res = await rawGet(`${BASIS}?kunde=c1`, admin) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body).sort(), ['auftraegeAktiv', 'konten', 'kunde', 'ohneProjekt', 'stripeOk']) + assert.deepEqual(res.body.kunde, { id: 'c1', name: 'Kunde Eins', kundenart: '' }) + assert.deepEqual(res.body.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) + assert.equal(res.body.stripeOk, true) + assert.equal(res.body.auftraegeAktiv, true) + assert.deepEqual(res.body.konten.map((konto) => [konto.projektId, konto.name, konto.projektTyp, konto.zeit.minuten]), [['p1', 'Website Müller', 'Website', 90]]) + assert.deepEqual(res.body.konten[0].tickets, [{ id: 't1', woid: '100001' }]) +}) + +test('GET /:projektId: Konto, ABs, Rechnungen und offene Arbeitsblätter; unbekannt und unmöglich -> 404', async () => { + seedBasis() + const res = await rawGet(`${BASIS}/p1`, admin) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body).sort(), ['auftraege', 'konto', 'offeneArbeitsblaetter', 'rechnungen', 'stripeOk']) + assert.equal(res.body.konto.projektId, 'p1') + assert.deepEqual(res.body.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w1', 'w2']) + for (const id of ['gibtsnicht', '%20x']) { + const fehler = await rawGet(`${BASIS}/${id}`, admin) + assert.equal(fehler.status, 404, id) + assert.deepEqual(fehler.body, { error: 'Projekt nicht gefunden.' }, id) + } +}) + +test('Stripe-Ausfall: beide Routen antworten 200 mit stripeOk false', async () => { + mock.method(console, 'warn', () => {}) + seedBasis() + st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) + const liste = await rawGet(`${BASIS}?kunde=c1`, admin) + assert.equal(liste.status, 200) + assert.equal(liste.body.stripeOk, false) + assert.equal(liste.body.konten[0].zeit.minuten, 90) + st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) + const detail = await rawGet(`${BASIS}/p1`, admin) + assert.equal(detail.status, 200) + assert.equal(detail.body.stripeOk, false) +}) + +test('Fehler in Appwrite: 500 mit fester Meldung, ohne Text von Appwrite', async () => { + mock.method(console, 'error', () => {}) + seedBasis() + aw.failNext({ collection: config.collections.websiteProjects, method: 'GET', status: 500, message: 'geheim-appwrite' }) + const res = await rawGet(`${BASIS}?kunde=c1`, admin) + assert.equal(res.status, 500) + assert.deepEqual(res.body, { error: 'Interner Fehler.' }) +})