Schema: Zustimmungsnachweis featureConsents im Skript feature-setup
scripts/feature-setup.mjs legt neben den portalFeatures-Attributen die Collection
featureConsents (nur Server-Key, ohne Rechte) mit 11 Attributen und den Indizes
idx_session und idx_project_feature an; --check meldet je fehlende Collection,
Attribut und Index eine Zeile "- <id>[.<key>] FEHLT", schreibt nichts und liefert
Exit-Code 1 bei Luecken. config.collections.featureConsents (Env
APPWRITE_COLLECTION_FEATURE_CONSENTS) und schemaKeys('featureConsents') in
test/support/schema.js ziehen mit. setup-abo-collections.mjs bleibt unveraendert.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98fa03aad7
commit
d8e4d79891
4 files changed
+268
-23
No files matched your search
+70
-11
@@ -1,16 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Portal-Funktionen (News, Terminplaner): legt die Attribute an der Collection portalFeatures an, die der
|
* Portal-Funktionen (News, Terminplaner): legt die Attribute an der Collection portalFeatures an, die der
|
||||||
* Verkauf des Terminplaners braucht (Abschaltgrund, Marker der Abo-Ende-Mail, Marker der Zahlungsmeldung).
|
* Verkauf des Terminplaners braucht (Abschaltgrund, Marker der Abo-Ende-Mail, Marker der Zahlungsmeldung),
|
||||||
* Idempotent: Vorhandenes wird uebersprungen. Vor dem Code-Deploy ausfuehren (activate() schreibt die neuen
|
* und die Collection featureConsents (Zustimmungsnachweis zur Bestellung, nur Server-Key) samt Attributen und
|
||||||
* Felder; ohne Schema antwortet Appwrite mit "Unknown attribute").
|
* Indizes. Idempotent: Vorhandenes wird uebersprungen. Vor dem Code-Deploy ausfuehren (activate() schreibt die
|
||||||
|
* neuen Felder; ohne Schema antwortet Appwrite mit "Unknown attribute").
|
||||||
* scripts/setup-abo-collections.mjs bleibt unveraendert (Top-Level-Skript ohne Pruefmodus).
|
* scripts/setup-abo-collections.mjs bleibt unveraendert (Top-Level-Skript ohne Pruefmodus).
|
||||||
* Usage:
|
* Usage:
|
||||||
* node scripts/feature-setup.mjs --check (nur melden, nichts anlegen; Exit-Code 1 bei Luecken)
|
* node scripts/feature-setup.mjs --check (nur melden, nichts anlegen; Exit-Code 1 bei Luecken)
|
||||||
* node scripts/feature-setup.mjs (Fehlendes anlegen)
|
* node scripts/feature-setup.mjs (Fehlendes anlegen)
|
||||||
*
|
*
|
||||||
* config.js und der Appwrite-Client werden erst in main() geladen: test/support/schema.js importiert die
|
* config.js und der Appwrite-Client werden erst in main() geladen: test/support/schema.js importiert die
|
||||||
* Attributliste, bevor die Tests ihre Umgebung (useTestEnv) setzen koennen.
|
* Attributlisten, bevor die Tests ihre Umgebung (useTestEnv) setzen koennen.
|
||||||
*/
|
*/
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
@@ -26,6 +27,39 @@ export const PORTAL_FEATURE_ATTRIBUTES = [
|
|||||||
{ type: 'string', key: 'zahlungMeldung', size: 24, required: false },
|
{ type: 'string', key: 'zahlungMeldung', size: 24, required: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const str = (key, size) => ({ key, type: 'string', size, required: false })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustimmungsnachweis zur Bestellung einer Funktion (Collection featureConsents, nur Server-Key, wie analyseConsents):
|
||||||
|
* - fassungen: JSON mit den Fassungen der gezeigten Texte; texte: JSON mit den gezeigten Texten (Appwrite macht aus
|
||||||
|
* dieser Groesse eine TEXT-Spalte; 20000 Zeichen genuegen fuer Zustimmung, Haken und Belehrung)
|
||||||
|
* - textHash: SHA-256 (hex) ueber die gespeicherten Texte; stripeSessionId: Stripe-Sitzung, zu der der Nachweis gehoert
|
||||||
|
* - ip, userAgent, konto: Herkunft der Zustimmung; acceptedAt: Zeitpunkt des Klicks (Anker der Widerrufsfrist)
|
||||||
|
*/
|
||||||
|
export const CONSENT_COLLECTION_NAME = 'featureConsents'
|
||||||
|
export const FEATURE_CONSENT_ATTRIBUTES = [
|
||||||
|
str('customerId', 36),
|
||||||
|
str('projectId', 36),
|
||||||
|
str('featureKey', 16),
|
||||||
|
str('stripeSessionId', 128),
|
||||||
|
{ key: 'acceptedAt', type: 'datetime', required: false },
|
||||||
|
str('fassungen', 1000),
|
||||||
|
str('texte', 20000),
|
||||||
|
str('textHash', 64),
|
||||||
|
str('ip', 64),
|
||||||
|
str('userAgent', 300),
|
||||||
|
str('konto', 255),
|
||||||
|
]
|
||||||
|
export const FEATURE_CONSENT_INDEXES = [
|
||||||
|
{ key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] },
|
||||||
|
{ key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** true, wenn das Ergebnis von main() Luecken meldet (Exit-Code 1 im Pruefmodus) */
|
||||||
|
export function hatLuecken({ missing = [], missingCollection = false, missingIndexes = [] } = {}) {
|
||||||
|
return missing.length > 0 || missingCollection || missingIndexes.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
export async function main({ check = false, client, log = console.log } = {}) {
|
export async function main({ check = false, client, log = console.log } = {}) {
|
||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
let api = client
|
let api = client
|
||||||
@@ -34,11 +68,12 @@ export async function main({ check = false, client, log = console.log } = {}) {
|
|||||||
api = createAppwriteClient()
|
api = createAppwriteClient()
|
||||||
}
|
}
|
||||||
const featuresId = config.collections.portalFeatures
|
const featuresId = config.collections.portalFeatures
|
||||||
|
const consentsId = config.collections.featureConsents
|
||||||
const missing = []
|
const missing = []
|
||||||
|
|
||||||
// Attribute einer Collection abgleichen: meldet Vorhandenes bzw. (mit --check) jede Luecke und liefert die fehlenden
|
// Attribute einer Collection abgleichen; `exists` false = Collection fehlt (alle Attribute fehlen)
|
||||||
async function syncAttributes(collectionId, attributes) {
|
async function syncAttributes(collectionId, attributes, exists = true) {
|
||||||
const have = new Set((await api.listAttributes(collectionId)).map((a) => a.key))
|
const have = exists ? new Set((await api.listAttributes(collectionId)).map((a) => a.key)) : new Set()
|
||||||
for (const attr of attributes) {
|
for (const attr of attributes) {
|
||||||
if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`)
|
if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`)
|
||||||
else if (check) log(`- ${collectionId}.${attr.key} FEHLT`)
|
else if (check) log(`- ${collectionId}.${attr.key} FEHLT`)
|
||||||
@@ -49,25 +84,49 @@ export async function main({ check = false, client, log = console.log } = {}) {
|
|||||||
const featureAbsent = await syncAttributes(featuresId, PORTAL_FEATURE_ATTRIBUTES)
|
const featureAbsent = await syncAttributes(featuresId, PORTAL_FEATURE_ATTRIBUTES)
|
||||||
featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId }))
|
featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId }))
|
||||||
|
|
||||||
|
const consentsExist = await api.collectionExists(consentsId)
|
||||||
|
if (!consentsExist && check) log(`- ${consentsId} FEHLT`)
|
||||||
|
const consentAbsent = await syncAttributes(consentsId, FEATURE_CONSENT_ATTRIBUTES, consentsExist)
|
||||||
|
consentAbsent.forEach((a) => missing.push({ ...a, collection: consentsId }))
|
||||||
|
const haveIndexes = consentsExist ? new Set((await api.listIndexes(consentsId)).map((i) => i.key)) : new Set()
|
||||||
|
const indexAbsent = FEATURE_CONSENT_INDEXES.filter((i) => !haveIndexes.has(i.key))
|
||||||
|
if (check) indexAbsent.forEach((i) => log(`- ${consentsId}.${i.key} FEHLT`))
|
||||||
|
|
||||||
|
const result = { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent }
|
||||||
if (check) {
|
if (check) {
|
||||||
log(missing.length ? `${missing.length} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
|
const open = missing.length + indexAbsent.length + (consentsExist ? 0 : 1)
|
||||||
return { missing }
|
log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const attr of featureAbsent) {
|
for (const attr of featureAbsent) {
|
||||||
await api.createAttribute(featuresId, attr)
|
await api.createAttribute(featuresId, attr)
|
||||||
log(`+ Attribut ${featuresId}.${attr.key}`)
|
log(`+ Attribut ${featuresId}.${attr.key}`)
|
||||||
}
|
}
|
||||||
|
if (!consentsExist) {
|
||||||
|
await api.ensureCollection(consentsId, CONSENT_COLLECTION_NAME)
|
||||||
|
log(`+ Collection ${consentsId}`)
|
||||||
|
}
|
||||||
|
for (const attr of consentAbsent) {
|
||||||
|
await api.createAttribute(consentsId, attr)
|
||||||
|
log(`+ Attribut ${consentsId}.${attr.key}`)
|
||||||
|
}
|
||||||
for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key)
|
for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key)
|
||||||
|
for (const attr of consentAbsent) await api.waitForAttribute(consentsId, attr.key)
|
||||||
|
// Indizes erst, wenn ihre Attribute verfuegbar sind
|
||||||
|
for (const index of indexAbsent) {
|
||||||
|
await api.createIndex(consentsId, index)
|
||||||
|
log(`+ Index ${consentsId}.${index.key}`)
|
||||||
|
}
|
||||||
log('Fertig.')
|
log('Fertig.')
|
||||||
return { missing }
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
if (invokedDirectly) {
|
if (invokedDirectly) {
|
||||||
const check = process.argv.includes('--check')
|
const check = process.argv.includes('--check')
|
||||||
main({ check })
|
main({ check })
|
||||||
.then(({ missing }) => process.exit(check && missing.length ? 1 : 0))
|
.then((result) => process.exit(check && hatLuecken(result) ? 1 : 0))
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e.message)
|
console.error(e.message)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ export const config = {
|
|||||||
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
|
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
|
||||||
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
|
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
|
||||||
analyseConsents: process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents',
|
analyseConsents: process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents',
|
||||||
|
// Zustimmungsnachweis zur Bestellung einer Funktion (nur Server-Key; scripts/feature-setup.mjs)
|
||||||
|
featureConsents: process.env.APPWRITE_COLLECTION_FEATURE_CONSENTS || 'featureConsents',
|
||||||
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
|
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
|
||||||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||||||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
||||||
|
|||||||
+192
-10
@@ -4,9 +4,17 @@ import { startAppwriteMock } from './support/appwriteMock.js'
|
|||||||
import { useTestEnv } from './support/testEnv.js'
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
import { schemaKeys } from './support/schema.js'
|
import { schemaKeys } from './support/schema.js'
|
||||||
// Das Skript laedt config.js erst in main(): schema.js (Testhilfe) importiert es vor useTestEnv()
|
// Das Skript laedt config.js erst in main(): schema.js (Testhilfe) importiert es vor useTestEnv()
|
||||||
import { PORTAL_FEATURE_ATTRIBUTES, main } from '../scripts/feature-setup.mjs'
|
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 (scripts/feature-setup.mjs): Mock-Client wie in analyseSetup.test.js
|
// 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 schema = { portalFeatures: [] } // wird im Schema-Test gefuellt (die Analyse-Schluessel kennt erst das dynamisch geladene Skript)
|
||||||
const aw = await startAppwriteMock({ schema })
|
const aw = await startAppwriteMock({ schema })
|
||||||
after(() => aw.close())
|
after(() => aw.close())
|
||||||
@@ -14,37 +22,55 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
|||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
const { ANALYSE_ATTRIBUTES } = await import('../scripts/analyse-setup.mjs')
|
const { ANALYSE_ATTRIBUTES } = await import('../scripts/analyse-setup.mjs')
|
||||||
|
|
||||||
function mockClient({ attrs = [] } = {}) {
|
const CONSENT_KEYS = [
|
||||||
const calls = { attrs: [], collections: [], indexes: [], waits: [] }
|
'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 {
|
return {
|
||||||
calls,
|
calls,
|
||||||
async collectionExists() {
|
async collectionExists(id) {
|
||||||
return true
|
return id === consentsId ? consents : true
|
||||||
},
|
},
|
||||||
async ensureCollection(id, name) {
|
async ensureCollection(id, name) {
|
||||||
calls.collections.push({ id, name })
|
calls.collections.push({ id, name })
|
||||||
|
calls.order.push(`collection ${id}`)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async listAttributes(id) {
|
async listAttributes(id) {
|
||||||
assert.equal(id, config.collections.portalFeatures, 'liest nur die Funktions-Collection')
|
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 }))
|
return attrs.map((key) => ({ key }))
|
||||||
},
|
},
|
||||||
async createAttribute(id, attr) {
|
async createAttribute(id, attr) {
|
||||||
calls.attrs.push({ id, ...attr })
|
calls.attrs.push({ id, ...attr })
|
||||||
|
calls.order.push(`attribute ${id}.${attr.key}`)
|
||||||
},
|
},
|
||||||
async waitForAttribute(id, key) {
|
async waitForAttribute(id, key) {
|
||||||
calls.waits.push(`${id}.${key}`)
|
calls.waits.push(`${id}.${key}`)
|
||||||
|
calls.order.push(`wait ${id}.${key}`)
|
||||||
},
|
},
|
||||||
async listIndexes() {
|
async listIndexes(id) {
|
||||||
return []
|
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) {
|
async createIndex(id, index) {
|
||||||
calls.indexes.push(`${id}.${index.key}`)
|
calls.indexes.push(`${id}.${index.key}`)
|
||||||
|
calls.order.push(`index ${id}.${index.key}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const KEYS = ['disabledReason', 'aboEndeMailAt', 'zahlungMeldung']
|
const KEYS = ['disabledReason', 'aboEndeMailAt', 'zahlungMeldung']
|
||||||
const KEINE = { attrs: [], collections: [], indexes: [], waits: [] }
|
const KEINE = { attrs: [], collections: [], indexes: [], waits: [], order: [] }
|
||||||
|
|
||||||
test('Schema: drei Attribute mit Typ und Groesse, alle optional', () => {
|
test('Schema: drei Attribute mit Typ und Groesse, alle optional', () => {
|
||||||
assert.deepEqual(PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), KEYS)
|
assert.deepEqual(PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), KEYS)
|
||||||
@@ -78,6 +104,8 @@ test('main legt nur Fehlendes an und wartet auf die neuen Attribute', async () =
|
|||||||
assert.deepEqual(client.calls.waits, ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung'])
|
assert.deepEqual(client.calls.waits, ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung'])
|
||||||
assert.deepEqual([client.calls.collections, client.calls.indexes], [[], []])
|
assert.deepEqual([client.calls.collections, client.calls.indexes], [[], []])
|
||||||
assert.deepEqual(result.missing.map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung'])
|
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('+ Attribut portalFeatures.aboEndeMailAt')))
|
||||||
assert.ok(log.some((zeile) => zeile.includes('= portalFeatures.disabledReason (existiert)')))
|
assert.ok(log.some((zeile) => zeile.includes('= portalFeatures.disabledReason (existiert)')))
|
||||||
})
|
})
|
||||||
@@ -128,3 +156,157 @@ test('schemaKeys(portalFeatures) enthaelt die drei Schluessel; der Analyse-Aufba
|
|||||||
assert.equal(neu.disabledReason, 'team')
|
assert.equal(neu.disabledReason, 'team')
|
||||||
await assert.rejects(() => updateDocument(config.collections.portalFeatures, doc.$id, { gibtEsNicht: 1 }), /Unknown attribute/)
|
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)
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
|
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
|
||||||
import { PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
|
import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
|
||||||
|
|
||||||
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
|
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
|
||||||
const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
|
const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
|
||||||
@@ -10,10 +10,12 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
|
|||||||
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
|
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
|
||||||
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
|
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
|
||||||
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs);
|
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs);
|
||||||
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES).
|
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES);
|
||||||
|
* featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES).
|
||||||
*/
|
*/
|
||||||
export function schemaKeys(collection) {
|
export function schemaKeys(collection) {
|
||||||
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
|
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||||
|
if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)
|
||||||
for (const script of SETUP_SCRIPTS) {
|
for (const script of SETUP_SCRIPTS) {
|
||||||
const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')
|
const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')
|
||||||
const start = src.indexOf(`'${collection}',`)
|
const start = src.indexOf(`'${collection}',`)
|
||||||
|
|||||||
Reference in new issue
Block a user