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])))) })