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:<id>
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: <Grund>", keine Mail. News bleibt unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:35:57 +00:00
1 parent f4754f573a
commit f57dad73ca
4 files changed
+541 -10

No files matched your search

+23
View File
@@ -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.<key>.<haken> als Funktion setzt (Tests), ueberschreibt den Standard.
* freischaltbar(doc) => Promise<boolean>: 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)
+49 -3
View File
@@ -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 ("<Zeit> System: Freischaltung wartet: <Grund>", 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:<id> 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(),
+440
View File
@@ -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)
})
+29 -7
View File
@@ -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 () => {