267 lines
15 KiB
JavaScript
267 lines
15 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'
|
|
|
|
/**
|
|
* "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 })
|
|
})
|