diff --git a/server/routes/abo.js b/server/routes/abo.js index 964e910..5227e11 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -18,7 +18,7 @@ import { reconcilePendingSwitch, switchToWartung, } from '../services/planSwitch.js' -import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js' +import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js' import { featureConfig } from '../services/featureRegistry.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { @@ -68,6 +68,8 @@ function minTermEndOf(sub) { // Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6) const productKeyOf = (sub) => sub?.productKey || 'wartung' const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung' +// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner +const ABO_FUNKTIONEN = ['analyse', 'termine'] // {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo" const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo') // {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne @@ -408,6 +410,24 @@ router.post('/switch', requireSession, async (req, res) => { // Stripe-Webhook // --------------------------------------------------------------------------- +/** + * Hat der Kunde dieses Abos die Website-Analyse oder den Terminplaner (Funktionsdokument in portalFeatures mit featureKey analyse + * oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht)? Nur dann gilt der Hinweis in K02 und + * K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). Der Kunde ist der des Abos (sub.customerId, + * wie in customerNameOf); gelesen wird wie sonst auch ueber listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als ja (der Kunde + * wird eher informiert als nicht), eine Logzeile, die Mail geht in jedem Fall raus. + */ +async function hatAboFunktionen(sub) { + try { + if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID') + const docs = await listCustomerFeatureDocs(sub.customerId) + return docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && (doc.state || 'off') !== 'off') + } catch (err) { + console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`) + return true + } +} + /** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */ async function customerNameOf(sub, fallback = '') { const customer = sub?.customerId @@ -475,8 +495,9 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) { vertragsende: fmtDate(endsAt), vertragsende_paragraph: endParagraphOf(sub), }, - // Hinweis auf nicht genutzte Arbeitszeit und auf das Ende von Website-Analyse und Terminplaner nur beim Wartungs-Abo - blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) }, + // Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner nur beim + // Wartungs-Abo eines Kunden, der eine von beiden hat (hatAboFunktionen) + blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) && (await hatAboFunktionen(sub)) }, }) await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString(), @@ -948,8 +969,9 @@ router.post('/public/cancel', async (req, res) => { abo: !storage, // Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo) wartung: !storage && isWartung(sub), - // Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung) - funktionen: !storage && isWartung(sub), + // Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung), + // nur fuer einen Kunden, der eine von beiden hat (hatAboFunktionen) + funktionen: !storage && isWartung(sub) && (await hatAboFunktionen(sub)), automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe', sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe', ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich', diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index e420aa5..8ae0ef0 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -8,6 +8,18 @@ function buildQueries(queries = []) { }) } +/** + * URL einer Anfrage: Pfad plus eine `queries[]` je Abfrage (JSON, URL-kodiert). Die einzige Stelle, die sie baut (adminFetch und + * dokumenteUrl), damit die Laenge, die der nginx vor Appwrite zaehlt (siehe AUSSCHLUSS_MAX in termine/outbox.js), pruefbar bleibt. + */ +function buildUrl(path, queries = []) { + const url = new URL(`${config.appwrite.endpoint}${path}`) + for (const q of buildQueries(queries)) { + url.searchParams.append('queries[]', q) + } + return url +} + function adminHeaders() { return { 'Content-Type': 'application/json', @@ -33,10 +45,7 @@ async function adminFetch(path, { method = 'GET', body, queries = [], raw = fals throw error } - const url = new URL(`${config.appwrite.endpoint}${path}`) - for (const q of buildQueries(queries)) { - url.searchParams.append('queries[]', q) - } + const url = buildUrl(path, queries) const useRawBody = raw || path.startsWith('/users') const requestBody = formatRequestBody(body, method, { raw: useRawBody }) @@ -93,12 +102,30 @@ export const Query = { orderAsc: (attribute) => ({ method: 'orderAsc', attribute }), /** Nur diese Felder zurueckgeben (die $-Felder wie $id liefert Appwrite immer mit) */ select: (attributes) => ({ method: 'select', values: attributes }), - /** Ein Wert oder eine Liste: kein Dokument mit einem der Werte ("keiner von") */ - notEqual: (attribute, value) => ({ - method: 'notEqual', - attribute, - values: Array.isArray(value) ? value : [value], - }), + /** + * Kein Dokument mit diesem Wert. Genau EIN Wert: Appwrite 1.8.1 lehnt eine Liste ab (HTTP 400 general_query_invalid + * "NotEqual queries require exactly one value.", gemessen 2026-10-05, M1). Fuer mehrere Werte keinerVon(attribute, werte). + * Zwei Eigenheiten der echten Datenbank, die auch der Nachbau der Tests hat: mehrere notEqual in einer Anfrage werden + * geschnitten (M2, bis 40 getestet), und ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL-Semantik + * (M3): nur verwenden, wenn das Feld an jedem Dokument gesetzt ist. + */ + notEqual: (attribute, value) => { + if (Array.isArray(value)) throw new TypeError(`Query.notEqual('${attribute}') nimmt genau einen Wert (Appwrite lehnt eine Liste ab); fuer mehrere keinerVon('${attribute}', werte) benutzen`) + return { method: 'notEqual', attribute, values: [value] } + }, +} + +/** + * "Keiner von diesen Werten": je Wert eine notEqual-Abfrage mit genau einem Wert; Appwrite schneidet sie (M2). Ohne Werte + * keine Abfrage (leeres Array). Jede Abfrage steht als eigenes queries[] in der URL, die Zahl der Werte also begrenzen + * (M4: der nginx vor Appwrite antwortet mit 414, in der Abfrage der Mail-Warteschlange ab 46 Werten); fuer M1 bis M3 + * siehe Query.notEqual. + * @param {string} attribute + * @param {Iterable} werte + * @returns {object[]} + */ +export function keinerVon(attribute, werte) { + return [...(werte ?? [])].map((wert) => Query.notEqual(attribute, wert)) } export const ID = { @@ -109,6 +136,11 @@ function collectionPath(collectionId) { return `/databases/${config.appwrite.databaseId}/collections/${collectionId}/documents` } +/** Die URL der Listenabfrage einer Collection (ohne Netz), so wie listDocuments sie sendet: fuer Tests der URL-Laenge. */ +export function dokumenteUrl(collectionId, queries = []) { + return buildUrl(collectionPath(collectionId), queries).toString() +} + export async function getUserById(userId) { return adminFetch(`/users/${userId}`) } diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index 1ea035a..f8d8be4 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -1,5 +1,5 @@ import { config } from '../../config.js' -import { Query, countDocuments, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' +import { Query, countDocuments, createDocument, deleteDocument, getDocument, keinerVon, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { sendGuestMail } from '../mailer.js' import { renderMail } from '../mailTemplates.js' @@ -376,16 +376,24 @@ async function speichereFehlschlag(doc, err, nowMs) { const KLASSEN = ['vorrang', 'normal', 'nachrangig'] /** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */ const RUNDEN_MAX = 5 -/** Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage (Appwrite erlaubt hoechstens 100 Werte je Abfrage). */ -const AUSSCHLUSS_MAX = 90 +/** + * Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage. Jede ist ein eigenes notEqual in der URL (Appwrite 1.8.1 + * nimmt je notEqual nur einen Wert, M1). Gemessen am Produktionsserver (M4, 2026-10-05): in dieser Abfrageform antwortet der nginx + * ab 46 Funktions-IDs mit 36 Zeichen mit 414 (URL zu lang); 30 lassen Luft fuer laengere IDs. Eine blockierte Funktion ausserhalb + * der Liste wird gelesen und uebersprungen (verarbeiteKlasse), das kostet nur Platz in der Liste. + */ +export const AUSSCHLUSS_MAX = 30 /** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */ const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch'] -/** Filter auf die Art der Mail: die Arten der Klasse (equal) bzw. alle anderen (notEqual, die Klasse normal). */ +/** + * Filter auf die Art der Mail, immer eine Liste von Abfragen: die Arten der Klasse (equal mit Liste, das erlaubt Appwrite) bzw. + * alle anderen (die Klasse normal: je Art ein notEqual, Appwrite schneidet sie; art ist an jedem Eintrag gesetzt, einreihen prueft es). + */ function klassenFilter(klasse) { - if (klasse === 'vorrang') return Query.equal('art', [...MAIL_VORRANG]) - if (klasse === 'nachrangig') return Query.equal('art', [...MAIL_NACHRANGIG]) - return Query.notEqual('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG]) + if (klasse === 'vorrang') return [Query.equal('art', [...MAIL_VORRANG])] + if (klasse === 'nachrangig') return [Query.equal('art', [...MAIL_NACHRANGIG])] + return keinerVon('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG]) } /** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */ @@ -393,8 +401,8 @@ function listenAbfrage(klasse, nowMs, blockiert) { return [ Query.equal('status', 'wartet'), Query.lessThanEqual('naechsterVersuch', iso(nowMs)), - klassenFilter(klasse), - ...(blockiert.size > 0 ? [Query.notEqual('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX))] : []), + ...klassenFilter(klasse), + ...keinerVon('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX)), Query.orderAsc('naechsterVersuch'), Query.limit(config.termine.mailListe), Query.select(LISTENFELDER), diff --git a/test/analyse.test.js b/test/analyse.test.js index f6d4578..c3dad35 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -356,7 +356,8 @@ test('Kunde ohne actor-Namen oder Mitarbeiter ohne Textform: 400', async () => { test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dokument gleich', async () => { aw.seed('websiteProjects', { $id: 'p14', customerId: 'c1', projectName: 'Historie', subdomain: 'historie', isPublic: true }) - const { doc: d1 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() }) + // feste Zeiten vor NOW: mit der echten Uhr kippt die Reihenfolge, sobald die Uhr NOW überholt + const { doc: d1 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust({ now: new Date(NOW.getTime() - 60_000) }) }) let h = await consents('p14') assert.equal(h.length, 1) assert.equal(h[0].event, 'on') @@ -375,8 +376,8 @@ test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dok assert.equal(d1.avvConsentDate, '') assert.equal(d1.avvConsentChannel, '') // Farbwechsel und erneutes Aktivieren: keine neue Zeile - await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', accentColor: '#222222', ...cust() }) - await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() }) + await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', accentColor: '#222222', ...cust({ now: new Date(NOW.getTime() - 50_000) }) }) + await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust({ now: new Date(NOW.getTime() - 40_000) }) }) assert.equal((await consents('p14')).length, 1) // Marker zurücksetzen beim Wiedereinschalten await updateDocument(config.collections.portalFeatures, d1.$id, { removalTicketId: 'r1', publishTicketId: 'u1' }) @@ -418,14 +419,15 @@ test('recordConsent scheitert: putSite wird zurückgenommen, Dokument bleibt aus test('Ausschalten schreibt off-Historie; Fehler dort verhindert das Ausschalten nicht', async () => { aw.seed('websiteProjects', { $id: 'p16', customerId: 'c1', projectName: 'Aus', subdomain: 'aus', isPublic: true }) - await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() }) + // feste Zeiten vor NOW (siehe Historie-Test) + await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust({ now: new Date(NOW.getTime() - 120_000) }) }) const prev = an.analyseDeps.recordConsent an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') } const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor }) an.analyseDeps.recordConsent = prev assert.equal(r.doc.state, 'off') assert.deepEqual((await consents('p16')).map((x) => x.event), ['on']) - await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() }) + await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust({ now: new Date(NOW.getTime() - 60_000) }) }) await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor }) assert.deepEqual((await consents('p16')).map((x) => x.event), ['on', 'on', 'off']) }) diff --git a/test/appwriteBulk.test.js b/test/appwriteBulk.test.js index 6b9c3a9..920c576 100644 --- a/test/appwriteBulk.test.js +++ b/test/appwriteBulk.test.js @@ -17,7 +17,7 @@ const plain = await startAppwriteMock() const st = await startFakeStripe() after(() => Promise.all([aw.close(), plain.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) -const { Query, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments, countDocuments } = await import( +const { Query, keinerVon, dokumenteUrl, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments, countDocuments } = await import( '../server/services/appwriteAdmin.js' ) @@ -41,7 +41,36 @@ test('Query-Helfer bauen die Appwrite-Abfragen', () => { assert.deepEqual(Query.cursorAfter('doc1'), { method: 'cursorAfter', values: ['doc1'] }) assert.deepEqual(Query.select(['a', 'b']), { method: 'select', values: ['a', 'b'] }) assert.deepEqual(Query.notEqual('status', 'x'), { method: 'notEqual', attribute: 'status', values: ['x'] }) - assert.deepEqual(Query.notEqual('status', ['x', 'y']), { method: 'notEqual', attribute: 'status', values: ['x', 'y'] }) +}) + +// Appwrite 1.8.1 (gemessen 2026-10-05): notEqual mit Liste => HTTP 400 "NotEqual queries require exactly one value." +test('Query.notEqual nimmt genau einen Wert; eine Liste wirft einen TypeError, der keinerVon nennt', () => { + assert.throws(() => Query.notEqual('status', ['x', 'y']), (err) => err instanceof TypeError && /keinerVon/.test(err.message)) + assert.throws(() => Query.notEqual('status', []), (err) => err instanceof TypeError && /keinerVon/.test(err.message)) + assert.throws(() => Query.notEqual('status', ['x']), TypeError, 'auch eine Liste mit einem Wert: der Aufrufer soll den Wert selbst uebergeben') +}) + +test('keinerVon baut je Wert eine notEqual-Abfrage mit genau einem Wert (Appwrite schneidet sie)', () => { + assert.deepEqual(keinerVon('status', ['x', 'y', 'z']), [ + { method: 'notEqual', attribute: 'status', values: ['x'] }, + { method: 'notEqual', attribute: 'status', values: ['y'] }, + { method: 'notEqual', attribute: 'status', values: ['z'] }, + ]) + assert.deepEqual(keinerVon('status', []), []) + assert.deepEqual(keinerVon('status', new Set(['x'])), [{ method: 'notEqual', attribute: 'status', values: ['x'] }]) + assert.throws(() => keinerVon('status', [['x', 'y']]), TypeError, 'ein Eintrag ist ein einzelner Wert, keine Liste') +}) + +test('dokumenteUrl: dieselbe Serialisierung wie die Anfrage an Appwrite (eine queries[] je Abfrage, URL-kodiert)', () => { + const url = dokumenteUrl('termineOutbox', [Query.equal('status', 'wartet'), ...keinerVon('art', ['G01', 'G02'])]) + const parsed = new URL(url) + assert.match(parsed.pathname, /\/collections\/termineOutbox\/documents$/) + assert.deepEqual(parsed.searchParams.getAll('queries[]').map((q) => JSON.parse(q)), [ + { method: 'equal', attribute: 'status', values: ['wartet'] }, + { method: 'notEqual', attribute: 'art', values: ['G01'] }, + { method: 'notEqual', attribute: 'art', values: ['G02'] }, + ]) + assert.ok(url.includes('queries%5B%5D='), 'die Klammern stehen kodiert in der URL, so zaehlt der nginx sie') }) test('Bulk-Create legt 3 Dokumente in einer Anfrage an', async () => { @@ -204,20 +233,52 @@ test('select liefert nur die genannten Felder plus alle $-Felder', async () => { assert.equal((await getDocument('termineOutbox', 't2_f_sel00')).featureId, 'f_sel') }) -test('notEqual schliesst einen Wert oder alle Werte der Liste aus', async () => { +test('notEqual schliesst einen Wert aus, mehrere notEqual schneiden sich (keinerVon)', async () => { seedOutbox('f_ne', 4, (i) => ({ status: ['wartet', 'gesendet', 'fehler', 'abgebrochen'][i] })) const filter = Query.equal('featureId', 'f_ne') const order = Query.orderAsc('$id') - const without = async (value) => (await listDocuments('termineOutbox', [filter, Query.notEqual('status', value), order])).map((d) => d.status) + const without = async (...werte) => (await listDocuments('termineOutbox', [filter, ...keinerVon('status', werte), order])).map((d) => d.status) assert.deepEqual(await without('wartet'), ['gesendet', 'fehler', 'abgebrochen']) - assert.deepEqual(await without(['gesendet', 'fehler']), ['wartet', 'abgebrochen']) - assert.deepEqual(await without(['wartet', 'gesendet', 'fehler', 'abgebrochen']), []) - assert.deepEqual(await without(['gibtEsNicht']), ['wartet', 'gesendet', 'fehler', 'abgebrochen']) - // zwei notEqual auf demselben Feld schneiden sich (beide Bedingungen muessen gelten) + assert.deepEqual(await without('gesendet', 'fehler'), ['wartet', 'abgebrochen']) + assert.deepEqual(await without('wartet', 'gesendet', 'fehler', 'abgebrochen'), []) + assert.deepEqual(await without('gibtEsNicht'), ['wartet', 'gesendet', 'fehler', 'abgebrochen']) + assert.deepEqual(await without(), ['wartet', 'gesendet', 'fehler', 'abgebrochen'], 'ohne Werte keine Abfrage') + // von Hand zwei notEqual auf demselben Feld: beide Bedingungen muessen gelten const both = await listDocuments('termineOutbox', [filter, Query.notEqual('status', 'wartet'), Query.notEqual('status', 'fehler'), order]) assert.deepEqual(both.map((d) => d.status), ['gesendet', 'abgebrochen']) }) +// Appwrite 1.8.1 (M1): HTTP 400 general_query_invalid; der Nachbau lehnt eine Liste genauso ab +test('Nachbau: notEqual mit Liste (oder ohne Wert) => HTTP 400 wie Appwrite, auch ueber den rohen Pfad', async () => { + seedOutbox('f_ne400', 2) + const roh = (values) => JSON.stringify({ method: 'notEqual', attribute: 'status', values }) + for (const values of [['a', 'b'], ['a', 'b', 'c'], []]) { + const antwort = await fetch(`${rawDocs(aw, 'termineOutbox')}?queries[]=${encodeURIComponent(roh(values))}`) + assert.equal(antwort.status, 400, JSON.stringify(values)) + assert.deepEqual(await antwort.json(), { message: 'Invalid query: NotEqual queries require exactly one value.', code: 400, type: 'general_query_invalid' }) + } + // durch appwriteAdmin: derselbe Fehler (status, type), kein Treffer + await assert.rejects( + listDocuments('termineOutbox', [roh(['a', 'b'])]), + (err) => err.status === 400 && err.type === 'general_query_invalid' && /exactly one value/.test(err.message), + ) + // ein Wert geht + assert.equal((await listDocuments('termineOutbox', [roh(['a']), Query.equal('featureId', 'f_ne400')])).length, 2) +}) + +// Appwrite 1.8.1 (M3): SQL-Semantik, ein Dokument ohne Wert (null oder nie gesetzt) passt zu notEqual nicht +test('Nachbau: ein Dokument mit Feld null oder ohne Feld passt nicht zu notEqual', async () => { + aw.seed('termineOutbox', { $id: 't2_f_nl00', featureId: 'f_nl', status: 'wartet' }) + aw.seed('termineOutbox', { $id: 't2_f_nl01', featureId: 'f_nl', status: null }) + aw.seed('termineOutbox', { $id: 't2_f_nl02', featureId: 'f_nl' }) + aw.seed('termineOutbox', { $id: 't2_f_nl03', featureId: 'f_nl', status: 'gesendet' }) + const ids = async (...queries) => (await listDocuments('termineOutbox', [Query.equal('featureId', 'f_nl'), ...queries, Query.orderAsc('$id')])).map((d) => d.$id) + assert.deepEqual(await ids(Query.notEqual('status', 'wartet')), ['t2_f_nl03'], 'null und fehlend sind ausgeschlossen') + assert.deepEqual(await ids(Query.notEqual('status', 'gibtEsNicht')), ['t2_f_nl00', 't2_f_nl03']) + assert.deepEqual(await ids(...keinerVon('status', ['wartet', 'gesendet'])), []) + assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nl'), Query.notEqual('status', 'wartet')]), 1) +}) + test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countDocuments liefert es', async () => { seedOutbox('f_total', 5) seedOutbox('f_total_fremd', 3) @@ -229,7 +290,7 @@ test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countD assert.equal(await totalOf([Query.equal('featureId', 'f_nirgends'), Query.limit(2)]), 0) assert.equal(await totalOf([filter]), 5, 'ohne limit gilt der Standard von 25, total bleibt die Trefferzahl') assert.equal(await countDocuments('termineOutbox', [filter]), 5) - assert.equal(await countDocuments('termineOutbox', [filter, Query.notEqual('$id', ['t2_f_total00', 't2_f_total01'])]), 3) + assert.equal(await countDocuments('termineOutbox', [filter, ...keinerVon('$id', ['t2_f_total00', 't2_f_total01'])]), 3) assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nirgends')]), 0) // 30 Treffer ueber dem Standardlimit 25: total sagt 30, die Liste hat 25 seedOutbox('f_total_dreissig', 30) diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 3a7eade..26f6734 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -1,4 +1,4 @@ -import { test, after, beforeEach } from 'node:test' +import { test, after, beforeEach, mock } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { startAppwriteMock } from './support/appwriteMock.js' @@ -149,52 +149,155 @@ test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4 assert.ok(k03u.text.includes('§ 11 der Vertragsbedingungen'), k03u.text) }) -// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden +// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden. +// Entscheidung 2026-10-05: nur, wenn der Kunde eine von beiden hat (Funktionsdokument analyse oder termine in einem Zustand ausser off). const FUNKTIONEN_SATZ = 'Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm.' const WARTUNG_V4 = { productKey: 'wartung', termsVersion: config.abo.termsVersion } +const WARTUNG = { patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } } const k02 = () => bySubject('Bestätigung Ihrer Kündigung') - -test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos', async () => { - await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }) +const FUNKTION_DOC = 'fk_funktion' +/** Fuehrt fn mit einem Funktionsdokument von c1 aus (doc null: keins; doc ueberschreibt die Vorgaben) und raeumt es danach auf. */ +async function mitFunktion(doc, fn) { + if (doc) aw.seed('portalFeatures', { $id: FUNKTION_DOC, customerId: 'c1', projectId: 'fk_p', featureKey: 'analyse', state: 'active', enabled: doc.state === 'active', ...doc }) + try { + return await fn() + } finally { + await deleteDocument(config.collections.portalFeatures, FUNKTION_DOC).catch(() => {}) + } +} +/** K03: Abo-Kuendigung ueber die Kuendigungsseite; liefert die Mail. setup = Parameter von resetSubH. */ +async function kuendigeMitK03(setup = {}) { + await resetSubH(setup) assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200) - assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text) - assert.ok(k03().text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), k03().text) + assert.ok(k03(), 'K03') + return k03() +} +const gekuendigt = (id) => ({ + id, + object: 'event', + type: 'customer.subscription.updated', + created: Math.floor(Date.now() / 1000), + data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } }, +}) +let k02Nummer = 0 +/** K02: Kuendigung im Stripe-Kundenportal (Webhook); liefert die Mail. setup = Parameter von resetSubH. */ +async function kuendigeMitK02(setup = {}) { + await resetSubH({ patch: { ...setup.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...setup.stripe } }) + const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.ok(k02(), 'K02') + return k02() +} - // Hosting-Abo: weder Analyse noch Terminplaner - await resetSubH() - assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200) - assert.doesNotMatch(k03().text, /Terminplaner/) +test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos mit Analyse oder Terminplaner', async () => { + await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => { + const mail = await kuendigeMitK03(WARTUNG) + assert.ok(mail.text.includes(FUNKTIONEN_SATZ), mail.text) + assert.ok(mail.text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), mail.text) - // nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis - await resetSubH({ units: 3, patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }) - assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200) - assert.doesNotMatch(k03().text, /Terminplaner/) + // Hosting-Abo: weder Analyse noch Terminplaner + assert.doesNotMatch((await kuendigeMitK03()).text, /Terminplaner/) - // unbekannte E-Mail (kein Abo gefunden): kein Hinweis - await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }) - assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200) - assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/) + // nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis + await resetSubH({ units: 3, ...WARTUNG }) + assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200) + assert.doesNotMatch(k03().text, /Terminplaner/) + + // unbekannte E-Mail (kein Abo gefunden): kein Hinweis + await resetSubH(WARTUNG) + assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200) + assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/) + }) }) -test('K02: Kündigung im Stripe-Kundenportal, Hinweis auf das Ende der Funktionen nur beim Wartungs-Abo', async () => { - const gekuendigt = (id) => ({ - id, - object: 'event', - type: 'customer.subscription.updated', - created: Math.floor(Date.now() / 1000), - data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } }, +// (a) bis (d): welche Funktionsdokumente des Kunden den Hinweis ausloesen +const FUNKTION_FAELLE = [ + ['ohne Funktionsdokument', null, false], + ['aktive Analyse', { featureKey: 'analyse', state: 'active' }, true], + ['Terminplaner im Zustand setup', { featureKey: 'termine', state: 'setup' }, true], + ['Terminplaner bestellt (ordered)', { featureKey: 'termine', state: 'ordered' }, true], + ['abgeschalteter Terminplaner (disabled)', { featureKey: 'termine', state: 'disabled' }, true], + ['nur ein Terminplaner-Dokument im Zustand off', { featureKey: 'termine', state: 'off' }, false], + ['nur ein Analyse-Dokument im Zustand off', { featureKey: 'analyse', state: 'off' }, false], + ['Dokument ohne Zustand (gilt wie off)', { featureKey: 'termine', state: '' }, false], + ['aktive News-Pinnwand (keine Funktion des Hinweises)', { featureKey: 'news', state: 'active' }, false], + ['aktive Analyse eines anderen Kunden', { featureKey: 'analyse', state: 'active', customerId: 'c2' }, false], +] +for (const [name, doc, erwartet] of FUNKTION_FAELLE) { + const wort = erwartet ? 'mit' : 'ohne' + test(`K03, Wartungs-Abo: ${name} => ${wort} Hinweis`, async () => { + const mail = await mitFunktion(doc, () => kuendigeMitK03(WARTUNG)) + assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text) + assert.equal(/Terminplaner/.test(mail.text), erwartet) + assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung') }) - for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) { - const patch = productKey === 'wartung' ? WARTUNG_V4 : {} - await resetSubH({ patch: { ...patch, cancelAtPeriodEnd: true }, stripe: { productKey, cancel_at_period_end: true, metadata: productKey === 'wartung' ? { termsVersion: config.abo.termsVersion } : {} } }) - const res = await postWebhook(gekuendigt(`evt_k02_${productKey}`)) - assert.equal(res.status, 200, JSON.stringify(res.body)) - assert.ok(k02(), `${productKey}: K02`) - assert.equal(k02().text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${k02().text}`) - assert.equal(/Terminplaner/.test(k02().text), mitHinweis, productKey) + test(`K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: ${name} => ${wort} Hinweis`, async () => { + const mail = await mitFunktion(doc, () => kuendigeMitK02(WARTUNG)) + assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text) + assert.equal(/Terminplaner/.test(mail.text), erwartet) + assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung') + }) +} + +// (f) Hosting-Abo: nie ein Hinweis, auch nicht mit aktiver Analyse; die Funktionen werden dafuer gar nicht erst gelesen +test('K03 und K02, Hosting-Abo: kein Hinweis (auch mit aktiver Analyse), Funktionsdokumente werden nicht gelesen', async () => { + await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => { + aw.resetListCalls() + const mail03 = await kuendigeMitK03() + assert.doesNotMatch(mail03.text, /Terminplaner/) + const mail02 = await kuendigeMitK02({ stripe: { productKey: 'hosting' } }) + assert.doesNotMatch(mail02.text, /Terminplaner/) + assert.equal(aw.listCalls('portalFeatures').length, 0, 'kein Lesen der Funktionen beim Hosting-Abo') + }) +}) + +// (e) Lesefehler: lieber informieren als nicht; die Mail geht in jedem Fall raus, eine Zeile im Log +test('K03, Wartungs-Abo: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => { + const log = mock.method(console, 'error', () => {}) + try { + await resetSubH(WARTUNG) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' }) + assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200) + assert.ok(k03(), 'K03 geht trotzdem raus') + assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text) + assert.ok(t08(), 'T08 ans Team') + const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen')) + assert.equal(zeilen.length, 1, zeilen.join('\n')) + assert.match(zeilen[0], /\[abo\] Funktionen/) + } finally { + log.mock.restore() } }) +test('K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => { + const log = mock.method(console, 'error', () => {}) + try { + await resetSubH({ patch: { ...WARTUNG.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...WARTUNG.stripe } }) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' }) + const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.ok(k02(), 'K02 geht trotzdem raus') + assert.ok(k02().text.includes(FUNKTIONEN_SATZ), k02().text) + assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, 'die Kündigung wird danach noch vermerkt') + const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen')) + assert.equal(zeilen.length, 1, zeilen.join('\n')) + assert.ok(!log.mock.calls.some((c) => String(c.arguments[0]).includes('Kuendigungsbestaetigung')), 'kein Fehler der Bestätigung') + } finally { + log.mock.restore() + } +}) + +test('K02: Kündigung im Stripe-Kundenportal, Hinweis nur beim Wartungs-Abo mit Funktion (Hosting-Abo nie)', async () => { + await mitFunktion({ featureKey: 'termine', state: 'active' }, async () => { + for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) { + const setup = productKey === 'wartung' ? WARTUNG : { patch: {}, stripe: { productKey, metadata: {} } } + const mail = await kuendigeMitK02(setup) + assert.equal(mail.text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${mail.text}`) + assert.equal(/Terminplaner/.test(mail.text), mitHinweis, productKey) + } + }) +}) + test('2 von 3 Paketen ergibt Menge 1', async () => { await resetSubH({ units: 3 }) const res = await cancel({ contract: 'zusatzspeicher', packages: '2' }) diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index a13d720..f965675 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -11,8 +11,10 @@ import http from 'node:http' * * Bulk-Create (POST .../documents mit { documents: [{ $id, ...felder }] }, 1 bis 100 Dokumente) ist alles oder * nichts: vorhandene oder doppelte $id => 409, ungueltige $id, unbekanntes Attribut oder ungueltiges Datum => 400, - * dann ist nichts geschrieben. Abfragen: equal, notEqual (mit Liste: keiner von; ein Dokument ohne das Feld passt), - * greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die + * dann ist nichts geschrieben. Abfragen: equal (mit Liste: einer von), notEqual (wie Appwrite 1.8.1, gemessen 2026-10-05: + * genau EIN Wert, sonst HTTP 400 "Invalid query: NotEqual queries require exactly one value." general_query_invalid; mehrere + * notEqual in einer Anfrage werden geschnitten; ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL- + * Semantik), greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die * genannten Felder plus alle $-Felder). Die Antwort traegt total = Treffer der Filter, unabhaengig von limit und * cursorAfter. listCalls(collection?) / resetListCalls() zeichnen die Listenabfragen auf. Zwei Werte, die beide * wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte (Schreibweise Z, +00:00, +02:00 ist egal), alle @@ -133,6 +135,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi case 'equal': return query.values.some((candidate) => same(value, candidate)) case 'notEqual': + // SQL: NULL <> 'x' ist nicht wahr, ein Dokument ohne Wert faellt heraus (Appwrite 1.8.1) + if (value === undefined || value === null) return false return !query.values.some((candidate) => same(value, candidate)) case 'greaterThan': return greater(value, first) @@ -158,6 +162,10 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi const queries = rawQueries.map((q) => JSON.parse(q)) const unsupported = queries.find((q) => !supportedQueries.includes(q.method)) if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }] + // Appwrite 1.8.1: notEqual nimmt genau einen Wert (der Nachbau lehnt die Liste wie die echte Datenbank ab) + if (queries.some((q) => q.method === 'notEqual' && (!Array.isArray(q.values) || q.values.length !== 1))) { + return [400, { message: 'Invalid query: NotEqual queries require exactly one value.', code: 400, type: 'general_query_invalid' }] + } // cursorAfter: das Dokument mit dieser ID muss es geben (es muss aber nicht zu den Filtern passen) const cursorId = queries.find((q) => q.method === 'cursorAfter')?.values[0] if (cursorId !== undefined && !docs(collection).has(cursorId)) { diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index a6f73c4..7a9c045 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -18,13 +18,13 @@ after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') -const { createDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { createDocument, deleteDocument, dokumenteUrl } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { loadTemplate } = await import('../server/services/mailTemplates.js') const { icsTermin } = await import('../server/services/termine/ics.js') const O = await import('../server/services/termine/outbox.js') const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O -const { MAIL_VORRANG, MAIL_NACHRANGIG, mailKlasse, pruefePlatz, mailBudget, verworfeneG01 } = O +const { MAIL_VORRANG, MAIL_NACHRANGIG, AUSSCHLUSS_MAX, mailKlasse, pruefePlatz, mailBudget, verworfeneG01 } = O after(() => setMailTransportForTests(null)) const MIN = 60000 @@ -509,15 +509,21 @@ test('Reserve: mit sehr kleinem Stundenbudget darf mindestens eine nachrangige M // --------------------------------------------------------------------------------------------------------------------- const listenAbfragen = () => aw.listCalls('termineOutbox') +// Appwrite 1.8.1: notEqual nimmt genau einen Wert, mehrere notEqual werden geschnitten. Die normale Klasse sind darum sechs +// notEqual auf art, die Ausschlussliste je Funktion eine notEqual auf featureId. const abfrageKlasse = ({ queries }) => { const art = queries.find((q) => q.attribute === 'art') if (art.method === 'notEqual') return 'normal' return art.values.includes('G01') ? 'vorrang' : 'nachrangig' } const abfrageKlassen = () => listenAbfragen().map(abfrageKlasse) -const ausschluss = ({ queries }) => queries.find((q) => q.method === 'notEqual' && q.attribute === 'featureId')?.values ?? null +const ausschluss = ({ queries }) => { + const ids = queries.filter((q) => q.method === 'notEqual' && q.attribute === 'featureId').map((q) => q.values[0]) + return ids.length ? ids : null +} const begrenzteLeseabfragen = (abfragen) => { for (const abfrage of abfragen) { + assert.ok(abfrage.queries.filter((q) => q.method === 'notEqual').every((q) => q.values.length === 1), `notEqual mit genau einem Wert (Appwrite 1.8.1): ${JSON.stringify(abfrage.queries)}`) const limit = abfrage.queries.find((q) => q.method === 'limit') assert.ok(limit && limit.values[0] <= 100, `limit höchstens 100: ${JSON.stringify(abfrage.queries)}`) assert.ok(!abfrage.queries.some((q) => q.method === 'cursorAfter' || (q.method === 'orderAsc' && q.attribute === '$id')), 'kein $id-Seitenlauf') @@ -606,6 +612,31 @@ test('Reihenfolge: Vorrang vor normal vor nachrangig, innerhalb einer Klasse die } }) +// G5 (2026-10-05): auf der echten Appwrite 1.8.1 lehnt notEqual mit Liste die Abfrage ab (HTTP 400). Die normale Klasse warf dann bei jedem Lauf, +// die nachrangige (G05, K20, K21) kam nie dran. Der Nachbau lehnt es jetzt genauso ab. +test('Live-Appwrite: ein Lauf mit fälligen Einträgen aller drei Klassen bedient Vorrang, normal UND nachrangig (G05, K20, K21)', async () => { + const arten = ['G02', 'G08', 'G03', 'K17', 'G05', 'K20', 'K21'] + const docs = [] + for (const [i, art] of arten.entries()) { + docs.push(await legeAn({ art, bookingId: `b_${art}`, an: `${art.toLowerCase()}@example.com`, naechsterVersuch: plus(-(10 - i) * MIN).toISOString() })) + } + aw.resetListCalls() + const ergebnis = await verarbeite(JETZT) + assert.deepEqual(ergebnis, { faellig: 7, gesendet: 7 }) + for (const doc of docs) assert.equal((await lies(doc.$id)).status, 'gesendet', doc.art) + assert.deepEqual(empfaenger(), ['g02@example.com', 'g08@example.com', 'g03@example.com', 'k17@example.com', 'g05@example.com', 'k20@example.com', 'k21@example.com']) + assert.deepEqual(abfrageKlassen(), ['vorrang', 'normal', 'nachrangig']) + begrenzteLeseabfragen(listenAbfragen()) + // die normale Klasse: sechs einzelne notEqual auf art, zusammen alle Arten der anderen Klassen + const normal = listenAbfragen().find((a) => abfrageKlasse(a) === 'normal') + const ausgeschlossen = normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'art') + assert.ok(ausgeschlossen.every((q) => q.values.length === 1)) + assert.deepEqual(ausgeschlossen.map((q) => q.values[0]).sort(), [...MAIL_VORRANG, ...MAIL_NACHRANGIG].sort()) + // vorrang und nachrangig: equal mit Liste ist bei Appwrite erlaubt + const nachrangig = listenAbfragen().find((a) => abfrageKlasse(a) === 'nachrangig') + assert.deepEqual(nachrangig.queries.find((q) => q.attribute === 'art'), { method: 'equal', attribute: 'art', values: [...MAIL_NACHRANGIG] }) +}) + test('kein Verhungern: 150 fällige G02 der Funktion A (Kontingent 40) vor einem G02 der Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B', async () => { for (let i = 0; i < 150; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() }) const b = await legeAn({ featureId: 'f_B', art: 'G02', bookingId: 'b_b', an: 'b@example.com', naechsterVersuch: plus(-1000).toISOString() }) @@ -633,21 +664,38 @@ test('Runden: höchstens 5 Abfragen je Klasse; mailListe wird bei jedem Aufruf g assert.deepEqual(await verarbeite(JETZT), { faellig: 2, gesendet: 2 }) }) -test('Ausschlussliste: höchstens 90 Funktionen je Abfrage, auch wenn mehr blockiert sind', async () => { +// Appwrite 1.8.1 (M4): ab etwa 45 featureId-notEqual in dieser Abfrageform antwortet der nginx mit 414 (URL zu lang); 30 lassen Luft +test('Ausschlussliste: höchstens AUSSCHLUSS_MAX (30) Funktionen je Abfrage, auch wenn mehr blockiert sind; die URL bleibt unter 6000 Zeichen', async () => { + assert.equal(AUSSCHLUSS_MAX, 30) config.termine.mailsProFunktion = 1 + // 36 Zeichen lange Funktions-IDs: so lang sind die Dokument-IDs in Produktion + const feature = (i) => `f-${String(i).padStart(34, '0')}` + assert.equal(feature(7).length, 36) for (let i = 0; i < 100; i++) { for (const teil of ['a', 'b']) { - await legeAn({ featureId: `f_${i}`, art: 'G02', bookingId: `b_${i}${teil}`, naechsterVersuch: plus(-3600000 + (2 * i + (teil === 'a' ? 0 : 1)) * 1000).toISOString() }) + await legeAn({ featureId: feature(i), art: 'G02', bookingId: `b_${i}${teil}`, naechsterVersuch: plus(-3600000 + (2 * i + (teil === 'a' ? 0 : 1)) * 1000).toISOString() }) } } aw.resetListCalls() - assert.deepEqual(await verarbeite(JETZT), { faellig: 210, gesendet: 100 }) + // Runde 1: Seite 1 (f0 bis f49) sendet 50, blockiert 50. Runde 2 (die ersten 30 ausgeschlossen): f30b bis f49b sind wieder dabei, dahinter + // f50 bis f89 (40 gesendet). Runde 3: 60 + 20 Eintraege, die letzten 10 gehen raus. Die Seite ist dann nicht mehr voll. + assert.deepEqual(await verarbeite(JETZT), { faellig: 280, gesendet: 100 }) const abfragen = listenAbfragen() + begrenzteLeseabfragen(abfragen) const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang') - assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 50, 90], 'Runde 3: 100 blockierte Funktionen, 90 davon in der Liste') - for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= 90) - assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === 90), 'blockierte Funktionen gelten für alle Klassen') + assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 30, 30], '100 Funktionen blockiert, aber nur 30 in der Liste') + assert.deepEqual(ausschluss(vorrang[1]), Array.from({ length: 30 }, (_, i) => feature(i)), 'die zuerst blockierten') + for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= AUSSCHLUSS_MAX) + assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === AUSSCHLUSS_MAX), 'blockierte Funktionen gelten für alle Klassen') assert.equal(alle().filter((d) => d.status === 'gesendet').length, 100) + + // Schlimmster Fall: die normale Klasse (6 notEqual auf art) mit voller Ausschlussliste. Dieselbe Serialisierung wie die Anfrage an Appwrite. + const normal = abfragen.find((a) => abfrageKlasse(a) === 'normal') + assert.equal(normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'art').length, 6) + assert.equal(normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'featureId').length, 30) + const laengen = abfragen.map((a) => dokumenteUrl(col(), a.queries).length) + assert.ok(Math.max(...laengen) < 6000, `längste Abfrage-URL ${Math.max(...laengen)} Zeichen`) + assert.equal(laengen[abfragen.indexOf(normal)], Math.max(...laengen), 'die normale Klasse ist der längste Fall') }) test('Reserve erreicht: keine Abfrage der nachrangigen Klasse mehr; Vorrang und normal kommen trotzdem dran', async () => {