267 lines
13 KiB
JavaScript
267 lines
13 KiB
JavaScript
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 })
|
||
})
|