import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' 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' /** * "E-Mail-Speicher" im Ticketsystem (GET/POST /api/team/storage-tasks, alle Mitarbeiter), Schnittstelle fuers * Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden der Liste. */ // Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') const { default: teamRoutes } = await import('../server/routes/team/index.js') const { default: adminCustomersRoutes } = await import('../server/routes/admin/customers.js') const { listContractEvents, recordContractEvent, getContractEvent } = await import('../server/services/contractEvents.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const app = await startTestApp({ '/api/team': teamRoutes, '/api/admin/customers': adminCustomersRoutes }) // F25: Mails (K14 beim Wechsel-Abgleich) landen in der Senke statt bei SMTP const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } }) st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents)) st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents)) st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents)) const HOUR = 3600000 const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString() // Mitarbeiter ohne Admin-Label (Team-Eingang: alle Mitarbeiter gleich) const ADMIN = { headers: { Authorization: 'Bearer jwt-staff' } } const STAFF_JWT = { headers: { Authorization: 'Bearer jwt-admin' } } async function clearEvents() { for (const customerId of ['c1', 'c2', 'c3']) { for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) } } const storageEvent = ({ id, customerId, subscriptionId, kind, fromUnits, toUnits, dueAt, status = 'offen', extra = {} }) => recordContractEvent({ id, customerId, subscriptionId, type: 'speicher', productKey: 'hosting', details: { kind, fromUnits, toUnits, effectiveAt: dueAt, totalGb: 5 + 5 * toUnits, monthlyCents: 100 * toUnits, ...extra }, consent: toUnits > fromUnits ? consent : null, status, dueAt, }) /** * Ausgangslage jedes Tests: zwei aktive Abos (sub_h von c1 ohne Pakete, sub_h2 von c2 mit 2 Paketen -> * 5 + 15 = 20 GB vergeben), dazu eine Einrichtung fuer c2 (faellig seit einer Stunde, 0 -> 2) und eine * Verringerung fuer c1 zum Periodenende (3 -> 1). */ beforeEach(async () => { await clearEvents() mails.sent.length = 0 seedFixtures(aw) aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) // sub_h wurde von 3 auf 1 Paket verringert; konsoleH haelt bis zum Periodenende noch 3 (offene Aufgabe unten) aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd }) aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd }) st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting' })) st.subscriptions.set('sub_h2', stripeSubscription({ id: 'sub_h2', customerId: 'c2', productKey: 'hosting', storageUnits: 2 })) await storageEvent({ id: 'speicher-einrichten-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 0, toUnits: 2, dueAt: iso(-HOUR) }) await storageEvent({ id: 'speicher-periodenende-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'periodenende', fromUnits: 3, toUnits: 1, dueAt: hostingDoc.currentPeriodEnd }) }) test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => { // sub_h: 1 Paket, aber 3→1 erst zum Periodenende → konsoleH haelt 5 + 15 = 20 GB; sub_h2: 5 + 10 = 15 GB const r = await get('/api/team/storage-tasks', ADMIN) assert.equal(r.allocatedGb, 35) assert.equal(r.packageGb, 50) assert.equal(r.warnGb, 40) assert.equal(r.packageName, 'Webhosting M') assert.equal(r.tasks.length, 2) // faelligste zuerst: die Einrichtung (seit einer Stunde faellig) vor der Verringerung zum Periodenende assert.equal(r.tasks[0].toUnits, 2) assert.equal(r.tasks[0].fromUnits, 0) assert.equal(r.tasks[0].kind, 'einrichten') assert.equal(r.tasks[0].customerId, 'c2') assert.equal(r.tasks[0].customerName, 'Kunde Zwei') assert.equal(r.tasks[0].email, 'k2@example.invalid') assert.equal(r.tasks[0].targetGb, 15) assert.equal(r.tasks[0].productKey, 'hosting') assert.ok(r.tasks[0].dueAt && r.tasks[0].createdAt) assert.equal(r.tasks[1].kind, 'periodenende') assert.equal(r.tasks[1].toUnits, 1) assert.equal(r.tasks[1].targetGb, 10) assert.equal(r.tasks[1].dueAt, hostingDoc.currentPeriodEnd) assert.equal(r.tasks[1].customerName, 'Kunde Eins') assert.deepEqual(r.failed, []) }) test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)', async () => { aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...hostingDoc, customerId: 'c3', status: 'canceled', storageUnits: 10 })) aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 })) try { const r = await get('/api/team/storage-tasks', ADMIN) assert.equal(r.allocatedGb, 45) } finally { await deleteDocument(config.collections.subscriptions, 'sub_alt') await deleteDocument(config.collections.subscriptions, 'sub_pd') } }) test('Admin: belegter Speicher zaehlt die Verringerung erst nach Erledigt', async () => { const before = await get('/api/team/storage-tasks', ADMIN) assert.equal(before.allocatedGb, 35, 'offene Aufgabe 3→1: konsoleH haelt noch 20 GB fuer sub_h') const task = before.tasks.find((t) => t.kind === 'periodenende') await post(`/api/team/storage-tasks/${task.id}/done`, {}, ADMIN) const after = await get('/api/team/storage-tasks', ADMIN) assert.equal(after.allocatedGb, 25, 'nach Erledigt: 5 + 5 = 10 GB fuer sub_h, dazu 15 GB fuer sub_h2') }) test('Admin: Aufgabe nennt Gesamtspeicher des Kunden und Kontakt-Domain', async () => { const { tasks } = await get('/api/team/storage-tasks', ADMIN) assert.equal(tasks[0].targetGb, 15) assert.equal(tasks[0].contactDomain, 'example.invalid') assert.equal(tasks[1].targetGb, 10) }) test('Admin: Erledigt auf ein gescheitertes Ereignis → 409', async () => { await storageEvent({ id: 'speicher-gescheitert-x', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' }) const r = await post('/api/team/storage-tasks/speicher-gescheitert-x/done', {}, ADMIN) assert.equal(r.status, 409) assert.equal((await getContractEvent('speicher-gescheitert-x')).status, 'gescheitert') }) test('Admin: gescheiterte Aenderungen nur der letzten 30 Tage', async () => { await storageEvent({ id: 'speicher-gescheitert-neu', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' }) await storageEvent({ id: 'speicher-gescheitert-alt', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 5, dueAt: iso(-2000), status: 'gescheitert' }) aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-alt')), $createdAt: iso(-31 * 24 * HOUR) }) aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-neu')), $createdAt: iso(-29 * 24 * HOUR) }) const r = await get('/api/team/storage-tasks', ADMIN) assert.deepEqual(r.failed.map((f) => f.id), ['speicher-gescheitert-neu']) }) test('Admin: Aufgabe erledigen', async () => { const { tasks } = await get('/api/team/storage-tasks', ADMIN) const { status, body: { task } } = await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN) assert.equal(status, 200) assert.equal(task.status, 'erledigt') assert.equal(task.doneBy, 'andrej@webklar.com') assert.ok(task.doneAt) assert.equal(task.id, tasks[0].id) const stored = await getContractEvent(tasks[0].id) assert.equal(stored.status, 'erledigt') assert.equal(stored.doneBy, 'andrej@webklar.com') // Die Liste zeigt sie nicht mehr const after = await get('/api/team/storage-tasks', ADMIN) assert.deepEqual(after.tasks.map((t) => t.id), [tasks[1].id]) }) test('Admin: Erledigt nur fuer offene Speicher-Aufgaben (404 unbekannt, 409 schon erledigt)', async () => { assert.equal((await post('/api/team/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404) const { tasks } = await get('/api/team/storage-tasks', ADMIN) await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN) const again = await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN) assert.equal(again.status, 409) // Ein Wechsel-Ereignis ist keine Speicher-Aufgabe await recordContractEvent({ id: 'wechsel-offen-x', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: {}, consent, status: 'offen' }) assert.equal((await post('/api/team/storage-tasks/wechsel-offen-x/done', {}, ADMIN)).status, 404) }) test('Admin: teilweise (Stripe-Schritt 2 gescheitert) und gescheiterte Aenderungen sind sichtbar', async () => { await storageEvent({ id: 'speicher-teilweise-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 2, toUnits: 5, dueAt: iso(-1000), extra: { partial: true, error: 'Stripe: card_declined' } }) await storageEvent({ id: 'speicher-gescheitert-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert', extra: { error: 'Stripe: resource_missing' } }) const r = await get('/api/team/storage-tasks', ADMIN) const partial = r.tasks.find((t) => t.id === 'speicher-teilweise-c2') assert.equal(partial.partial, true) assert.equal(partial.error, 'Stripe: card_declined') assert.equal(r.tasks.some((t) => t.id === 'speicher-gescheitert-c1'), false, 'gescheitert ist keine offene Aufgabe') assert.equal(r.failed.length, 1) assert.equal(r.failed[0].id, 'speicher-gescheitert-c1') assert.equal(r.failed[0].status, 'gescheitert') assert.equal(r.failed[0].error, 'Stripe: resource_missing') assert.equal(r.failed[0].customerName, 'Kunde Eins') assert.equal(r.failed[0].toUnits, 4) }) test('Admin: Wechsel-Abgleich beim Laden des Reiters (verlorener Stripe-Webhook)', async () => { // Offener Kartenwechsel, dessen zurueckgehaltene Aenderung laengst verfallen ist; Stripe hat sie verworfen await recordContractEvent({ id: 'wechsel-abgelaufen-c1', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: { switchedAt: iso(-30 * HOUR), pendingExpiresAt: iso(-60000), from: 'hosting', to: 'wartung' }, consent, status: 'offen', }) const r = await get('/api/team/storage-tasks', ADMIN) assert.equal(r.tasks.length, 2) const ev = await getContractEvent('wechsel-abgelaufen-c1') assert.equal(ev.status, 'gescheitert') assert.equal(ev.doneBy, 'abgleich') }) test('Admin: Wechsel-Abgleich laesst einen noch laufenden Wechsel in Ruhe', async () => { await recordContractEvent({ id: 'wechsel-laufend-c1', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: { switchedAt: iso(-HOUR), pendingExpiresAt: iso(22 * HOUR) }, consent, status: 'offen', }) const before = st.calls.length await get('/api/team/storage-tasks', ADMIN) assert.equal((await getContractEvent('wechsel-laufend-c1')).status, 'offen') assert.equal(st.calls.length, before, 'kein Stripe-Abruf fuer einen nicht abgelaufenen Wechsel') }) test('Ohne Mitarbeiter-JWT 401 (auch mit Kunden-Cookie)', async () => { assert.equal((await rawGet('/api/team/storage-tasks', { cookie: customerCookie() })).status, 401) assert.equal((await rawGet('/api/team/storage-tasks')).status, 401) assert.equal((await post('/api/team/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401) assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen') }) test('Ticketsystem-Schnittstelle: Produkt und aktiv', async () => { const r = await get('/api/admin/customers/c1/abo', STAFF_JWT) assert.deepEqual(r, { productKey: 'hosting', active: true }) assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403) assert.equal((await rawGet('/api/admin/customers/c1/abo')).status, 401) assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-fremd' } })).status, 401) }) test('Ticketsystem-Schnittstelle: Wartungs-Abo, gekuendigtes Abo, kein Abo', async () => { aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc })) assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'wartung', active: true }) // Altbestand ohne productKey gilt als Wartungs-Abo aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, productKey: null })) assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'wartung', active: true }) // Beendetes Abo: Produkt bekannt, aber nicht aktiv aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, status: 'canceled' })) assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'hosting', active: false }) // Kein Abo assert.deepEqual(await get('/api/admin/customers/c3/abo', STAFF_JWT), { productKey: null, active: false }) })