import { test, after } from 'node:test' import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' 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' // Etappe 3: Gutschriften, Abo-Angebote, E-Mail-Speicher und Funktionen ueber den Team-Eingang (/api/team, JWT) process.env.TAKEOVER_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-takeover-')) const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), takeoverCredits: schemaKeys('takeoverCredits'), aboOffers: schemaKeys('aboOffers'), portalFeatures: schemaKeys('portalFeatures'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { startTestApp, rawGet, post } = await import('./support/testApp.js') const { seedFixtures, consent } = await import('./support/fixtures.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { recordContractEvent } = await import('../server/services/contractEvents.js') const { default: teamRoutes } = await import('../server/routes/team/index.js') const app = await startTestApp({ '/api/team': teamRoutes }) setMailTransportForTests(mailSink()) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) const credit = (id, customerId, status) => aw.seed('takeoverCredits', { $id: id, customerId, email: 'k@example.invalid', services: JSON.stringify(['hosting']), provider: 'IONOS', domain: 'kunde.de', invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31T00:00:00.000Z', message: 'Bitte verrechnen', files: '[]', status, history: '[]', takeoverFrom: '2026-10-01T00:00:00.000Z', }) credit('tc1', 'c1', 'offen') credit('tc2', 'c1', 'abgelehnt') credit('tc3', 'c2', 'offen') await recordContractEvent({ id: 'speicher-c2', customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting', details: { kind: 'einrichten', fromUnits: 0, toUnits: 2, effectiveAt: new Date().toISOString(), totalGb: 15, monthlyCents: 200 }, consent, status: 'offen', dueAt: new Date().toISOString(), }) aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Team-Test' }) const staff = { headers: { authorization: 'Bearer jwt-staff' } } test('alle vier Bereiche mit jwt-staff erreichbar (kein Admin-Label noetig)', async () => { for (const p of ['/api/team/takeover-credits', '/api/team/offers', '/api/team/storage-tasks', '/api/team/funktionen/c1']) { assert.equal((await rawGet(p, staff)).status, 200, p) } }) test('Kundenfilter: Gutschriften nur des Kunden, openCount weiter ueber alle', async () => { const all = (await rawGet('/api/team/takeover-credits?status=all&customerId=c1', staff)).body assert.deepEqual(all.requests.map((r) => r.id).sort(), ['tc1', 'tc2']) assert.equal(all.openCount, 2) const open = (await rawGet('/api/team/takeover-credits?customerId=c2', staff)).body assert.deepEqual(open.requests.map((r) => r.id), ['tc3']) }) test('Angebot anlegen als Mitarbeiter, Kundenfilter der Angebotsliste', async () => { const res = await post('/api/team/offers', { customerId: 'c2', productKey: 'wartung', monthlyPriceCents: 2500, months: 2, notifyCustomer: false }, staff) assert.equal(res.status, 201, JSON.stringify(res.body)) assert.equal(res.body.offer.createdBy, 'Andrej ') assert.deepEqual((await rawGet('/api/team/offers?customerId=c2', staff)).body.offers.map((o) => o.customerId), ['c2']) assert.deepEqual((await rawGet('/api/team/offers?customerId=c1', staff)).body.offers, []) }) test('E-Mail-Speicher open-count ohne Stripe-Abgleich', async () => { const before = st.calls.length assert.deepEqual((await rawGet('/api/team/storage-tasks/open-count', staff)).body, { open: 1 }) assert.equal(st.calls.length, before) }) test('Mitarbeiter aus dem JWT: Rueckfrage, Erledigt und Funktionen-Notiz tragen Andrej', async () => { const q = await post('/api/team/takeover-credits/tc3/decision', { action: 'question', note: 'Bitte Rechnung schicken' }, staff) assert.equal(q.status, 200, JSON.stringify(q.body)) const detail = (await rawGet('/api/team/takeover-credits/tc3', staff)).body assert.equal(detail.request.history.at(-1).by, 'Andrej ') const done = await post('/api/team/storage-tasks/speicher-c2/done', {}, staff) assert.equal(done.status, 200, JSON.stringify(done.body)) assert.equal(done.body.task.doneBy, 'andrej@webklar.com') const news = await post('/api/team/funktionen/c1/news', { projectId: 'pa1', action: 'activate', note: 'Pilot' }, staff) assert.equal(news.status, 200) assert.match(news.body.projects[0].features.news.adminNote, /Andrej: activate – Pilot$/) }) test('ohne JWT 401 fuer alle neuen Pfade', async () => { for (const p of ['/api/team/takeover-credits', '/api/team/takeover-credits/open-count', '/api/team/offers', '/api/team/storage-tasks', '/api/team/storage-tasks/open-count', '/api/team/funktionen/c1']) { assert.equal((await rawGet(p)).status, 401, p) } }) // Task 2: Nachweise per signiertem Link (15 min) – Links im Ticketsystem tragen keinen Authorization-Header const FILE_ID = '0b9c2f0e-6d6e-4c1a-9a51-6a3a9d1b2c3d' aw.seed('takeoverCredits', { // eigener Kunde und entschieden, damit Filter und openCount der anderen Tests gleich bleiben $id: 'tc9', customerId: 'c4', email: 'k@example.invalid', services: JSON.stringify(['hosting']), provider: 'IONOS', domain: 'kunde.de', invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31T00:00:00.000Z', message: 'x', status: 'abgelehnt', history: '[]', takeoverFrom: '2026-10-01T00:00:00.000Z', files: JSON.stringify([{ id: FILE_ID, name: 'rechnung.pdf', type: 'application/pdf', ext: 'pdf', size: 9, uploadedAt: '2026-10-01T08:00:00.000Z' }]), }) mkdirSync(path.join(process.env.TAKEOVER_UPLOAD_DIR, 'tc9'), { recursive: true }) writeFileSync(path.join(process.env.TAKEOVER_UPLOAD_DIR, 'tc9', `${FILE_ID}.pdf`), '%PDF-1.4\n') test('Nachweis: signierter Link ohne JWT 200 mit Inhalt, fremder Antrag/abgelaufen 403, ohne sig 401', async (t) => { const detail = (await rawGet('/api/team/takeover-credits/tc9', staff)).body const url = detail.request.files[0].url assert.match(url, new RegExp(`^https://project\\.webklar\\.com/api/team/takeover-credits/tc9/files/${FILE_ID}\\?exp=\\d+&sig=[0-9a-f]{64}$`)) const u = new URL(url) const rel = u.pathname + u.search const ok = await fetch(app.url + rel) assert.equal(ok.status, 200) assert.equal(ok.headers.get('content-type'), 'application/pdf') assert.equal(await ok.text(), '%PDF-1.4\n') assert.equal((await rawGet(rel.replace('/tc9/', '/tc1/'))).status, 403) assert.equal((await rawGet(u.pathname)).status, 401) const now = Date.now() t.mock.method(Date, 'now', () => now + 16 * 60 * 1000) const expired = await rawGet(rel) assert.equal(expired.status, 403) assert.equal(expired.body.error, 'Link abgelaufen. Bitte den Antrag neu öffnen.') }) // Abschlussreview: zwei Mitarbeiter entscheiden im selben Moment -> genau eine Entscheidung, eine Kunden-Mail test('gleichzeitige Entscheidungen: eine gewinnt, die andere bekommt 409', async () => { credit('tc7', 'c2', 'offen') const sink = mailSink() setMailTransportForTests(sink) const checks = { recipient: true, service: true, period: true, replaced: true } const figures = { invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31', takeoverFrom: '2026-10-01' } const [a, b] = await Promise.all([ post('/api/team/takeover-credits/tc7/decision', { action: 'approve', amountCents: 5000, checks, note: '', ...figures }, staff), post('/api/team/takeover-credits/tc7/decision', { action: 'reject', note: 'Leider nicht moeglich' }, staff), ]) assert.deepEqual([a.status, b.status].sort(), [200, 409], JSON.stringify([a.body, b.body])) const loser = a.status === 409 ? a : b assert.equal(loser.body.error, 'Über diesen Antrag wurde bereits entschieden.') assert.equal(sink.sent.length, 1, 'genau eine Kunden-Mail') })