Projektkonto: Admin-Routen für Konten je Kunde und Detail je Projekt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:12:40 +00:00
1 parent 32726fa1c2
commit 846059b455
2 files changed
+162 -2

No files matched your search

+29 -2
View File
@@ -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=<id>) und GET /:projektId.
* Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router).
* GET /?kunde=<id> 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
+133
View File
@@ -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=<unbekannt> -> 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.' })
})