import { test, after } 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' // Das Skript laedt config.js erst in main(): schema.js (Testhilfe) importiert es vor useTestEnv() import { CONSENT_COLLECTION_NAME, FEATURE_CONSENT_ATTRIBUTES, FEATURE_CONSENT_INDEXES, PORTAL_FEATURE_ATTRIBUTES, hatLuecken, main, } from '../scripts/feature-setup.mjs' // Schema-Skript der Funktions-Attribute und des Zustimmungsnachweises (scripts/feature-setup.mjs): // Mock-Client wie in analyseSetup.test.js const schema = { portalFeatures: [] } // wird im Schema-Test gefuellt (die Analyse-Schluessel kennt erst das dynamisch geladene Skript) const aw = await startAppwriteMock({ schema }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { ANALYSE_ATTRIBUTES } = await import('../scripts/analyse-setup.mjs') const CONSENT_KEYS = [ 'customerId', 'projectId', 'featureKey', 'stripeSessionId', 'acceptedAt', 'fassungen', 'texte', 'textHash', 'ip', 'userAgent', 'konto', ] const CONSENT_INDEX_KEYS = ['idx_session', 'idx_project_feature'] // Ohne Angaben ist featureConsents komplett vorhanden (Attribute und Indizes): die Faelle fuer portalFeatures bleiben so // unberuehrt; die Faelle fuer featureConsents setzen consents/consentAttrs/indexes selbst. function mockClient({ attrs = [], consents = true, consentAttrs = CONSENT_KEYS, indexes = CONSENT_INDEX_KEYS } = {}) { const calls = { attrs: [], collections: [], indexes: [], waits: [], order: [] } const consentsId = config.collections.featureConsents return { calls, async collectionExists(id) { return id === consentsId ? consents : true }, async ensureCollection(id, name) { calls.collections.push({ id, name }) calls.order.push(`collection ${id}`) return true }, async listAttributes(id) { if (id === consentsId) { if (!consents) throw Object.assign(new Error('Collection nicht gefunden'), { status: 404 }) return consentAttrs.map((key) => ({ key })) } assert.equal(id, config.collections.portalFeatures, 'liest nur die beiden Funktions-Collections') return attrs.map((key) => ({ key })) }, async createAttribute(id, attr) { calls.attrs.push({ id, ...attr }) calls.order.push(`attribute ${id}.${attr.key}`) }, async waitForAttribute(id, key) { calls.waits.push(`${id}.${key}`) calls.order.push(`wait ${id}.${key}`) }, async listIndexes(id) { assert.equal(id, consentsId, 'Indizes gibt es nur an featureConsents') if (!consents) throw Object.assign(new Error('Collection nicht gefunden'), { status: 404 }) return indexes.map((key) => ({ key })) }, async createIndex(id, index) { calls.indexes.push(`${id}.${index.key}`) calls.order.push(`index ${id}.${index.key}`) }, } } const KEYS = ['disabledReason', 'aboEndeMailAt', 'zahlungMeldung'] const KEINE = { attrs: [], collections: [], indexes: [], waits: [], order: [] } test('Schema: drei Attribute mit Typ und Groesse, alle optional', () => { assert.deepEqual(PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), KEYS) const nach = Object.fromEntries(PORTAL_FEATURE_ATTRIBUTES.map((a) => [a.key, a])) assert.equal(nach.disabledReason.type, 'string') assert.equal(nach.disabledReason.size, 32) assert.ok(nach.disabledReason.size >= 'abo_ende_vor_einrichtung'.length, 'der laengste Abschaltgrund passt hinein') assert.ok(nach.disabledReason.size >= 24) assert.equal(nach.aboEndeMailAt.type, 'datetime') assert.equal('size' in nach.aboEndeMailAt, false, 'ein datetime-Attribut hat keine Groesse') assert.equal(nach.zahlungMeldung.type, 'string') assert.equal(nach.zahlungMeldung.size, 24) assert.ok(nach.zahlungMeldung.size >= 'bereits_aktiv'.length) assert.ok(PORTAL_FEATURE_ATTRIBUTES.every((a) => a.required === false), 'alle optional') }) test('Schema: DISABLED_REASONS passen in disabledReason', async () => { const { DISABLED_REASONS } = await import('../server/services/siteFeatures.js') const groesse = PORTAL_FEATURE_ATTRIBUTES.find((a) => a.key === 'disabledReason').size for (const grund of DISABLED_REASONS) assert.ok(grund.length <= groesse, grund) }) test('main legt nur Fehlendes an und wartet auf die neuen Attribute', async () => { const client = mockClient({ attrs: ['disabledReason', 'customerId'] }) const log = [] const result = await main({ client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls.attrs.map((a) => `${a.id}.${a.key}`), ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung']) assert.deepEqual(client.calls.attrs.map((a) => a.type), ['datetime', 'string']) assert.ok(client.calls.attrs.every((a) => a.required === false)) assert.equal(client.calls.attrs.find((a) => a.key === 'zahlungMeldung').size, 24) assert.deepEqual(client.calls.waits, ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung']) assert.deepEqual([client.calls.collections, client.calls.indexes], [[], []]) assert.deepEqual(result.missing.map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung']) assert.equal(result.missingCollection, false) assert.deepEqual(result.missingIndexes, []) assert.ok(log.some((zeile) => zeile.includes('+ Attribut portalFeatures.aboEndeMailAt'))) assert.ok(log.some((zeile) => zeile.includes('= portalFeatures.disabledReason (existiert)'))) }) test('main legt bei leerer Collection alle drei an, beim zweiten Lauf nichts mehr (idempotent)', async () => { const leer = mockClient() assert.equal((await main({ client: leer, log: () => {} })).missing.length, 3) assert.deepEqual(leer.calls.attrs.map((a) => a.key), KEYS) const voll = mockClient({ attrs: KEYS }) const result = await main({ client: voll, log: () => {} }) assert.deepEqual(voll.calls, KEINE) assert.equal(result.missing.length, 0) }) test('main --check legt nichts an, meldet jede Luecke und liefert sie im Ergebnis', async () => { const client = mockClient({ attrs: ['disabledReason'] }) const log = [] const result = await main({ check: true, client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls, KEINE) assert.deepEqual(result.missing.map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung']) assert.ok(log.includes('- portalFeatures.aboEndeMailAt FEHLT')) assert.ok(log.includes('- portalFeatures.zahlungMeldung FEHLT')) assert.ok(!log.some((zeile) => zeile.includes('disabledReason FEHLT'))) const alle = mockClient({ attrs: KEYS }) const log2 = [] const heil = await main({ check: true, client: alle, log: (zeile) => log2.push(zeile) }) assert.equal(heil.missing.length, 0) assert.ok(!log2.some((zeile) => zeile.includes('FEHLT'))) assert.deepEqual(alle.calls, KEINE) }) test('schemaKeys(portalFeatures) enthaelt die drei Schluessel; der Analyse-Aufbau bleibt fehlerfrei', async () => { const keys = schemaKeys('portalFeatures') for (const key of [...KEYS, 'disabledAt', 'state', 'consentText']) assert.ok(keys.includes(key), key) assert.equal(new Set(keys).size, keys.length, 'keine doppelten Schluessel') // Der Aufbau aus analyse.test.js: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS] const analyse = ANALYSE_ATTRIBUTES.map((a) => a.key) const zusammen = [...keys, ...analyse] // activatedAt steht schon im Grundschema und in ANALYSE_KEYS (harmlos); die neuen Schluessel gehoeren nur einer Seite for (const key of KEYS) assert.ok(!analyse.includes(key), `${key} ist kein Analyse-Attribut`) schema.portalFeatures.push(...zusammen) const { createDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const doc = await createDocument(config.collections.portalFeatures, { customerId: 'c1', disabledReason: 'team', accentColor: '#111111' }, 'f_schema') const neu = await updateDocument(config.collections.portalFeatures, doc.$id, { aboEndeMailAt: null, zahlungMeldung: '', offSince: null }) assert.equal(neu.disabledReason, 'team') await assert.rejects(() => updateDocument(config.collections.portalFeatures, doc.$id, { gibtEsNicht: 1 }), /Unknown attribute/) }) test('Schema featureConsents: 11 Schluessel mit Typ und Groesse, alle optional', () => { assert.equal(CONSENT_COLLECTION_NAME, 'featureConsents') assert.equal(config.collections.featureConsents, 'featureConsents') assert.deepEqual(FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key), CONSENT_KEYS) const nach = Object.fromEntries(FEATURE_CONSENT_ATTRIBUTES.map((a) => [a.key, a])) const groessen = Object.fromEntries(FEATURE_CONSENT_ATTRIBUTES.filter((a) => a.type === 'string').map((a) => [a.key, a.size])) assert.deepEqual(groessen, { customerId: 36, projectId: 36, featureKey: 16, stripeSessionId: 128, fassungen: 1000, texte: 20000, textHash: 64, ip: 64, userAgent: 300, konto: 255, }) assert.ok(nach.texte.size >= 20000, 'die gezeigten Texte (JSON) passen hinein') assert.ok(nach.stripeSessionId.size >= 128) assert.equal(nach.acceptedAt.type, 'datetime') assert.equal('size' in nach.acceptedAt, false, 'ein datetime-Attribut hat keine Groesse') assert.ok(FEATURE_CONSENT_ATTRIBUTES.every((a) => a.required === false), 'alle optional') assert.equal(new Set(CONSENT_KEYS).size, CONSENT_KEYS.length) // Ein SHA-256 in hex (64 Zeichen) und eine UUID (36) passen assert.ok(nach.textHash.size >= 64 && nach.customerId.size >= 36 && nach.projectId.size >= 36) }) test('Schema featureConsents: beide Indizes mit den Attributen aus der Vorgabe', () => { assert.deepEqual(FEATURE_CONSENT_INDEXES, [ { key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] }, { key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] }, ]) const keys = new Set(CONSENT_KEYS) for (const index of FEATURE_CONSENT_INDEXES) for (const attr of index.attributes) assert.ok(keys.has(attr), `${index.key}: ${attr}`) }) test('schemaKeys(featureConsents) liefert dieselben Schluessel, der Nachbau nimmt sie an und lehnt andere ab', async () => { assert.deepEqual(schemaKeys('featureConsents'), CONSENT_KEYS) assert.deepEqual(schemaKeys('featureConsents'), FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)) schema.featureConsents = schemaKeys('featureConsents') const { createDocument } = await import('../server/services/appwriteAdmin.js') const doc = await createDocument(config.collections.featureConsents, { customerId: 'c1', projectId: 'p1', featureKey: 'termine', stripeSessionId: 'cs_1', acceptedAt: '2026-10-04T10:00:00.000+00:00', fassungen: '{}', texte: '[]', textHash: 'x', ip: '127.0.0.1', userAgent: 'test', konto: 'k@example.com', }, 'fc_schema') assert.equal(doc.stripeSessionId, 'cs_1') await assert.rejects(() => createDocument(config.collections.featureConsents, { gibtEsNicht: 1 }, 'fc_schema2'), /Unknown attribute/) }) test('main legt bei fehlender Collection genau featureConsents mit 11 Attributen und 2 Indizes an', async () => { const client = mockClient({ attrs: KEYS, consents: false, consentAttrs: [], indexes: [] }) const log = [] const result = await main({ client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls.collections, [{ id: 'featureConsents', name: 'featureConsents' }]) assert.deepEqual(client.calls.attrs.map((a) => a.id), Array(11).fill('featureConsents'), 'nichts an portalFeatures, da dort alles vorhanden ist') assert.deepEqual(client.calls.attrs.map((a) => a.key), CONSENT_KEYS) assert.deepEqual(client.calls.attrs.map((a) => a.type), ['string', 'string', 'string', 'string', 'datetime', 'string', 'string', 'string', 'string', 'string', 'string']) assert.equal(client.calls.attrs.find((a) => a.key === 'texte').size, 20000) assert.ok(client.calls.attrs.every((a) => a.required === false)) assert.deepEqual(client.calls.waits, CONSENT_KEYS.map((key) => `featureConsents.${key}`)) assert.deepEqual(client.calls.indexes, ['featureConsents.idx_session', 'featureConsents.idx_project_feature']) // Reihenfolge: Collection, alle Attribute, Warten auf alle Attribute, erst dann die Indizes const order = client.calls.order assert.equal(order[0], 'collection featureConsents') assert.equal(order.findIndex((e) => e.startsWith('index ')), order.length - 2, 'die Indizes kommen zuletzt') assert.ok(order.lastIndexOf('wait featureConsents.konto') < order.indexOf('index featureConsents.idx_session')) assert.ok(order.indexOf('attribute featureConsents.konto') < order.indexOf('wait featureConsents.customerId')) assert.equal(result.missingCollection, true) assert.equal(result.missing.length, 11) assert.ok(result.missing.every((a) => a.collection === 'featureConsents')) assert.deepEqual(result.missingIndexes.map((i) => i.key), ['idx_session', 'idx_project_feature']) assert.ok(log.includes('+ Collection featureConsents')) assert.ok(log.includes('+ Index featureConsents.idx_project_feature')) }) test('main legt bei fehlender Collection und fehlenden portalFeatures-Attributen nur das Fehlende an', async () => { const client = mockClient({ attrs: ['disabledReason'], consents: false, consentAttrs: [], indexes: [] }) const result = await main({ client, log: () => {} }) assert.deepEqual(client.calls.collections.map((c) => c.id), ['featureConsents']) assert.deepEqual(client.calls.attrs.filter((a) => a.id === 'portalFeatures').map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung']) assert.equal(client.calls.attrs.filter((a) => a.id === 'featureConsents').length, 11) assert.equal(result.missing.length, 13) }) test('main ergaenzt an vorhandener Collection nur fehlende Attribute und Indizes', async () => { const client = mockClient({ attrs: KEYS, consentAttrs: CONSENT_KEYS.filter((key) => key !== 'konto' && key !== 'textHash'), indexes: ['idx_session'], }) const result = await main({ client, log: () => {} }) assert.deepEqual(client.calls.collections, [], 'die Collection wird nicht noch einmal angelegt') assert.deepEqual(client.calls.attrs.map((a) => `${a.id}.${a.key}`), ['featureConsents.textHash', 'featureConsents.konto']) assert.deepEqual(client.calls.waits, ['featureConsents.textHash', 'featureConsents.konto']) assert.deepEqual(client.calls.indexes, ['featureConsents.idx_project_feature']) assert.equal(result.missingCollection, false) assert.deepEqual(result.missing.map((a) => a.key), ['textHash', 'konto']) assert.deepEqual(result.missingIndexes.map((i) => i.key), ['idx_project_feature']) }) test('main ueberspringt Vorhandenes: keine Anlege-Aufrufe, Ergebnis ohne Luecken', async () => { const client = mockClient({ attrs: KEYS }) const log = [] const result = await main({ client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls, KEINE) assert.equal(result.missing.length, 0) assert.equal(result.missingCollection, false) assert.deepEqual(result.missingIndexes, []) assert.equal(hatLuecken(result), false) assert.ok(log.includes('= featureConsents.texte (existiert)')) assert.ok(!log.some((zeile) => zeile.includes('FEHLT') || zeile.startsWith('+'))) }) test('main --check schreibt nie, meldet featureConsents FEHLT und liefert die Luecken im Ergebnis', async () => { const client = mockClient({ attrs: KEYS, consents: false, consentAttrs: [], indexes: [] }) const log = [] const result = await main({ check: true, client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls, KEINE, 'weder ensureCollection noch createAttribute noch createIndex noch waitForAttribute') assert.ok(log.includes('- featureConsents FEHLT')) for (const key of CONSENT_KEYS) assert.ok(log.includes(`- featureConsents.${key} FEHLT`), key) assert.ok(log.includes('- featureConsents.idx_session FEHLT')) assert.ok(log.includes('- featureConsents.idx_project_feature FEHLT')) assert.ok(!log.some((zeile) => zeile.includes('portalFeatures') && zeile.includes('FEHLT'))) assert.ok(!log.some((zeile) => zeile.startsWith('+'))) assert.equal(result.missingCollection, true) assert.deepEqual(result.missing.map((a) => a.key), CONSENT_KEYS) assert.equal(result.missingIndexes.length, 2) assert.equal(hatLuecken(result), true) assert.equal(log.at(-1), '14 Eintrag/Eintraege fehlen.') }) test('main --check meldet je fehlendes Attribut und je fehlenden Index einer vorhandenen Collection', async () => { const client = mockClient({ attrs: KEYS, consentAttrs: CONSENT_KEYS.filter((key) => key !== 'texte'), indexes: ['idx_project_feature'] }) const log = [] const result = await main({ check: true, client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls, KEINE) assert.ok(log.includes('- featureConsents.texte FEHLT')) assert.ok(log.includes('- featureConsents.idx_session FEHLT')) assert.ok(!log.includes('- featureConsents FEHLT'), 'die Collection selbst ist da') assert.ok(!log.includes('- featureConsents.idx_project_feature FEHLT')) assert.equal(result.missingCollection, false) assert.equal(hatLuecken(result), true) }) test('main --check ohne Luecken: Alles vorhanden, Exit-Code 0', async () => { const client = mockClient({ attrs: KEYS }) const log = [] const result = await main({ check: true, client, log: (zeile) => log.push(zeile) }) assert.deepEqual(client.calls, KEINE) assert.equal(log.at(-1), 'Alles vorhanden.') assert.equal(hatLuecken(result), false) }) test('hatLuecken zaehlt Attribute, Collection und Indizes', () => { assert.equal(hatLuecken({ missing: [], missingCollection: false, missingIndexes: [] }), false) assert.equal(hatLuecken({ missing: [{ key: 'a' }], missingCollection: false, missingIndexes: [] }), true) assert.equal(hatLuecken({ missing: [], missingCollection: true, missingIndexes: [] }), true) assert.equal(hatLuecken({ missing: [], missingCollection: false, missingIndexes: [{ key: 'i' }] }), true) assert.equal(hatLuecken({ missing: [] }), false) })