Files
Webklar-Kundenbereich/test/funktionenTermine.test.js
T
ClaudeandClaude Sonnet 5.5 eae6eee03c Terminplaner TP2 E2: Nachbesserung 1 (Löschdatum nur für den Terminplaner)
Ruling R105: featureView setzt loeschungAm nur für featureKey 'termine' (der
30-Tage-Löschlauf löscht nur Terminplaner-Daten); disabledReason und disabledAt
bleiben für alle Funktionen. Test: News ohne Löschdatum, Zeitfenster-Prüfung
entfernt, Teamroute-Ansicht pinnt loeschungAm für den Terminplaner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:02:57 +00:00

266 lines
17 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 { loeschTag } = await import('../server/services/termine/frist.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'])
// Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
const aus = pt1.features.news
assert.equal(aus.disabledReason, 'team')
assert.match(aus.disabledAt, /^\d{4}-\d\d-\d\dT[\d:.]+Z$/)
assert.equal(aus.loeschungAm, null)
for (const view of [pt1.features.termine, pt2.features.news, pt2.features.termine]) {
assert.deepEqual([view.disabledReason, view.disabledAt, view.loeschungAm], [null, null, null])
}
// ohne Sitzung wie bisher gesperrt
assert.equal((await rawGet('/api/funktionen')).status, 401)
})
test('GET /api/funktionen: abgeschalteter Terminplaner nennt Grund, Zeitpunkt und Berliner Loeschtag; Altbestand ohne Grund', async () => {
const projekte = [
['pe2_aboende', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }],
['pe2_team', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }],
['pe2_alt', { state: 'disabled', enabled: false, disabledAt: '2026-10-01T10:00:00.000Z' }],
['pe2_aktiv', { state: 'active', enabled: true }],
]
for (const [id, felder] of projekte) {
aw.seed('websiteProjects', { $id: id, customerId: 'ce2', projectName: id })
aw.seed('portalFeatures', { $id: `f_${id}_termine`, customerId: 'ce2', projectId: id, featureKey: 'termine', siteKey: 'abcdefghijklmnop', ...felder })
}
const body = await get('/api/funktionen', { cookie: customerCookie('ce2') })
const termine = (id) => body.projects.find((p) => p.projectId === id).features.termine
const felder = (id) => ({ disabledReason: termine(id).disabledReason, disabledAt: termine(id).disabledAt, loeschungAm: termine(id).loeschungAm })
assert.deepEqual(felder('pe2_aboende'), { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }, 'Berliner Tag, nicht der UTC-Tag 2026-11-03')
assert.deepEqual(felder('pe2_team'), { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' })
assert.deepEqual(felder('pe2_alt'), { disabledReason: null, disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' }, 'Altbestand ohne Grund')
assert.deepEqual(felder('pe2_aktiv'), { disabledReason: null, disabledAt: null, loeschungAm: null })
assert.equal(termine('pe2_aboende').state, 'disabled')
})
test('Abgeschaltetes News hat Grund und Zeitpunkt, aber kein Loeschdatum; die Teamansicht nennt es fuer den Terminplaner (R105)', async () => {
const lange_her = '2026-01-15T10:00:00.000Z'
aw.seed('websiteProjects', { $id: 'pe3_p', customerId: 'ce3', projectName: 'Alt abgeschaltet' })
aw.seed('portalFeatures', { $id: 'f_pe3_news', customerId: 'ce3', projectId: 'pe3_p', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: lange_her })
aw.seed('portalFeatures', { $id: 'f_pe3_termine', customerId: 'ce3', projectId: 'pe3_p', featureKey: 'termine', siteKey: 'bcdefghijklmnopq', state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: lange_her })
// Kundenansicht
const kunde = (await get('/api/funktionen', { cookie: customerCookie('ce3') })).projects.find((p) => p.projectId === 'pe3_p').features
assert.deepEqual([kunde.news.state, kunde.news.disabledReason, kunde.news.disabledAt, kunde.news.loeschungAm], ['disabled', 'team', lange_her, null])
assert.deepEqual([kunde.termine.disabledReason, kunde.termine.disabledAt, kunde.termine.loeschungAm], ['abo_ende', lange_her, '2026-02-14'])
// Teamansicht (adminView, GET /api/team/funktionen/:customerId): dieselben Felder, die WOMS-Anzeige (E12) liest loeschungAm
const team_ansicht = (await get('/api/team/funktionen/ce3', STAFF)).projects.find((p) => p.projectId === 'pe3_p').features
assert.deepEqual([team_ansicht.termine.state, team_ansicht.termine.disabledReason, team_ansicht.termine.disabledAt], ['disabled', 'abo_ende', lange_her])
assert.equal(team_ansicht.termine.loeschungAm, '2026-02-14', 'abgeschalteter Terminplaner traegt loeschungAm (Berliner Tag, 30 x 24 h spaeter)')
assert.equal(team_ansicht.termine.loeschungAm, loeschTag(lange_her))
assert.deepEqual([team_ansicht.news.disabledReason, team_ansicht.news.disabledAt, team_ansicht.news.loeschungAm], ['team', lange_her, null])
})
// --------------------------------------------------------- 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]))))
})