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>
266 lines
17 KiB
JavaScript
266 lines
17 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'
|
||
|
||
// 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]))))
|
||
})
|