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:
1 parent
32726fa1c2
commit
846059b455
2 files changed
+162
-2
No files matched your search
@@ -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
|
||||
@@ -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.' })
|
||||
})
|
||||
Reference in new issue
Block a user