Files
Webklar-Kundenbereich/test/storageTasks.test.js
T

231 lines
12 KiB
JavaScript

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'
/**
* Task 13: Admin-Reiter "E-Mail-Speicher" (GET/POST /api/portal-admin/storage-tasks), Schnittstelle fuers
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden des Reiters.
*/
// 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, adminCookie, seedFixtures, stripeSubscription } =
await import('./support/fixtures.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.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/portal-admin': portalAdminRoutes, '/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()
const ADMIN = { cookie: adminCookie() }
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('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 () => {
// zwei aktive Abos: storageUnits 0 und 2 → 5 + 15 = 20 GB
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 20)
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/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 30)
} finally {
await deleteDocument(config.collections.subscriptions, 'sub_alt')
await deleteDocument(config.collections.subscriptions, 'sub_pd')
}
})
test('Admin: Aufgabe erledigen', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(status, 200)
assert.equal(task.status, 'erledigt')
assert.equal(task.doneBy, 'kenso@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, 'kenso@webklar.com')
// Die Liste zeigt sie nicht mehr
const after = await get('/api/portal-admin/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/portal-admin/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const again = await post(`/api/portal-admin/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/portal-admin/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/portal-admin/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/portal-admin/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/portal-admin/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 Admin-Session 401', async () => {
assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401)
assert.equal((await rawGet('/api/portal-admin/storage-tasks')).status, 401)
assert.equal((await post('/api/portal-admin/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 })
})