Files
Webklar-Kundenbereich/test/funktionenTermine.test.js
T
ClaudeandClaude Sonnet 5.5 aaed273b62 Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup
disable() laeuft unter einer Sperre je Dokument und aendert ein schon
abgeschaltetes Dokument nicht mehr (disabledAt als Beginn der 30-Tage-Frist,
Grund und Notiz bleiben); nur Altbestand ohne disabledAt bekommt dieses Feld.
Neu: DISABLED_REASONS und der Parameter reason (Vorgabe team, unbekannt = 400),
disableCustomerFeatures reicht ihn durch, die Kundenloeschung nutzt
kunde_geloescht. activate() leert zusaetzlich disabledReason und aboEndeMailAt
und laesst zahlungMeldung unberuehrt.

scripts/feature-setup.mjs legt portalFeatures.disabledReason, aboEndeMailAt und
zahlungMeldung idempotent an (--check meldet Luecken mit Exit 1);
schemaKeys('portalFeatures') zieht die Schluessel mit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:33:17 +00:00

218 lines
12 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'
// Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter,
// Kunde loeschen schaltet den Terminplaner ab. News verhaelt sich wie bisher (test/funktionenRoutes.test.js).
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, rawGet, post } = await import('./support/testApp.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
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' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const cookie = customerCookie('c1')
const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts)
const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features
const workorders = () => aw.docs('workorders').size
const docOf = (projectId, key) => sf.getFeatureDoc('c1', projectId, key)
test('Team aktiviert termine fuer ein Projekt, News bleibt unveraendert', async () => {
const tickets = workorders()
const res = await team('c1', 'termine', { projectId: 'pt1', action: 'activate', note: 'Pilot' })
assert.equal(res.status, 200)
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['pt1'])
const view = featuresOf(res, 'pt1')
assert.equal(view.termine.state, 'active')
assert.equal(view.termine.ticketWoid, null)
assert.match(view.termine.siteKey, /^[a-z2-7]{16}$/)
assert.match(view.termine.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/)
assert.equal(view.termine.ticketMissing, false)
assert.equal(view.news.state, 'off')
assert.equal(view.news.adminNote, '')
const doc = await docOf('pt1', 'termine')
assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedByPurchase], ['termine', 'active', true, false])
assert.equal(await docOf('pt1', 'news'), null, 'kein News-Dokument entstanden')
assert.equal(workorders(), tickets, 'kein Ticket')
// das Projekt ohne Aktion bleibt aus
const list = await get('/api/team/funktionen/c1', STAFF)
assert.equal(list.projects.find((p) => p.projectId === 'pt2').features.termine.state, 'off')
})
test('/news bleibt gleichwertig und aendert den Terminplaner nicht', async () => {
const res = await team('c1', 'news', { projectId: 'pt1', action: 'activate', note: 'Kulanz' })
assert.equal(res.status, 200)
const view = featuresOf(res, 'pt1')
assert.equal(view.news.state, 'active')
assert.match(view.news.adminNote, /Andrej: activate – Kulanz$/)
assert.equal(view.termine.state, 'active')
assert.doesNotMatch(view.termine.adminNote, /Kulanz/)
assert.notEqual((await docOf('pt1', 'news')).siteKey, (await docOf('pt1', 'termine')).siteKey)
assert.equal((await team('c1', 'news', { projectId: 'pt1', action: 'disable' })).status, 200)
const after = await docOf('pt1', 'termine')
assert.equal(after.state, 'active')
})
test('Abschalten setzt disabledAt und den Grund team, erneutes Aktivieren leert beides (R36)', async () => {
const off = await team('c1', 'termine', { projectId: 'pt1', action: 'disable' })
assert.equal(off.status, 200)
assert.equal(featuresOf(off, 'pt1').termine.state, 'disabled')
const doc = await docOf('pt1', 'termine')
assert.equal(doc.enabled, false)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000)
assert.equal(doc.disabledReason, 'team', 'das WOMS schaltet mit dem Vorgabegrund ab')
// zweiter Klick (Doppelklick im WOMS): Beginn der 30 Tage, Grund und Notiz bleiben
const nochmal = await team('c1', 'termine', { projectId: 'pt1', action: 'disable', note: 'zweiter Klick' })
assert.equal(nochmal.status, 200)
const gleich = await docOf('pt1', 'termine')
assert.deepEqual([gleich.disabledAt, gleich.disabledReason, gleich.adminNote], [doc.disabledAt, 'team', doc.adminNote])
const on = await team('c1', 'termine', { projectId: 'pt1', action: 'activate' })
assert.equal(featuresOf(on, 'pt1').termine.state, 'active')
const aktiv = await docOf('pt1', 'termine')
assert.equal(aktiv.disabledAt, null)
assert.equal(aktiv.disabledReason, null)
})
test('unbekannter Schluessel -> 404, auch /kalender; nichts wird angelegt', async () => {
const docs = aw.docs('portalFeatures').size
for (const key of ['kalender', 'analyse', 'Termine', 'termine2']) {
const res = await team('c1', key, { projectId: 'pt2', action: 'activate' })
assert.equal(res.status, 404, key)
assert.equal(typeof res.body.error, 'string', key)
}
// der Schluessel wird vor dem Rumpf geprueft
assert.equal((await team('c1', 'kalender', {})).status, 404)
assert.equal(aw.docs('portalFeatures').size, docs)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Meldungen sind je Funktion grammatisch', async () => {
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Ohne Dokument' })
const termine = await team('c1', 'termine', { projectId: 'pt4', action: 'disable' })
assert.equal(termine.status, 409)
assert.equal(termine.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
const news = await team('c1', 'news', { projectId: 'pt4', action: 'disable' })
assert.equal(news.status, 409)
assert.equal(news.body.error, 'Für dieses Projekt gibt es noch keine News-Pinnwand')
assert.equal(await docOf('pt4', 'termine'), null)
})
test('Pruefungen wie bei News: Aktion, Projekt, fremdes Projekt, Kunde', async () => {
assert.equal((await team('c1', 'termine', { projectId: 'pt1', action: 'loeschen' })).status, 400)
assert.equal((await team('c1', 'termine', { action: 'activate' })).status, 400)
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'activate' })).status, 403)
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'activate' })).status, 403)
assert.equal(await sf.getFeatureDoc('c2', 'pt3', 'termine'), null)
const asCustomer = await team('c1', 'termine', { projectId: 'pt2', action: 'activate' }, { cookie })
assert.ok(asCustomer.status === 401 || asCustomer.status === 403)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Die Ansicht des Teams liest die Funktionsdokumente des Kunden einmal', async (t) => {
const echt = globalThis.fetch
const abfragen = []
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`)
return echt(url, optionen)
}
t.after(() => (globalThis.fetch = echt))
const list = await get('/api/team/funktionen/c1', STAFF)
assert.ok(list.projects.length >= 3)
assert.equal(abfragen.length, 1, abfragen.join(' | '))
assert.match(abfragen[0], /^GET .*\/collections\/portalFeatures\/documents$/)
})
// ------------------------------------------------------------- Kundenliste ---
const MODUS = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
const termineMit = (mode, pilotEmails = []) => {
config.termine.mode = mode
config.termine.pilotEmails = pilotEmails
}
after(() => termineMit(MODUS.mode, MODUS.pilotEmails))
const verfuegbar = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine
test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail', async () => {
termineMit('off')
assert.deepEqual(await verfuegbar(), { verfuegbar: false })
termineMit('pilot', ['k@example.invalid'])
assert.deepEqual(await verfuegbar(), { verfuegbar: true })
assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste')
termineMit('pilot', [])
assert.deepEqual(await verfuegbar(), { verfuegbar: false })
termineMit('pilot', ['*'])
assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true })
termineMit('on')
assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true })
})
test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher', async () => {
termineMit('pilot', ['*'])
const body = await get('/api/funktionen', { cookie })
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
const pt1 = body.projects.find((p) => p.projectId === 'pt1')
assert.equal(pt1.features.termine.state, 'active')
assert.equal(pt1.features.news.state, 'disabled')
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine'])
// ohne Sitzung wie bisher gesperrt
assert.equal((await rawGet('/api/funktionen')).status, 401)
})
// --------------------------------------------------------- Kunde loeschen ---
test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht, Grund kunde_geloescht), nur seine', async () => {
aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' })
aw.seed('websiteProjects', { $id: 'ktp2', customerId: 'kt1', projectName: 'Weg Zwei' })
aw.seed('websiteProjects', { $id: 'ktp3', customerId: 'kt1', projectName: 'Weg Drei' })
await sf.adminActivate('kt1', 'ktp1', 'termine', { by: 'test' })
await sf.adminActivate('kt1', 'ktp1', 'news', { by: 'test' })
await sf.adminActivate('kt1', 'ktp2', 'termine', { by: 'test' })
const schonAus = await sf.adminActivate('kt1', 'ktp3', 'termine', { by: 'test' })
const frueher = (await sf.disable(schonAus.$id, { by: 'test' })).disabledAt
const fremd = await sf.getFeatureDoc('c1', 'pt1', 'termine')
await deleteCustomerWithPortalAccess('kt1')
assert.equal(aw.docs('customers').has('kt1'), false)
for (const projekt of ['ktp1', 'ktp2']) {
const doc = await sf.getFeatureDoc('kt1', projekt, 'termine')
assert.deepEqual([doc.state, doc.enabled, doc.adminNote, doc.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'], projekt)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, projekt)
}
const schonAusNachher = await sf.getFeatureDoc('kt1', 'ktp3', 'termine')
assert.equal(schonAusNachher.disabledAt, frueher, 'Beginn der 30 Tage bleibt')
assert.equal(schonAusNachher.disabledReason, 'team', 'ein schon abgeschalteter Terminplaner behaelt seinen Grund')
assert.equal((await sf.getFeatureDoc('kt1', 'ktp1', 'news')).state, 'active', 'News ist nicht Sache des Terminplaners')
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).state, fremd.state, 'anderer Kunde')
})
test('Kunde loeschen gelingt auch, wenn das Abschalten scheitert (Fehler im Log)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
aw.seed('customers', { $id: 'kt2', name: 'Weg Zwei', email: 'weg2@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp4', customerId: 'kt2', projectName: 'Weg Vier' })
await sf.adminActivate('kt2', 'ktp4', 'termine', { by: 'test' })
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('kt2')
assert.equal(aw.docs('customers').has('kt2'), false)
assert.ok(log.mock.calls.some((c) => /Terminplaner von Kunde kt2 nicht abgeschaltet/.test(String(c.arguments[0]))))
})