From f57dad73ca8a35927bacc6593a523a08e495e0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:35:57 +0000 Subject: [PATCH] Freischaltung ueber das Register mit Pruefung von Einstellung und Abo activateClosedTickets oeffnet jetzt alle Funktionen mit ticketFreischaltung im Register (ticketFreischaltungKeys) statt fest news und fragt unter der Sperre feature-activate: nach dem frischen Lesen den Haken freischaltbar(doc). Der Standard fuer termine verlangt eine gespeicherte Einstellung und ein laufendes Wartungs-Abo. false oder Fehler (z. B. Abo nicht lesbar) lassen das Dokument in setup: Log und genau ein adminNote-Eintrag "Freischaltung wartet: ", keine Mail. News bleibt unveraendert. Co-Authored-By: Claude Sonnet 5.5 --- server/services/featureRegistry.js | 23 ++ server/services/siteFeatures.js | 52 +++- test/featureFreischaltung.test.js | 440 +++++++++++++++++++++++++++++ test/siteFeatures.test.js | 36 ++- 4 files changed, 541 insertions(+), 10 deletions(-) create mode 100644 test/featureFreischaltung.test.js diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 6547502..893050d 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -25,6 +25,18 @@ async function termineBestellpruefung({ customerId, projectId, feature }) { return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false }) } +/** + * Freischaltpruefung des Terminplaners (Spec 4.5: die Aktivierung bleibt gesperrt, bis ein Abo laeuft): die Einrichtung + * ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Fail-closed: ein Lesefehler + * (Abo, Einstellung) wirft, activateClosedTickets haelt das Dokument dann in 'setup'. Der Aufrufer liest das Dokument + * unter der Sperre frisch und uebergibt es; auch das Abo wird hier frisch gelesen, nie aus einem Zwischenspeicher. + */ +async function termineFreischaltbar(doc) { + const [{ ladeEinstellung }, { laeuft }] = await Promise.all([import('./termine/kontext.js'), import('./aboStatus.js')]) + if (!(await laeuft(doc.customerId))) return false + return (await ladeEinstellung(doc.$id)) !== null +} + /** * Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der * Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion(). @@ -44,6 +56,7 @@ const STANDARD_HAKEN = { bestellpruefung: termineBestellpruefung, unterlagenBereit: async () => true, belehrung: async () => BELEHRUNG_PLATZHALTER, + freischaltbar: termineFreischaltbar, }, } @@ -58,9 +71,19 @@ export function featureKeys() { return Object.keys(config.features) } +/** + * Schluessel der Funktionen, die nach einem geschlossenen Einrichtungs-Ticket freigeschaltet werden (ticketFreischaltung: true, + * nur genau true), in Registerreihenfolge. config.features wird bei jedem Aufruf gelesen. + */ +export function ticketFreischaltungKeys() { + return featureKeys().filter((key) => config.features[key].ticketFreischaltung === true) +} + /** * Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }. * Wer config.features.. als Funktion setzt (Tests), ueberschreibt den Standard. + * freischaltbar(doc) => Promise: darf das Dokument (Zustand 'setup', Ticket geschlossen) jetzt aktiv werden? + * Nur true gibt frei; false und jeder Fehler halten es in 'setup' (siehe activateClosedTickets). */ export function featureHaken(key) { const eintrag = featureConfig(key) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 49108e3..028e814 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -4,7 +4,7 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' -import { anleitungsLink, featureConfig, featureKeys, ticketTexte } from './featureRegistry.js' +import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js' import { zustimmungFuerSitzung } from './featureConsents.js' import { laeuft } from './aboStatus.js' import { createFeatureTicket } from './ticketAdmin.js' @@ -472,9 +472,50 @@ export async function adminActivate(customerId, projectId, key, { by, adminNote return activate(doc.$id, { by, adminNote }) } -/** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */ +/** + * Haken freischaltbar(doc) der Funktion, falls das Register einen hat (nur true gibt frei). Liefert null (frei) oder den Grund, + * warum das Dokument in 'setup' bleibt: false => Voraussetzungen offen, Fehler (z. B. Abo oder Einstellung nicht lesbar) => + * Pruefung nicht moeglich; der Fehler wird geloggt, nie geworfen (ein Dokument darf die uebrigen nicht aufhalten). + */ +async function freischaltHindernis(doc) { + const { freischaltbar } = featureHaken(doc.featureKey) + if (typeof freischaltbar !== 'function') return null + try { + return (await freischaltbar(doc)) === true ? null : 'Voraussetzungen noch nicht erfüllt' + } catch (err) { + console.error(`[funktionen] Freischaltung ${doc.featureKey} (${doc.$id}) nicht prüfbar:`, err.message) + return 'Prüfung nicht möglich' + } +} + +/** + * Eine blockierte Freischaltung einmal in adminNote vermerken (" System: Freischaltung wartet: ", neuester Eintrag + * vorn). Steht derselbe Eintrag schon vorn, bleibt alles wie es ist: der 5-Minuten-Job schreibt nicht alle 5 Minuten und sendet + * keine Mail (der Hinweis kein_abo kam mit der Zahlung, siehe onFeaturePaid). Ein Schreibfehler wird nur geloggt. + * Aufruf unter der Sperre des Dokuments mit dem frisch gelesenen Dokument. + */ +async function vermerkeWartet(doc, grund, now) { + const eintrag = `System: Freischaltung wartet: ${grund}` + const neuester = String(doc.adminNote || '').split(' | ')[0] + if (neuester.endsWith(eintrag)) return + console.warn(`[funktionen] Freischaltung ${doc.featureKey} (${doc.$id}) wartet: ${grund}`) + try { + await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: notizVorn(doc.adminNote, `${now.toISOString()} ${eintrag}`) }) + } catch (err) { + console.error(`[funktionen] Wartenotiz für ${doc.$id} nicht gespeichert:`, err.message) + } +} + +/** + * Alle 'setup'-Funktionen (optional nur eines Kunden) mit ticketFreischaltung im Register (ticketFreischaltungKeys), deren Ticket + * 'Closed' ist, werden 'active' - sofern der Haken freischaltbar der Funktion (Termine: Einstellung gespeichert und Wartungs-Abo + * laeuft) es erlaubt. Das prueft der Job unter der Sperre feature-activate: nach dem frischen Lesen, damit ein Abo, das zwischen + * Listenlesen und Sperre endet, nicht mehr freischaltet. Gesperrte Dokumente bleiben 'setup' (Notiz und Log, keine Mail). + */ export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) { - const queries = [Query.equal('featureKey', 'news'), Query.equal('state', 'setup'), Query.limit(1000)] + const keys = ticketFreischaltungKeys() + if (keys.length === 0) return [] + const queries = [Query.equal('featureKey', keys), Query.equal('state', 'setup'), Query.limit(1000)] if (customerId) queries.push(Query.equal('customerId', customerId)) const pending = await listDocuments(config.collections.portalFeatures, queries) const activated = [] @@ -493,6 +534,11 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul const updated = await withLocks([`feature-activate:${doc.$id}`], async () => { const fresh = await getDocument(config.collections.portalFeatures, doc.$id) if (fresh.state !== 'setup') return null + const grund = await freischaltHindernis(fresh) + if (grund) { + await vermerkeWartet(fresh, grund, now) + return null + } return updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('active'), activatedAt: now.toISOString(), diff --git a/test/featureFreischaltung.test.js b/test/featureFreischaltung.test.js new file mode 100644 index 0000000..b5d5eb5 --- /dev/null +++ b/test/featureFreischaltung.test.js @@ -0,0 +1,440 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Freischaltung ueber das Register (Task B11): activateClosedTickets liest die Schluessel mit ticketFreischaltung aus +// config.features und fragt je Dokument unter der Sperre (nach dem frischen Lesen) den Haken freischaltbar. Termine: +// Einstellung vorhanden und laufendes Wartungs-Abo (Spec 4.5). Blockiert: das Dokument bleibt 'setup', EIN adminNote-Eintrag, +// keine Mail. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + termineConfig: schemaKeys('termineConfig'), + }, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') + +const mails = mailSink() +setMailTransportForTests(mails) +after(() => setMailTransportForTests(null)) + +const BASE = 'https://project.webklar.com' +const JETZT = new Date('2026-10-05T08:00:00.000Z') +const NOTIZ = /^\d{4}-\d\d-\d\dT[\d:.]+Z System: Freischaltung wartet: \S/ +const TERMINE = config.features.termine +const NEWS = config.features.news + +// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo +const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) +const wartung2 = (patch = {}) => + aw.seed('subscriptions', subscriptionDoc('sub_h2', { ...wartungDoc, customerId: 'c2', email: 'k2@example.invalid', stripeCustomerId: 'cus_c2', ...patch })) + +let nr = 0 +/** + * Dokument im Zustand 'setup' samt Projekt und Ticket (Standard: Ticket geschlossen). Liefert die ID des Dokuments. + * Termine: dazu optional die gespeicherte Einstellung (einstellung: true). + */ +function einrichtung(key, { customerId = 'c1', ticket = 'Closed', einstellung = false, ...over } = {}) { + nr += 1 + const id = `f_${key}_${nr}` + const projectId = `p_${key}_${nr}` + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}` }) + aw.seed('workorders', { $id: `t_${id}`, woid: `WO-${nr}`, status: ticket, topic: 'x' }) + aw.seed('portalFeatures', { + $id: id, + customerId, + projectId, + featureKey: key, + state: 'setup', + enabled: false, + siteKey: `siteke${String(nr).padStart(10, 'a')}`.slice(0, 16), + ticketId: `t_${id}`, + purchaseStatus: 'paid', + unlockedByPurchase: true, + ...over, + }) + if (einstellung) speichere(id, customerId, projectId) + return id +} + +function speichere(featureId, customerId = 'c1', projectId = 'p') { + aw.seed('termineConfig', { $id: featureId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: 'Salon' } }), version: 1, updatedAt: JETZT.toISOString(), updatedBy: 'test' }) +} + +const feld = (id, name) => aw.docs('portalFeatures').get(id)[name] +const zustand = (id) => feld(id, 'state') +const wartetEintraege = (id) => String(feld(id, 'adminNote') || '').split(' | ').filter((e) => e.includes('Freischaltung wartet')) +const k16 = (subject) => mails.sent.filter((m) => m.subject === subject) + +const STUMM = (t) => { + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'warn', () => {}) +} + +beforeEach(async () => { + for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects']) { + for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + } + seedFixtures(aw) + wartung() + mails.sent.length = 0 + config.termine.mode = 'pilot' + config.termine.pilotEmails = ['*'] +}) + +afterEach(() => { + delete TERMINE.freischaltbar + delete NEWS.freischaltbar + NEWS.ticketFreischaltung = true +}) + +// ---------------------------------------------------------------------------------------- Register --- +test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => { + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine']) + NEWS.ticketFreischaltung = false + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine']) + NEWS.ticketFreischaltung = 'ja' + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine'], 'nur true zaehlt') + NEWS.ticketFreischaltung = true + TERMINE.ticketFreischaltung = false + try { + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news']) + } finally { + TERMINE.ticketFreischaltung = true + } +}) + +test('Standard-Haken: termine hat freischaltbar, news nicht', () => { + assert.equal(typeof reg.featureHaken('termine').freischaltbar, 'function') + assert.equal(reg.featureHaken('news').freischaltbar, undefined) +}) + +// ------------------------------------------------------------------------------------------- News --- +test('News unveraendert: Ticket geschlossen => active, K16 der News-Pinnwand, ohne Abo, Einstellung oder Notiz', async () => { + const id = einrichtung('news') + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(feld(id, 'enabled'), true) + assert.equal(feld(id, 'activatedAt'), JETZT.toISOString()) + assert.ok(!feld(id, 'adminNote'), 'keine Notiz') + assert.equal(k16('Deine News-Pinnwand ist bereit').length, 1) +}) + +test('News: offenes Ticket bleibt setup', async () => { + const id = einrichtung('news', { ticket: 'Open' }) + assert.deepEqual(await sf.activateClosedTickets({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') +}) + +// ------------------------------------------------------------------------------ Termine: Freigabe --- +test('Termine: Ticket geschlossen + Einstellung + Abo => active, K16 "Dein Terminplaner ist bereit" mit Knopf "Termine oeffnen"', async () => { + const id = einrichtung('termine', { einstellung: true }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(feld(id, 'enabled'), true) + assert.equal(feld(id, 'activatedAt'), JETZT.toISOString()) + assert.ok(!feld(id, 'adminNote'), 'frei => keine Wartenotiz') + + const gesendet = k16('Dein Terminplaner ist bereit') + assert.equal(gesendet.length, 1, mails.sent.map((m) => m.subject).join(' / ')) + assert.equal(mails.sent.length, 1, 'genau eine Mail') + const [mail] = gesendet + assert.deepEqual(mail.to.map((t) => t.address), ['k@example.invalid']) + assert.match(mail.text, /Hallo Kunde Eins,\nder Terminplaner ist auf Deiner Website eingebaut und freigeschaltet\./) + assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Projekt \d+/) + assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#termine"/) + assert.doesNotMatch(mail.text, /News|Beitr/) + // zweiter Lauf: nichts mehr offen + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(mails.sent.length, 1) +}) + +test('Termine: Abo past_due zaehlt als laufend (wie laeuft)', async () => { + wartung({ status: 'past_due' }) + const id = einrichtung('termine', { einstellung: true }) + assert.equal((await sf.activateClosedTickets({ now: JETZT })).length, 1) + assert.equal(zustand(id), 'active') +}) + +test('Termine: kein Abo => bleibt setup, ein adminNote-Eintrag, keine Mail; Abo kommt zurueck => naechster Lauf aktiviert und sendet K16', async (t) => { + STUMM(t) + const id = einrichtung('termine', { einstellung: true }) + // c1 hat nur ein Hosting-Abo: laeuft false + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) + + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(feld(id, 'enabled'), false) + assert.equal(wartetEintraege(id).length, 1) + assert.match(feld(id, 'adminNote'), NOTIZ) + assert.equal(mails.sent.length, 0, 'keine Mail alle 5 Minuten') + + // zweiter und dritter Lauf: weder ein zweiter Eintrag noch eine Mail + const notiz = feld(id, 'adminNote') + assert.deepEqual(await sf.runActivation({ now: new Date(JETZT.getTime() + 5 * 60000) }), []) + assert.deepEqual(await sf.runActivation({ now: new Date(JETZT.getTime() + 10 * 60000) }), []) + assert.equal(feld(id, 'adminNote'), notiz, 'Notiz unveraendert') + assert.equal(wartetEintraege(id).length, 1) + assert.equal(mails.sent.length, 0) + + // Abo kommt zurueck + wartung() + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(k16('Dein Terminplaner ist bereit').length, 1) + assert.equal(mails.sent.length, 1) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(mails.sent.length, 1) +}) + +test('Termine: Abo beendet (canceled) => bleibt setup', async (t) => { + STUMM(t) + wartung({ status: 'canceled' }) + const id = einrichtung('termine', { einstellung: true }) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.match(feld(id, 'adminNote'), /Freischaltung wartet/) + assert.equal(mails.sent.length, 0) +}) + +test('Termine: keine Einstellung => bleibt setup (mit Notiz), mit gespeicherter Einstellung aktiviert der naechste Lauf', async (t) => { + STUMM(t) + const id = einrichtung('termine') + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(wartetEintraege(id).length, 1) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(wartetEintraege(id).length, 1, 'kein zweiter Eintrag') + assert.equal(mails.sent.length, 0) + + speichere(id, 'c1', feld(id, 'projectId')) + assert.equal((await sf.runActivation({ now: JETZT })).length, 1) + assert.equal(zustand(id), 'active') + assert.equal(k16('Dein Terminplaner ist bereit').length, 1) +}) + +test('Termine: aendert sich der Grund, kommt ein neuer Eintrag; Notiz bleibt auf 300 Zeichen und aeltere Eintraege bleiben stehen', async (t) => { + STUMM(t) + const id = einrichtung('termine', { adminNote: '2026-10-01T10:00:00.000Z Andrej: activate – Kulanz' }) + await sf.activateClosedTickets({ now: JETZT }) + assert.equal(wartetEintraege(id).length, 1) + assert.match(feld(id, 'adminNote'), /\| 2026-10-01T10:00:00\.000Z Andrej: activate – Kulanz$/, 'aelterer Eintrag bleibt') + assert.ok(feld(id, 'adminNote').startsWith(JETZT.toISOString()), 'neuester Eintrag vorn, mit der Zeit des Laufs') + assert.ok(feld(id, 'adminNote').length <= 300) +}) + +// ------------------------------------------------------------------------------- Termine: Fehler --- +test('Abo-Lesefehler => bleibt setup, runActivation wirft nicht, andere Dokumente werden trotzdem bearbeitet', async (t) => { + STUMM(t) + const termine = einrichtung('termine', { einstellung: true }) + const news = einrichtung('news') + aw.failNext({ collection: 'subscriptions', status: 500 }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [news]) + assert.equal(zustand(termine), 'setup', 'nie freischalten, wenn das Abo nicht lesbar war') + assert.equal(zustand(news), 'active') + assert.match(feld(termine, 'adminNote'), /Freischaltung wartet/) + assert.equal(k16('Deine News-Pinnwand ist bereit').length, 1) + assert.equal(k16('Dein Terminplaner ist bereit').length, 0) + const geloggt = console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') + assert.match(geloggt, /Freischaltung termine \(f_termine_\d+\) nicht prüfbar/, 'Fehler wird geloggt') + + // der naechste Lauf liest wieder und aktiviert + const spaeter = await sf.runActivation({ now: JETZT }) + assert.deepEqual(spaeter.map((d) => d.$id), [termine]) + assert.equal(zustand(termine), 'active') +}) + +test('Lesefehler bei der Einstellung (termineConfig) => bleibt setup, kein Wurf', async (t) => { + STUMM(t) + const id = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'termineConfig', status: 500 }) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(mails.sent.length, 0) +}) + +test('Haken wirft => bleibt setup (Log, Notiz), die uebrigen Dokumente werden bearbeitet', async (t) => { + STUMM(t) + TERMINE.freischaltbar = async () => { + throw new Error('Pruefung kaputt') + } + const termine = einrichtung('termine', { einstellung: true }) + const news = einrichtung('news') + assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [news]) + assert.equal(zustand(termine), 'setup') + assert.match(feld(termine, 'adminNote'), /Freischaltung wartet/) + assert.match(console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n'), /Pruefung kaputt/) +}) + +test('Haken meldet etwas anderes als true (false, undefined, Text) => bleibt setup (fail-closed)', async (t) => { + STUMM(t) + const id = einrichtung('termine', { einstellung: true }) + for (const antwort of [false, undefined, null, 'ja', 1]) { + TERMINE.freischaltbar = async () => antwort + assert.deepEqual(await sf.activateClosedTickets({ now: JETZT }), [], String(antwort)) + assert.equal(zustand(id), 'setup', String(antwort)) + } +}) + +test('Schreiben der Wartenotiz scheitert => kein Wurf, das naechste Dokument wird trotzdem bearbeitet', async (t) => { + STUMM(t) + const termine = einrichtung('termine') + const news = einrichtung('news') + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 }) + const done = await sf.activateClosedTickets({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [news]) + assert.equal(zustand(termine), 'setup') + assert.equal(zustand(news), 'active') +}) + +// ------------------------------------------------------------------------ Sperre und frisches Lesen --- +test('Abo endet zwischen Listenlesen und Sperre (der Haken meldet beim frischen Lesen false) => bleibt setup', async (t) => { + STUMM(t) + const id = einrichtung('termine', { einstellung: true }) + let lauf + await withLocks([`feature-activate:${id}`], async () => { + // der Lauf liest die Liste und das Ticket und wartet dann auf die Sperre + lauf = sf.activateClosedTickets({ now: JETZT }) + for (let i = 0; i < 100 && aw.listCalls('portalFeatures').length === 0; i += 1) await new Promise((r) => setTimeout(r, 5)) + await new Promise((r) => setTimeout(r, 50)) + wartung({ status: 'canceled' }) + }) + assert.deepEqual(await lauf, []) + assert.equal(zustand(id), 'setup') + assert.match(feld(id, 'adminNote'), /Freischaltung wartet/) +}) + +test('Der Haken bekommt das unter der Sperre frisch gelesene Dokument; ein zwischendurch aktiviertes wird nicht noch einmal angefasst', async () => { + const gesehen = [] + TERMINE.freischaltbar = async (doc) => { + gesehen.push({ state: doc.state, marker: doc.adminNote }) + return true + } + const frisch = einrichtung('termine') + const schonAktiv = einrichtung('termine') + let lauf + await withLocks([`feature-activate:${frisch}`, `feature-activate:${schonAktiv}`], async () => { + lauf = sf.activateClosedTickets({ now: JETZT }) + for (let i = 0; i < 100 && aw.listCalls('portalFeatures').length === 0; i += 1) await new Promise((r) => setTimeout(r, 5)) + await new Promise((r) => setTimeout(r, 50)) + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(frisch), adminNote: 'frisch gelesen' }) + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(schonAktiv), state: 'active', enabled: true }) + }) + const done = await lauf + assert.deepEqual(done.map((d) => d.$id), [frisch]) + assert.deepEqual(gesehen, [{ state: 'setup', marker: 'frisch gelesen' }], 'der Haken sieht nur das frische, noch offene Dokument') +}) + +// ------------------------------------------------------------------------------- Tickets, Kunden --- +test('Ticket Cancelled (oder Open) => bleibt setup, der Haken wird gar nicht gefragt', async () => { + let gefragt = 0 + TERMINE.freischaltbar = async () => { + gefragt += 1 + return true + } + const a = einrichtung('termine', { einstellung: true, ticket: 'Cancelled' }) + const b = einrichtung('termine', { einstellung: true, ticket: 'Open' }) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(a), 'setup') + assert.equal(zustand(b), 'setup') + assert.equal(gefragt, 0) + assert.equal(mails.sent.length, 0) +}) + +test('customerId-Filter: nur Dokumente dieses Kunden', async () => { + wartung2() + const eins = einrichtung('termine', { einstellung: true }) + const zwei = einrichtung('termine', { einstellung: true, customerId: 'c2' }) + const done = await sf.runActivation({ now: JETZT, customerId: 'c1' }) + assert.deepEqual(done.map((d) => d.$id), [eins]) + assert.equal(zustand(eins), 'active') + assert.equal(zustand(zwei), 'setup', 'anderer Kunde bleibt liegen') + assert.deepEqual(mails.sent.map((m) => m.to[0].address), ['k@example.invalid']) + // ohne Filter kommt der andere Kunde dran + assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [zwei]) + assert.deepEqual(mails.sent.map((m) => m.to[0].address), ['k@example.invalid', 'k2@example.invalid']) +}) + +// -------------------------------------------------------------------------------- Haken je Funktion --- +test('Der Haken gilt nur fuer seine Funktion: ein News-Dokument im selben Lauf wird aktiviert', async (t) => { + STUMM(t) + const gefragt = [] + TERMINE.freischaltbar = async (doc) => { + gefragt.push(doc.featureKey) + return false + } + const termine = einrichtung('termine', { einstellung: true }) + const news = einrichtung('news') + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [news]) + assert.equal(zustand(termine), 'setup') + assert.equal(zustand(news), 'active') + assert.deepEqual(gefragt, ['termine'], 'der Termine-Haken wird nie fuer News gefragt') +}) + +test('Ein Haken im Register ueberschreibt den Standard: true gibt frei, auch ohne Abo und Einstellung', async () => { + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) + TERMINE.freischaltbar = async () => true + const id = einrichtung('termine') + assert.equal((await sf.runActivation({ now: JETZT })).length, 1) + assert.equal(zustand(id), 'active') + assert.equal(k16('Dein Terminplaner ist bereit').length, 1) +}) + +test('Funktionen ohne ticketFreischaltung im Register werden nicht geoeffnet', async () => { + NEWS.ticketFreischaltung = false + const news = einrichtung('news') + assert.deepEqual(await sf.activateClosedTickets({ now: JETZT }), []) + assert.equal(zustand(news), 'setup') +}) + +test('Dokument einer anderen Funktion mit setup (analyse) bleibt unberuehrt', async () => { + const analyse = einrichtung('analyse') + const unbekannt = einrichtung('gibtsnicht') + const termine = einrichtung('termine', { einstellung: true }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [termine]) + for (const id of [analyse, unbekannt]) { + assert.equal(zustand(id), 'setup', id) + assert.equal(feld(id, 'enabled'), false, id) + assert.ok(!feld(id, 'adminNote'), id) + } + assert.equal(mails.sent.length, 1) +}) + +// --------------------------------------------------------------------------------- parallele Laeufe --- +test('Parallele Laeufe (runActivation() und runActivation({ customerId })) => ein Terminplaner-K16', async () => { + const id = einrichtung('termine', { einstellung: true }) + const [a, b] = await Promise.all([sf.runActivation({ now: JETZT }), sf.runActivation({ now: JETZT, customerId: 'c1' })]) + assert.equal(a.length + b.length, 1) + assert.equal(zustand(id), 'active') + assert.equal(k16('Dein Terminplaner ist bereit').length, 1) + assert.equal(mails.sent.length, 1) +}) + +test('Parallele Laeufe ohne Abo => hoechstens eine Wartenotiz, keine Mail', async (t) => { + STUMM(t) + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) + const id = einrichtung('termine', { einstellung: true }) + await Promise.all([sf.runActivation({ now: JETZT }), sf.runActivation({ now: JETZT, customerId: 'c1' }), sf.runActivation({ now: JETZT })]) + assert.equal(zustand(id), 'setup') + assert.equal(wartetEintraege(id).length, 1) + assert.equal(mails.sent.length, 0) +}) diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index 9d8f17b..cc666f1 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -232,15 +232,37 @@ test('activate gilt fuer jede Funktion gleich: News verliert disabledAt ebenfall 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') +test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt sich unveraendert (TP2)', async () => { const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { config } = await import('../server/config.js') - await deleteDocument(config.collections.portalFeatures, 'f_setup_termine') + const seedSetup = (id, featureKey, siteKey, ticketId) => { + aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: 'pt2', featureKey, state: 'setup', enabled: false, siteKey, ticketId }) + aw.seed('workorders', { $id: ticketId, woid: 'WO-9', status: 'Closed' }) + } + seedSetup('f_setup_termine', 'termine', 'bbbbbbbbbbbbbbbb', 't_closed_termine') + seedSetup('f_setup_news', 'news', 'cccccccccccccccc', 't_closed_news') + seedSetup('f_setup_analyse', 'analyse', 'dddddddddddddddd', 't_closed_analyse') + const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse'] + const reg = await import('../server/services/featureRegistry.js') + try { + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine']) + // News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung + const done = await sf.activateClosedTickets({ customerId: 'c1' }) + assert.deepEqual(done.map((d) => d.featureKey), ['news']) + assert.equal(aw.docs('portalFeatures').get('f_setup_news').state, 'active') + assert.equal(aw.docs('portalFeatures').get('f_setup_termine').state, 'setup') + assert.match(aw.docs('portalFeatures').get('f_setup_termine').adminNote, /Freischaltung wartet/) + assert.equal(aw.docs('portalFeatures').get('f_setup_analyse').state, 'setup') + assert.ok(!aw.docs('portalFeatures').get('f_setup_analyse').adminNote) + // Sind die Voraussetzungen der Funktion erfuellt (Haken), oeffnet derselbe Lauf auch Termine - Analyse bleibt zu + config.features.termine.freischaltbar = async () => true + const spaeter = await sf.activateClosedTickets({ customerId: 'c1' }) + assert.deepEqual(spaeter.map((d) => d.featureKey), ['termine']) + assert.equal(aw.docs('portalFeatures').get('f_setup_analyse').state, 'setup') + } finally { + delete config.features.termine.freischaltbar + for (const id of ids) await deleteDocument(config.collections.portalFeatures, id) + } }) test('disableCustomerFeatures: schaltet nur die genannte Funktion des Kunden ab, mit Notiz', async () => {