From 0432b7e01d90bd55d393ff020814b5f7cd76766b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:13:20 +0000 Subject: [PATCH] =?UTF-8?q?Rechtstexte:=20Hochladen-Skript=20f=C3=BCr=20WO?= =?UTF-8?q?MS=20Rechtliches=20(ein=20Dokument=20je=20Aufruf,=20nur=20mit?= =?UTF-8?q?=20Freigabe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/rechtliches-hochladen.mjs | 325 ++++++++++++++++++++++++++++++ test/rechtlichesHochladen.test.js | 93 +++++++++ 2 files changed, 418 insertions(+) create mode 100644 scripts/rechtliches-hochladen.mjs create mode 100644 test/rechtlichesHochladen.test.js diff --git a/scripts/rechtliches-hochladen.mjs b/scripts/rechtliches-hochladen.mjs new file mode 100644 index 0000000..523ef75 --- /dev/null +++ b/scripts/rechtliches-hochladen.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +// Lädt die Rechtstexte der Fassung 6 (Kundenansicht neu) nach der Freigabe in WOMS „Rechtliches“ hoch (Bucket legal-files, Collection +// legalVersions), wie es die WOMS-Oberfläche tut: neue Fassung "current", die bisherige gültige wird "superseded" mit validTo. Ein Dokument +// je Aufruf, nur mit --freigabe=JJJJ-MM-TT; schon hochgeladene Fassungen (gleiche source) werden übersprungen. +// +// node scripts/rechtliches-hochladen.mjs --probe nur lesen: Schlüssel, Bucket, Versionen und Karten; jede fehlende Karte ist ein Stopp +// node scripts/rechtliches-hochladen.mjs --karten-anlegen legt die fehlenden Karten in legalDocuments an (vorhandene nie), einmal vor dem ersten Hochladen +// node scripts/rechtliches-hochladen.mjs --freigabe=JJJJ-MM-TT +// lädt genau ein Dokument hoch (validFrom = Freigabedatum); ohne Karte Abbruch vor dem Schreiben +// node scripts/rechtliches-hochladen.mjs --pruefen nur lesen: Nachkontrolle, Exit 0 nur, wenn alle elf Dokumente eine aktuelle Version +// mit passender source haben +// +// Schlüssel: kurzlebiger Appwrite-Schlüssel mit files.read/files.write/documents.read/documents.write in /root/.appwrite-rechtliches.key (chmod 600). +// Der Import dieser Datei liest weder den Schlüssel noch nutzt er das Netz; beides geschieht nur in main (deps.schluessel, deps.fetch). +// Dieses Skript wird im Paket P8 nur geschrieben und getestet (mit Nachbau-deps), nie ausgeführt. +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.js' + +const ENDPOINT = 'https://ticket.webklar.com/v1' +const PROJECT = '6a1058610003c5a13a05' +const DB = 'woms-database' +const BUCKET = 'legal-files' +const KEY_DATEI = '/root/.appwrite-rechtliches.key' +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const LIVE = 'https://project.webklar.com/' + +const USAGE = 'Aufruf: node scripts/rechtliches-hochladen.mjs --probe | --karten-anlegen | --pruefen | --freigabe=JJJJ-MM-TT (genau ein Dokument je Aufruf)' + +// ---------------------------------------------------------------------------------------------------------- Dokumente + +const NAMEN = { + news: 'News-Pinnwand', + termine: 'Terminplaner', + shop: 'Webshop', + unterkunft: 'Buchungskalender', + zeiten: 'Öffnungszeiten live', + analyse: 'Website-Analyse', +} + +const ABO = Object.freeze({ + documentKey: 'abo-bedingungen', + dateien: Object.freeze(['public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', 'public/neu/rechtliches/abo-bedingungen.html']), + titel: 'Abo-Bedingungen', + quelle: 'public/neu/rechtliches/abo-bedingungen.html', +}) + +/** + * Die elf Dokumente der Fassung 6 in der Reihenfolge der Tabelle: die Abo-Bedingungen, dann je Funktion Leistungsbeschreibung und AVV wie in + * FUNKTIONEN_UNTERLAGEN_V6 (documentKey = Slug der Datei ohne Fassung). dateien = [PDF, HTML]; quelle = der HTML-Pfad, den source beim Hochladen + * trägt (verschiebt P10 die Abo-Seite, ändert sich dateien, quelle bleibt). + */ +export const DOKUMENTE_V6 = Object.freeze([ + ABO, + ...Object.entries(FUNKTIONEN_UNTERLAGEN_V6).flatMap(([key, unterlagen]) => + [['lb', unterlagen.lb, `Leistungsbeschreibung ${NAMEN[key]}`], ['avv', unterlagen.avv, `AVV ${NAMEN[key]}`]] + .filter(([, dok]) => dok) + .map(([, dok, titel]) => { + const html = `public${dok.url}` + return Object.freeze({ + documentKey: path.basename(dok.url, `-${dok.version}.html`), + dateien: Object.freeze([`public/${dok.pdfFile}`, html]), + titel, + quelle: html, + }) + }), + ), +]) + +/** Eintrag der Tabelle zu einem documentKey; sonst Error('Unbekanntes Dokument: …'). */ +export function dokumentFuer(documentKey) { + const d = DOKUMENTE_V6.find((x) => x.documentKey === documentKey) + if (!d) throw new Error(`Unbekanntes Dokument: ${String(documentKey).slice(0, 80)}`) + return d +} + +// ---------------------------------------------------------------------------------------------------------- reine Funktionen + +const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' } + +const istKalendertag = (iso) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false + const d = new Date(`${iso}T12:00:00Z`) + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso +} + +/** + * Kommandozeile prüfen: ein Modus allein (--probe, --karten-anlegen, --pruefen) oder genau ein documentKey mit --freigabe=JJJJ-MM-TT. + * @returns {{ modus: 'probe'|'karten'|'pruefen'|'hochladen', documentKey: string|null, freigabe: string|null }} + */ +export function parseArgs(argv) { + const optionen = argv.filter((a) => a.startsWith('--')) + const dokumente = argv.filter((a) => !a.startsWith('--')) + const modi = optionen.filter((o) => Object.hasOwn(MODI, o)) + const freigaben = optionen.filter((o) => o.startsWith('--freigabe=')) + const fremde = optionen.filter((o) => !Object.hasOwn(MODI, o) && !o.startsWith('--freigabe=')) + if (fremde.length > 0) throw new Error(`${USAGE}\nUnbekannte Option: ${fremde[0].slice(0, 40)}`) + if (modi.length > 0) { + if (modi.length > 1 || dokumente.length > 0 || freigaben.length > 0) throw new Error(`${USAGE}\n${modi[0]} steht allein.`) + return { modus: MODI[modi[0]], documentKey: null, freigabe: null } + } + if (dokumente.length !== 1) throw new Error(`${USAGE}\nGenau ein Dokument je Aufruf.`) + if (freigaben.length !== 1) throw new Error('Freigabe fehlt: --freigabe=JJJJ-MM-TT ist beim Hochladen Pflicht.') + const freigabe = freigaben[0].slice('--freigabe='.length) + if (!istKalendertag(freigabe)) throw new Error(`Freigabe ungültig (erwartet JJJJ-MM-TT, ein echter Kalendertag): ${freigabe.slice(0, 20)}`) + dokumentFuer(dokumente[0]) + return { modus: 'hochladen', documentKey: dokumente[0], freigabe } +} + +/** Der Tag vor einem Datum (JJJJ-MM-TT). */ +export function tagVorher(iso) { + const d = new Date(`${iso}T12:00:00Z`) + d.setUTCDate(d.getUTCDate() - 1) + return d.toISOString().slice(0, 10) +} + +/** + * Plan für eine neue Version eines Dokuments: schon = gleiche source ist hochgeladen; nummer = vN (Zahl der Versionen mit ^v\d+$ plus eins); + * aktuellId = die bisherige gültige Version (jüngstes validFrom), die abgelöst wird; validToAltes = Tag vor validFrom, bei gleichem oder + * früherem validFrom = validFrom (wie die Vorlage des Webshop-Livegangs). + */ +export function planVersion({ alle, source, validFrom }) { + const aktuell = alle.filter((v) => v.status === 'current').sort((a, b) => b.validFrom.localeCompare(a.validFrom))[0] + return { + schon: alle.some((v) => v.source === source), + nummer: `v${alle.filter((v) => /^v\d+$/.test(v.version)).length + 1}`, + aktuellId: aktuell ? aktuell.$id : null, + validToAltes: aktuell ? (validFrom > aktuell.validFrom ? tagVorher(validFrom) : validFrom) : null, + } +} + +/** Dokument-ID einer Karte: der Schlüssel, wenn er höchstens 36 Zeichen hat, sonst 27 Zeichen davon, "-" und 8 Hexzeichen von sha256(key). */ +export function kartenId(key) { + if (key.length <= 36) return key + return `${key.slice(0, 27)}-${createHash('sha256').update(key).digest('hex').slice(0, 8)}` +} + +/** Die fehlenden Karten in Tabellenreihenfolge: [{ id, daten: { key, title, liveUrl, sort } }], sort ab hoechsteSort + 1. */ +export function kartenPlan({ vorhandeneKeys, hoechsteSort }) { + const vorhanden = new Set(vorhandeneKeys) + let sort = hoechsteSort + return DOKUMENTE_V6.filter((d) => !vorhanden.has(d.documentKey)).map((d) => { + sort += 1 + return { id: kartenId(d.documentKey), daten: { key: d.documentKey, title: d.titel, liveUrl: `${LIVE}${d.dateien[1].replace(/^public\//, '')}`, sort } } + }) +} + +const maskiere = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** Nachkontrolle: { [documentKey]: Version[] } -> { fehlt: documentKey[] }; ein Dokument zählt mit einer Version status current und source kundenbereich@:. */ +export function pruefeHochgeladen(versionenJe) { + const fehlt = DOKUMENTE_V6.filter((d) => { + const muster = new RegExp(`^kundenbereich@[0-9a-f]{7,40}:${maskiere(d.quelle)}$`) + return !(versionenJe[d.documentKey] ?? []).some((v) => v.status === 'current' && muster.test(v.source ?? '')) + }).map((d) => d.documentKey) + return { fehlt } +} + +// ---------------------------------------------------------------------------------------------------------- Zugriff auf WOMS + +const q = (o) => encodeURIComponent(JSON.stringify(o)) +const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20) + +/** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */ +function client(fetchFn, schluessel) { + const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel } + return async function api(method, pfad, body) { + const formular = typeof FormData !== 'undefined' && body instanceof FormData + const res = await fetchFn(`${ENDPOINT}${pfad}`, { + method, + headers: formular ? kopf : { ...kopf, 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : formular ? body : JSON.stringify(body), + }) + const text = await res.text() + if (!res.ok) throw Object.assign(new Error(`${method} ${pfad}: ${res.status} ${text.slice(0, 200)}`), { status: res.status }) + return text ? JSON.parse(text) : null + } +} + +async function alleDokumente(api, sammlung) { + const aus = [] + for (let offset = 0; ; offset += 100) { + const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`) + aus.push(...r.documents) + if (r.documents.length < 100) return aus + } +} + +async function versionen(api, documentKey) { + const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`) + return r.documents +} + +const ddmmjjjj = (iso) => iso.split('-').reverse().join('.') + +// ---------------------------------------------------------------------------------------------------------- Aufruf + +/** + * Einstieg für alle Aufrufe. deps = { fetch, schluessel, log, git, dateien } mit den Standards globales fetch, Datei /root/.appwrite-rechtliches.key, + * console.log, git im Projekt und das Dateisystem; der einzige Weg zu Schlüssel und Netz. + * @returns {Promise} Exit-Code (0 oder 1) + */ +export async function main(argv, deps = {}) { + const log = deps.log ?? console.log + let args + try { + args = parseArgs(argv) + } catch (err) { + log(err.message) + return 1 + } + try { + const fetchFn = deps.fetch ?? globalThis.fetch + const api = client(fetchFn, (deps.schluessel ?? (() => readFileSync(KEY_DATEI, 'utf8').trim()))()) + if (args.modus === 'probe') return await probe(api, log) + if (args.modus === 'karten') return await kartenAnlegen(api, log) + if (args.modus === 'pruefen') return await pruefen(api, log) + return await hochladen(api, log, args, deps) + } catch (err) { + log(`Fehler: ${err.message}`) + return 1 + } +} + +/** --probe: nur lesen. Jede fehlende Karte ist ein Stopp. */ +async function probe(api, log) { + await api('GET', `/storage/buckets/${BUCKET}/files?queries[]=${q({ method: 'limit', values: [1] })}`) + const karten = new Set((await alleDokumente(api, 'legalDocuments')).map((k) => k.key)) + let stopp = 0 + for (const d of DOKUMENTE_V6) { + log(`${d.documentKey}: ${(await versionen(api, d.documentKey)).map((v) => `${v.version}:${v.status}`).join(' ') || '—'}`) + if (!karten.has(d.documentKey)) { + log(`STOPP: Karte fehlt: ${d.documentKey}`) + stopp += 1 + } + } + if (stopp > 0) return 1 + log('Schlüssel ok (files.read + documents.read), alle Karten vorhanden.') + return 0 +} + +/** --karten-anlegen: legt nur die fehlenden Karten an, vorhandene nie. */ +async function kartenAnlegen(api, log) { + const vorhanden = await alleDokumente(api, 'legalDocuments') + const plan = kartenPlan({ vorhandeneKeys: vorhanden.map((k) => k.key), hoechsteSort: Math.max(0, ...vorhanden.map((k) => Number(k.sort) || 0)) }) + for (const karte of plan) { + await api('POST', `/databases/${DB}/collections/legalDocuments/documents`, { documentId: karte.id, data: karte.daten }) + log(`+ Karte ${karte.daten.key} (ID ${karte.id})`) + } + if (plan.length === 0) log('Alle Karten sind vorhanden.') + return 0 +} + +/** --pruefen: nur lesen, Nachkontrolle nach den Uploads. */ +async function pruefen(api, log) { + const je = {} + for (const d of DOKUMENTE_V6) je[d.documentKey] = await versionen(api, d.documentKey) + const { fehlt } = pruefeHochgeladen(je) + for (const d of DOKUMENTE_V6) log(`${fehlt.includes(d.documentKey) ? 'FEHLT' : 'ok'} ${d.documentKey}`) + return fehlt.length > 0 ? 1 : 0 +} + +/** --freigabe=…: genau ein Dokument hochladen. Als Erstes die Kartenprüfung, dann Git- und Dateiprüfung, erst dann schreiben. */ +async function hochladen(api, log, { documentKey, freigabe }, deps) { + const d = dokumentFuer(documentKey) + const karten = await alleDokumente(api, 'legalDocuments') + if (!karten.some((k) => k.key === documentKey)) { + log(`Karte fehlt: ${documentKey} (zuerst --karten-anlegen)`) + return 1 + } + const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' })) + const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) } + for (const rel of d.dateien) { + if (!dateisystem.existsSync(rel)) { + log(`Datei fehlt: ${rel}`) + return 1 + } + } + if (git(['status', '--porcelain', '--', ...d.dateien]).trim() !== '') { + log(`Abbruch: nicht committeter Stand in ${d.dateien.join(' und ')} (git status --porcelain ist nicht leer)`) + return 1 + } + const commit = git(['rev-parse', '--short', 'HEAD']).trim() + const source = `kundenbereich@${commit}:${d.quelle}` + const alle = await versionen(api, documentKey) + const plan = planVersion({ alle, source, validFrom: freigabe }) + if (plan.schon) { + log(`= ${documentKey}: schon hochgeladen`) + return 0 + } + const fileIds = [] + for (const rel of d.dateien) { + const form = new FormData() + form.append('fileId', neueId()) + form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel)) + fileIds.push((await api('POST', `/storage/buckets/${BUCKET}/files`, form)).$id) + } + await api('POST', `/databases/${DB}/collections/legalVersions/documents`, { + documentId: neueId(), + data: { + documentKey, + version: plan.nummer, + validFrom: freigabe, + validTo: null, + status: 'current', + changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: Kundenansicht neu (Fassung 6)`, + fileIds, + source, + }, + }) + if (plan.aktuellId) { + await api('PATCH', `/databases/${DB}/collections/legalVersions/documents/${plan.aktuellId}`, { data: { status: 'superseded', validTo: plan.validToAltes } }) + } + log(`+ ${documentKey} ${plan.nummer} (${fileIds.length} Dateien)${plan.aktuellId ? ', bisherige Version abgelöst' : ''}`) + return 0 +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + main(process.argv.slice(2)).then((code) => { + process.exitCode = code + }) +} diff --git a/test/rechtlichesHochladen.test.js b/test/rechtlichesHochladen.test.js new file mode 100644 index 0000000..6e0bd57 --- /dev/null +++ b/test/rechtlichesHochladen.test.js @@ -0,0 +1,93 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +// Hochladen der Fassung 6 nach WOMS "Rechtliches" (scripts/rechtliches-hochladen.mjs): die Tests rufen main nur mit Nachbau-deps auf, +// nie mit dem echten Schlüssel oder dem Netz. +const { FUNKTIONEN_UNTERLAGEN_V6, FUNKTIONEN_FASSUNG_V6 } = await import('../server/services/products.js') +const m = await import('../scripts/rechtliches-hochladen.mjs') // der Import selbst darf weder Schlüssel lesen noch das Netz nutzen +test('parseArgs: ein Dokument je Aufruf, Hochladen nur mit --freigabe=Datum, --probe und --pruefen lesen nur, --karten-anlegen legt nur Karten an', () => { + assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', documentKey: null, freigabe: null }) + assert.deepEqual(m.parseArgs(['--karten-anlegen']), { modus: 'karten', documentKey: null, freigabe: null }) + assert.deepEqual(m.parseArgs(['--pruefen']), { modus: 'pruefen', documentKey: null, freigabe: null }) + assert.deepEqual(m.parseArgs(['avv-webshop', '--freigabe=2026-10-20']), { modus: 'hochladen', documentKey: 'avv-webshop', freigabe: '2026-10-20' }) + for (const bad of [[], ['avv-webshop'], ['--freigabe=2026-10-20'], ['avv-webshop', 'avv-terminplaner', '--freigabe=2026-10-20'], ['avv-webshop', '--freigabe=20.10.2026'], ['avv-webshop', '--freigabe=2026-13-40'], ['datenschutz', '--freigabe=2026-10-20'], ['--probe', 'avv-webshop'], ['--probe', '--freigabe=2026-10-20'], ['--karten-anlegen', 'avv-webshop'], ['--karten-anlegen', '--freigabe=2026-10-20'], ['--pruefen', '--probe'], ['--pruefen', 'avv-webshop']]) { + assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe|Dokument/, JSON.stringify(bad)) + } + assert.equal(typeof m.main, 'function') +}) +test('Tabelle: elf Dokumente, Dateien im Vorschaupfad, passend zu FUNKTIONEN_UNTERLAGEN_V6', () => { + assert.deepEqual(m.DOKUMENTE_V6.map((d) => d.documentKey), ['abo-bedingungen', 'leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse']) + assert.deepEqual(m.dokumentFuer('abo-bedingungen').dateien, ['public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', 'public/neu/rechtliches/abo-bedingungen.html']) + const unterlagen = Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean) + assert.deepEqual(m.DOKUMENTE_V6.slice(1).map((d) => d.dateien), unterlagen.map((u) => [`public/${u.pdfFile}`, `public${u.url}`])) + assert.deepEqual(m.DOKUMENTE_V6.map((d) => d.quelle), m.DOKUMENTE_V6.map((d) => d.dateien[1]), 'quelle = HTML-Pfad, der in source steht') + assert.throws(() => m.dokumentFuer('datenschutz'), /Unbekanntes Dokument/) +}) +test('planVersion: neue Fassung als vN, bisherige gültige wird abgelöst, doppelter Upload wird erkannt', () => { + const alle = [{ $id: 'a', version: 'v1', validFrom: '2026-09-30', status: 'current', source: 'x' }, { $id: 'b', version: 'v0', validFrom: '2026-09-25', status: 'superseded', source: 'y' }] + assert.deepEqual(m.planVersion({ alle, source: 's', validFrom: '2026-10-20' }), { schon: false, nummer: 'v3', aktuellId: 'a', validToAltes: '2026-10-19' }) + assert.equal(m.planVersion({ alle, source: 'x', validFrom: '2026-10-20' }).schon, true) + assert.deepEqual(m.planVersion({ alle: [], source: 's', validFrom: '2026-10-20' }), { schon: false, nummer: 'v1', aktuellId: null, validToAltes: null }) + assert.equal(m.planVersion({ alle, source: 's', validFrom: '2026-09-30' }).validToAltes, '2026-09-30', 'gleicher Tag: validTo = validFrom wie in der Vorlage') + assert.equal(m.tagVorher('2026-03-01'), '2026-02-28') +}) +// Nachbau des WOMS-Backends für main(): Karten als Dokumente { $id, key, sort }, Versionen und Dateien leer; zählt alle Aufrufe +function backend(karten) { + const aufrufe = [], log = [], antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) }) + const fetch = async (url, opt = {}) => { + const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, ''); aufrufe.push(`${methode} ${pfad}`) + if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten }) + if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: 0, documents: [] }) + if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] }) + if (methode === 'POST' && pfad.endsWith('/collections/legalDocuments/documents')) { const b = JSON.parse(opt.body); karten.push({ $id: b.documentId, ...b.data }); return antwort({ $id: b.documentId }) } + throw new Error(`unerwartet: ${methode} ${pfad}`) + } + return { karten, aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z) } } +} +const NUR_LESEN = (b) => b.aufrufe.every((a) => a.startsWith('GET ')) +const FEHLEN = ['leistungsbeschreibung-news', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'] // „leistungsbeschreibung-website-analyse“ hat 37 Zeichen, eine Dokument-ID darf höchstens 36 haben +const kartenDoc = (key, i) => ({ $id: key.slice(0, 36), key, sort: i + 1 }) +const VORHANDEN = m.DOKUMENTE_V6.map((d) => d.documentKey).filter((k) => !FEHLEN.includes(k)) // die Karten laut WOMS scripts/setup-legal.mjs +test('kartenPlan: genau die drei fehlenden Karten in Tabellenreihenfolge, mit zulässiger Dokument-ID', () => { + const plan = m.kartenPlan({ vorhandeneKeys: VORHANDEN, hoechsteSort: 14 }) + assert.deepEqual(plan.map((k) => [k.daten.key, k.daten.sort]), [[FEHLEN[0], 15], [FEHLEN[1], 16], [FEHLEN[2], 17]]) + assert.deepEqual(plan.map((k) => k.daten.title), ['Leistungsbeschreibung News-Pinnwand', 'Leistungsbeschreibung Website-Analyse', 'AVV Website-Analyse']) + assert.ok(plan.every((k) => k.daten.liveUrl.startsWith('https://project.webklar.com/neu/rechtliches/') && k.daten.liveUrl.endsWith(`-${FUNKTIONEN_FASSUNG_V6}.html`))) + assert.ok(plan.every((k) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(k.id)), 'Dokument-ID höchstens 36 Zeichen') + assert.equal(plan[0].id, FEHLEN[0], 'kurze Schlüssel bleiben die ID, wie in setup-legal.mjs') + assert.notEqual(plan[1].id, FEHLEN[1]); assert.equal(m.kartenId(FEHLEN[1]), plan[1].id, 'lange Schlüssel: abgeleitete ID, immer dieselbe') + assert.deepEqual(m.kartenPlan({ vorhandeneKeys: m.DOKUMENTE_V6.map((d) => d.documentKey), hoechsteSort: 20 }), []) +}) +test('--probe: eine fehlende Karte ist ein Stopp (Exit 1), mit allen Karten Exit 0; schreibt nie', async () => { + const b = backend(VORHANDEN.map(kartenDoc)) + assert.equal(await m.main(['--probe'], b.deps), 1) + for (const k of FEHLEN) assert.ok(b.log.includes(`STOPP: Karte fehlt: ${k}`), k) + assert.ok(NUR_LESEN(b), b.aufrufe.join(', ')) + assert.equal(await m.main(['--probe'], backend(m.DOKUMENTE_V6.map((d, i) => kartenDoc(d.documentKey, i))).deps), 0) +}) +test('--karten-anlegen: legt nur die drei fehlenden Karten an, ein zweiter Lauf tut nichts, danach ist --probe grün', async () => { + const b = backend(VORHANDEN.map(kartenDoc)) + assert.equal(await m.main(['--karten-anlegen'], b.deps), 0) + assert.deepEqual(b.aufrufe.filter((a) => !a.startsWith('GET ')), FEHLEN.map(() => 'POST /databases/woms-database/collections/legalDocuments/documents')) + assert.deepEqual(b.karten.slice(VORHANDEN.length).map((k) => k.key), FEHLEN) + b.aufrufe.length = 0 + assert.equal(await m.main(['--karten-anlegen'], b.deps), 0); assert.ok(NUR_LESEN(b)) + assert.equal(await m.main(['--probe'], b.deps), 0) +}) +test('Hochladen ohne Karte bricht ab, bevor etwas geschrieben wird', async () => { + const b = backend(VORHANDEN.map(kartenDoc)) + assert.equal(await m.main([FEHLEN[0], '--freigabe=2026-10-20'], b.deps), 1) + assert.ok(b.log.some((z) => z.includes(`Karte fehlt: ${FEHLEN[0]}`)) && NUR_LESEN(b)) +}) +test('pruefeHochgeladen und --pruefen: alle elf Dokumente brauchen eine aktuelle Version mit passender Quelle', async () => { + const ok = (d) => [{ $id: 'v', version: 'v2', status: 'current', source: `kundenbereich@abc1234:${d.quelle}` }] + const alle = Object.fromEntries(m.DOKUMENTE_V6.map((d) => [d.documentKey, ok(d)])) + assert.deepEqual(m.pruefeHochgeladen(alle), { fehlt: [] }) + assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'avv-website-analyse': [] }).fehlt, ['avv-website-analyse']) + assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'abo-bedingungen': [{ ...alle['abo-bedingungen'][0], source: 'kundenbereich@7ae0ffd:public/abo-bedingungen.html' }] }).fehlt, ['abo-bedingungen'], 'Fassung 5 zählt nicht') + assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'leistungsbeschreibung-news': [{ ...alle['leistungsbeschreibung-news'][0], status: 'superseded' }] }).fehlt, ['leistungsbeschreibung-news']) + assert.equal(m.pruefeHochgeladen({}).fehlt.length, 11) + const b = backend([]) // Versionen leer: alle elf fehlen + assert.equal(await m.main(['--pruefen'], b.deps), 1) + assert.equal(b.log.filter((z) => z.startsWith('FEHLT ')).length, 11); assert.ok(NUR_LESEN(b)) +})