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

267 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } 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'
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const sf = await import('../server/services/siteFeatures.js')
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin' })
aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei' })
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café', subdomain: 'cafe' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Fremd' })
aw.seed('websiteProjects', { $id: 'p_' + 'x'.repeat(40), customerId: 'c1', projectName: 'Lang' })
const consent = { text: 'Ich stimme zu', at: '2026-09-30T10:00:00.000Z', ip: '1.2.3.4', userAgent: 'UA' }
const tickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
test('ohne_dokument_ist_off', async () => {
const list = await sf.listCustomerFeatures('c1')
assert.equal(list.length, 3)
for (const p of list) assert.equal(p.features.news.state, 'off')
assert.equal(list.find((p) => p.projectId === 'p1').liveUrl, 'https://baeckerei.de')
})
test('bezahlt_legt_ticket_an', async () => {
const ordered = await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_1', consent })
assert.equal(ordered.state, 'ordered')
assert.equal(ordered.enabled, false)
assert.equal(ordered.consentIp, '1.2.3.4')
assert.match(ordered.siteKey, /^[a-z2-7]{16}$/)
const { doc, created } = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' })
assert.equal(created, true)
assert.equal(doc.state, 'setup')
assert.equal(doc.enabled, false)
assert.equal(doc.unlockedByPurchase, true)
assert.ok(doc.ticketId)
assert.equal(tickets().length, 1)
assert.match(tickets()[0].topic, /^News-Pinnwand einbauen – Bäckerei/)
assert.ok(tickets()[0].details.includes(`data-webklar-news="${doc.siteKey}"`))
const view = (await sf.listCustomerFeatures('c1')).find((p) => p.projectId === 'p1').features.news
assert.equal(view.state, 'setup')
assert.ok(view.ticketWoid)
})
test('webhook_idempotent', async () => {
const again = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' })
assert.equal(again.created, false)
assert.equal(tickets().length, 1)
})
test('ticket_geschlossen_aktiviert', async () => {
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c1' }), [])
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, 'setup')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c2' }), [])
const now = new Date('2026-10-01T08:00:00Z')
const done = await sf.activateClosedTickets({ now })
assert.equal(done.length, 1)
assert.equal(done[0].state, 'active')
assert.equal(done[0].enabled, true)
assert.equal(done[0].activatedAt, now.toISOString())
assert.equal((await sf.findActiveBySiteKey(doc.siteKey)).$id, doc.$id)
})
test('fehlgeschlagene_zahlung_setzt_off', async () => {
await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: 'cs_2', consent })
const doc = await sf.markCheckoutFailed('cs_2')
assert.equal(doc.state, 'off')
assert.equal(doc.enabled, false)
assert.equal(await sf.markCheckoutFailed('cs_unbekannt'), null)
})
test('fremdes_projekt_403', async () => {
await assert.rejects(() => sf.assertOwnProject('c1', 'p3'), { status: 403 })
await assert.rejects(() => sf.assertOwnProject('c1', 'gibts_nicht'), { status: 403 })
assert.equal((await sf.assertOwnProject('c1', 'p1')).$id, 'p1')
})
test('sitekey_format', () => {
const keys = new Set()
for (let i = 0; i < 100; i += 1) {
const k = sf.newSiteKey()
assert.match(k, /^[a-z2-7]{16}$/)
keys.add(k)
}
assert.equal(keys.size, 100)
})
test('admin_aktiviert_ohne_zahlung', async () => {
const before = tickets().length
const doc = await sf.adminActivate('c1', 'p2', 'news', { by: 'kenso' })
assert.equal(doc.state, 'active')
assert.equal(doc.enabled, true)
assert.equal(doc.unlockedByPurchase, false)
assert.equal(tickets().length, before)
const off = await sf.disable(doc.$id, { by: 'kenso' })
assert.equal(off.state, 'disabled')
assert.equal(off.enabled, false)
})
test('lange_projekt_id_bekommt_gueltige_dokument_id', async () => {
const doc = await sf.adminActivate('c1', 'p_' + 'x'.repeat(40), 'news', {})
assert.ok(doc.$id.length <= 36)
})
test('bestellen_bei_aktiver_funktion_409', async () => {
const doc = await sf.adminActivate('c1', 'p1', 'news', {}).catch(() => null)
const active = doc || (await sf.getFeatureDoc('c1', 'p1', 'news'))
assert.equal(active.state, 'active')
await assert.rejects(
() => sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_x', consent }),
{ status: 409 }
)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, 'active')
})
test('parallele_webhooks_ein_ticket', async () => {
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Parallel' })
await sf.markOrdered({ customerId: 'c1', projectId: 'p4', key: 'news', sessionId: 'cs_par', consent })
const before = tickets().length
const res = await Promise.all([1, 2, 3].map(() => sf.markPaid({ sessionId: 'cs_par', invoiceId: 'in_p', invoiceUrl: 'u' })))
assert.equal(res.filter((r) => r.created).length, 1)
assert.equal(tickets().length, before + 1)
assert.ok((await sf.getFeatureDoc('c1', 'p4', 'news')).ticketId)
})
test('abgelaufen_fasst_setup_und_active_nicht_an', async () => {
// cs_1 ist bezahlt ('setup' bzw. nach dem Schliessen des Tickets 'active'): ein spaetes expired aendert nichts
const before = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.notEqual(before.state, 'ordered')
const after = await sf.markCheckoutFailed('cs_1')
assert.equal(after.state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).purchaseStatus, 'paid')
})
test('bezahlt_und_abgelaufen_gleichzeitig_bleiben_stimmig', async () => {
const pid = 'p_race'
aw.seed('websiteProjects', { $id: pid, customerId: 'c1', projectName: 'Rennen' })
await sf.markOrdered({ customerId: 'c1', projectId: pid, key: 'news', sessionId: 'cs_race', consent })
const ticketsBefore = tickets().length
await Promise.all([
sf.markPaid({ sessionId: 'cs_race', invoiceId: 'in_r', invoiceUrl: '' }),
sf.markCheckoutFailed('cs_race'),
])
const doc = await sf.getFeatureDoc('c1', pid, 'news')
// entweder bezahlt (mit Ticket) oder zurueckgesetzt (ohne): nie 'setup' ohne Ticket oder 'off' mit Ticket
if (doc.state === 'setup') {
assert.ok(doc.ticketId)
assert.equal(tickets().length, ticketsBefore + 1)
} else {
assert.equal(doc.state, 'off')
assert.equal(doc.ticketId || '', '')
assert.equal(tickets().length, ticketsBefore)
}
})
// ---------------------------------------------------------------- Terminplaner als zweite Funktion ---
test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
// erst hier angelegt: die aelteren Tests oben zaehlen die Projekte von c1
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine'])
assert.deepEqual(sf.FEATURE_LABELS, {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
})
})
test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => {
const list = await sf.listCustomerFeatures('c1')
const p = list.find((x) => x.projectId === 'pt1')
assert.deepEqual(Object.keys(p.features), ['news', 'termine'])
assert.equal(p.features.termine.state, 'off')
assert.equal(p.features.termine.siteKey, null)
assert.equal(p.features.termine.ticketWoid, null)
})
test('termine_wird_aktiviert_und_bekommt_eigenen_sitekey_und_eigenes_dokument', async () => {
const doc = await sf.adminActivate('c1', 'pt1', 'termine', { by: 'kenso' })
assert.equal(doc.featureKey, 'termine')
assert.equal(doc.state, 'active')
assert.equal(doc.enabled, true)
assert.equal(doc.unlockedByPurchase, false)
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
assert.equal(await sf.getFeatureDoc('c1', 'pt1', 'news'), null, 'News bleibt unberuehrt')
const view = (await sf.listCustomerFeatures('c1')).find((x) => x.projectId === 'pt1').features
assert.equal(view.termine.state, 'active')
assert.equal(view.termine.siteKey, doc.siteKey)
assert.equal(view.news.state, 'off')
await assert.rejects(() => sf.adminActivate('c1', 'pt1', 'kalender', {}), { status: 400 })
})
test('findActiveBySiteKey: Vorgabe ist news, termine nur mit zweitem Argument, nie der andere Schluessel', async () => {
const termine = await sf.getFeatureDoc('c1', 'pt1', 'termine')
assert.equal((await sf.findActiveBySiteKey(termine.siteKey, 'termine')).$id, termine.$id)
assert.equal(await sf.findActiveBySiteKey(termine.siteKey), null, 'ohne zweites Argument = news')
assert.equal(await sf.findActiveBySiteKey(termine.siteKey, 'news'), null)
const news = await sf.adminActivate('c1', 'pt2', 'news', { by: 'kenso' })
assert.equal((await sf.findActiveBySiteKey(news.siteKey)).$id, news.$id)
assert.equal((await sf.findActiveBySiteKey(news.siteKey, 'news')).$id, news.$id)
assert.equal(await sf.findActiveBySiteKey(news.siteKey, 'termine'), null)
assert.equal(await sf.findActiveBySiteKey('zzzzzzzzzzzzzzzz', 'termine'), null)
assert.equal(await sf.findActiveBySiteKey('KURZ', 'termine'), null)
})
test('abgeschaltet_ist_nicht_auffindbar_und_wieder_aktivieren_leert_disabledAt (R36)', async () => {
const doc = await sf.getFeatureDoc('c1', 'pt1', 'termine')
const off = await sf.disable(doc.$id, { by: 'kenso' })
assert.equal(off.state, 'disabled')
assert.ok(Math.abs(Date.parse(off.disabledAt) - Date.now()) < 5000)
assert.equal(await sf.findActiveBySiteKey(doc.siteKey, 'termine'), null)
const on = await sf.activate(doc.$id, { by: 'kenso' })
assert.equal(on.state, 'active')
assert.equal(on.disabledAt, null, 'sonst koennte der 30-Tage-Loeschlauf eine wieder eingeschaltete Funktion treffen')
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, null)
assert.equal((await sf.findActiveBySiteKey(doc.siteKey, 'termine')).$id, doc.$id)
})
test('activate gilt fuer jede Funktion gleich: News verliert disabledAt ebenfalls', async () => {
const news = await sf.getFeatureDoc('c1', 'pt2', 'news')
assert.equal((await sf.disable(news.$id, {})).state, 'disabled')
assert.equal((await sf.activate(news.$id, {})).disabledAt, null)
})
test('activateClosedTickets bleibt bei News (TP2): ein termine-Dokument im Zustand setup wird nicht angefasst', async () => {
aw.seed('portalFeatures', { $id: 'f_setup_termine', customerId: 'c1', projectId: 'pt2', featureKey: 'termine', state: 'setup', enabled: false, siteKey: 'bbbbbbbbbbbbbbbb', ticketId: 't_closed' })
aw.seed('workorders', { $id: 't_closed', woid: 'WO-9', status: 'Closed' })
const done = await sf.activateClosedTickets({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.featureKey), [])
assert.equal(aw.docs('portalFeatures').get('f_setup_termine').state, 'setup')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { config } = await import('../server/config.js')
await deleteDocument(config.collections.portalFeatures, 'f_setup_termine')
})
test('disableCustomerFeatures: schaltet nur die genannte Funktion des Kunden ab, mit Notiz', async () => {
const mine = await sf.getFeatureDoc('c1', 'pt1', 'termine')
const other = await sf.adminActivate('c2', 'pt3', 'termine', { by: 'kenso' })
const news = await sf.getFeatureDoc('c1', 'pt2', 'news')
await sf.activate(news.$id, {})
const done = await sf.disableCustomerFeatures('c1', 'termine', { adminNote: 'Kunde gelöscht' })
assert.deepEqual(done.map((d) => d.$id), [mine.$id])
const after = await sf.getFeatureDoc('c1', 'pt1', 'termine')
assert.equal(after.state, 'disabled')
assert.equal(after.enabled, false)
assert.equal(after.adminNote, 'Kunde gelöscht')
assert.ok(after.disabledAt)
assert.equal((await sf.getFeatureDoc('c2', 'pt3', 'termine')).state, 'active', 'anderer Kunde')
assert.equal(other.state, 'active')
assert.equal((await sf.getFeatureDoc('c1', 'pt2', 'news')).state, 'active', 'andere Funktion')
// schon abgeschaltet: disabledAt (Beginn der 30 Tage) bleibt
const marker = after.disabledAt
assert.deepEqual(await sf.disableCustomerFeatures('c1', 'termine', { adminNote: 'Kunde gelöscht' }), [])
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, marker)
await assert.rejects(() => sf.disableCustomerFeatures('c1', 'kalender', {}), { status: 400 })
})