Termine-Outbox: notEqual mit genau einem Wert (Appwrite 1.8.1), nachrangige Mails kommen wieder dran
Auf der echten Appwrite 1.8.1 lehnt notEqual mit Liste die Abfrage ab (HTTP 400
general_query_invalid, "NotEqual queries require exactly one value"). Die Klasse normal der
Mail-Warteschlange schickte immer eine Liste, jeder Lauf warf dort, und die nachrangige Klasse
(G05, K20, K21) kam nie dran.
- appwriteAdmin: Query.notEqual nimmt genau einen Wert (eine Liste wirft einen TypeError, der
keinerVon nennt); neu keinerVon(attribute, werte) = je Wert ein notEqual (Appwrite schneidet
sie); dokumenteUrl() baut die Listen-URL ohne Netz, mit derselben Serialisierung wie die Anfrage.
- outbox: klassenFilter liefert fuer jede Klasse eine Liste von Abfragen (normal = sechs notEqual
auf art), die Ausschlussliste ist keinerVon('featureId', ...). AUSSCHLUSS_MAX 90 -> 30 (der nginx
antwortet in dieser Abfrageform ab 46 Funktions-IDs mit 414); der Kommentar "100 Werte je Abfrage"
entfaellt.
- Nachbau (appwriteMock): notEqual mit Liste oder ohne Wert => 400 wie Appwrite; ein Dokument mit
Feld null oder ohne Feld passt nicht zu notEqual (SQL-Semantik).
- Tests: Query-Helfer, keinerVon, dokumenteUrl, Nachbau (400, null), ein Lauf bedient alle drei
Klassen, Ausschlussliste hoechstens 30 und URL unter 6000 Zeichen im schlimmsten Fall.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af63b69ec1
commit
2c7cd26532
5 files changed
+196
-39
No files matched your search
@@ -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() {
|
function adminHeaders() {
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -33,10 +45,7 @@ async function adminFetch(path, { method = 'GET', body, queries = [], raw = fals
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(`${config.appwrite.endpoint}${path}`)
|
const url = buildUrl(path, queries)
|
||||||
for (const q of buildQueries(queries)) {
|
|
||||||
url.searchParams.append('queries[]', q)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useRawBody = raw || path.startsWith('/users')
|
const useRawBody = raw || path.startsWith('/users')
|
||||||
const requestBody = formatRequestBody(body, method, { raw: useRawBody })
|
const requestBody = formatRequestBody(body, method, { raw: useRawBody })
|
||||||
@@ -93,12 +102,30 @@ export const Query = {
|
|||||||
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
||||||
/** Nur diese Felder zurueckgeben (die $-Felder wie $id liefert Appwrite immer mit) */
|
/** Nur diese Felder zurueckgeben (die $-Felder wie $id liefert Appwrite immer mit) */
|
||||||
select: (attributes) => ({ method: 'select', values: attributes }),
|
select: (attributes) => ({ method: 'select', values: attributes }),
|
||||||
/** Ein Wert oder eine Liste: kein Dokument mit einem der Werte ("keiner von") */
|
/**
|
||||||
notEqual: (attribute, value) => ({
|
* Kein Dokument mit diesem Wert. Genau EIN Wert: Appwrite 1.8.1 lehnt eine Liste ab (HTTP 400 general_query_invalid
|
||||||
method: 'notEqual',
|
* "NotEqual queries require exactly one value.", gemessen 2026-10-05, M1). Fuer mehrere Werte keinerVon(attribute, werte).
|
||||||
attribute,
|
* Zwei Eigenheiten der echten Datenbank, die auch der Nachbau der Tests hat: mehrere notEqual in einer Anfrage werden
|
||||||
values: Array.isArray(value) ? value : [value],
|
* 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<string|number|boolean>} werte
|
||||||
|
* @returns {object[]}
|
||||||
|
*/
|
||||||
|
export function keinerVon(attribute, werte) {
|
||||||
|
return [...(werte ?? [])].map((wert) => Query.notEqual(attribute, wert))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ID = {
|
export const ID = {
|
||||||
@@ -109,6 +136,11 @@ function collectionPath(collectionId) {
|
|||||||
return `/databases/${config.appwrite.databaseId}/collections/${collectionId}/documents`
|
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) {
|
export async function getUserById(userId) {
|
||||||
return adminFetch(`/users/${userId}`)
|
return adminFetch(`/users/${userId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { config } from '../../config.js'
|
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 { withLocks } from '../keyedLock.js'
|
||||||
import { sendGuestMail } from '../mailer.js'
|
import { sendGuestMail } from '../mailer.js'
|
||||||
import { renderMail } from '../mailTemplates.js'
|
import { renderMail } from '../mailTemplates.js'
|
||||||
@@ -372,16 +372,24 @@ async function speichereFehlschlag(doc, err, nowMs) {
|
|||||||
const KLASSEN = ['vorrang', 'normal', 'nachrangig']
|
const KLASSEN = ['vorrang', 'normal', 'nachrangig']
|
||||||
/** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */
|
/** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */
|
||||||
const RUNDEN_MAX = 5
|
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. */
|
/** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */
|
||||||
const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch']
|
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) {
|
function klassenFilter(klasse) {
|
||||||
if (klasse === 'vorrang') return Query.equal('art', [...MAIL_VORRANG])
|
if (klasse === 'vorrang') return [Query.equal('art', [...MAIL_VORRANG])]
|
||||||
if (klasse === 'nachrangig') return Query.equal('art', [...MAIL_NACHRANGIG])
|
if (klasse === 'nachrangig') return [Query.equal('art', [...MAIL_NACHRANGIG])]
|
||||||
return Query.notEqual('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
|
return keinerVon('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */
|
/** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */
|
||||||
@@ -389,8 +397,8 @@ function listenAbfrage(klasse, nowMs, blockiert) {
|
|||||||
return [
|
return [
|
||||||
Query.equal('status', 'wartet'),
|
Query.equal('status', 'wartet'),
|
||||||
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
|
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
|
||||||
klassenFilter(klasse),
|
...klassenFilter(klasse),
|
||||||
...(blockiert.size > 0 ? [Query.notEqual('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX))] : []),
|
...keinerVon('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX)),
|
||||||
Query.orderAsc('naechsterVersuch'),
|
Query.orderAsc('naechsterVersuch'),
|
||||||
Query.limit(config.termine.mailListe),
|
Query.limit(config.termine.mailListe),
|
||||||
Query.select(LISTENFELDER),
|
Query.select(LISTENFELDER),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const plain = await startAppwriteMock()
|
|||||||
const st = await startFakeStripe()
|
const st = await startFakeStripe()
|
||||||
after(() => Promise.all([aw.close(), plain.close(), st.close()]))
|
after(() => Promise.all([aw.close(), plain.close(), st.close()]))
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
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'
|
'../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.cursorAfter('doc1'), { method: 'cursorAfter', values: ['doc1'] })
|
||||||
assert.deepEqual(Query.select(['a', 'b']), { method: 'select', values: ['a', 'b'] })
|
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'), { 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 () => {
|
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')
|
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] }))
|
seedOutbox('f_ne', 4, (i) => ({ status: ['wartet', 'gesendet', 'fehler', 'abgebrochen'][i] }))
|
||||||
const filter = Query.equal('featureId', 'f_ne')
|
const filter = Query.equal('featureId', 'f_ne')
|
||||||
const order = Query.orderAsc('$id')
|
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('wartet'), ['gesendet', 'fehler', 'abgebrochen'])
|
||||||
assert.deepEqual(await without(['gesendet', 'fehler']), ['wartet', 'abgebrochen'])
|
assert.deepEqual(await without('gesendet', 'fehler'), ['wartet', 'abgebrochen'])
|
||||||
assert.deepEqual(await without(['wartet', 'gesendet', 'fehler', 'abgebrochen']), [])
|
assert.deepEqual(await without('wartet', 'gesendet', 'fehler', 'abgebrochen'), [])
|
||||||
assert.deepEqual(await without(['gibtEsNicht']), ['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(), ['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])
|
const both = await listDocuments('termineOutbox', [filter, Query.notEqual('status', 'wartet'), Query.notEqual('status', 'fehler'), order])
|
||||||
assert.deepEqual(both.map((d) => d.status), ['gesendet', 'abgebrochen'])
|
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 () => {
|
test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countDocuments liefert es', async () => {
|
||||||
seedOutbox('f_total', 5)
|
seedOutbox('f_total', 5)
|
||||||
seedOutbox('f_total_fremd', 3)
|
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([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 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]), 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)
|
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nirgends')]), 0)
|
||||||
// 30 Treffer ueber dem Standardlimit 25: total sagt 30, die Liste hat 25
|
// 30 Treffer ueber dem Standardlimit 25: total sagt 30, die Liste hat 25
|
||||||
seedOutbox('f_total_dreissig', 30)
|
seedOutbox('f_total_dreissig', 30)
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import http from 'node:http'
|
|||||||
*
|
*
|
||||||
* Bulk-Create (POST .../documents mit { documents: [{ $id, ...felder }] }, 1 bis 100 Dokumente) ist alles oder
|
* 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,
|
* 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),
|
* dann ist nichts geschrieben. Abfragen: equal (mit Liste: einer von), notEqual (wie Appwrite 1.8.1, gemessen 2026-10-05:
|
||||||
* greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
|
* 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
|
* 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
|
* 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
|
* 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':
|
case 'equal':
|
||||||
return query.values.some((candidate) => same(value, candidate))
|
return query.values.some((candidate) => same(value, candidate))
|
||||||
case 'notEqual':
|
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))
|
return !query.values.some((candidate) => same(value, candidate))
|
||||||
case 'greaterThan':
|
case 'greaterThan':
|
||||||
return greater(value, first)
|
return greater(value, first)
|
||||||
@@ -158,6 +162,10 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
|||||||
const queries = rawQueries.map((q) => JSON.parse(q))
|
const queries = rawQueries.map((q) => JSON.parse(q))
|
||||||
const unsupported = queries.find((q) => !supportedQueries.includes(q.method))
|
const unsupported = queries.find((q) => !supportedQueries.includes(q.method))
|
||||||
if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }]
|
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)
|
// 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]
|
const cursorId = queries.find((q) => q.method === 'cursorAfter')?.values[0]
|
||||||
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
|
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ after(() => aw.close())
|
|||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
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 { 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 { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||||
const { loadTemplate } = await import('../server/services/mailTemplates.js')
|
const { loadTemplate } = await import('../server/services/mailTemplates.js')
|
||||||
const { icsTermin } = await import('../server/services/termine/ics.js')
|
const { icsTermin } = await import('../server/services/termine/ics.js')
|
||||||
const O = await import('../server/services/termine/outbox.js')
|
const O = await import('../server/services/termine/outbox.js')
|
||||||
const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O
|
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))
|
after(() => setMailTransportForTests(null))
|
||||||
|
|
||||||
const MIN = 60000
|
const MIN = 60000
|
||||||
@@ -509,15 +509,21 @@ test('Reserve: mit sehr kleinem Stundenbudget darf mindestens eine nachrangige M
|
|||||||
// ---------------------------------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
const listenAbfragen = () => aw.listCalls('termineOutbox')
|
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 abfrageKlasse = ({ queries }) => {
|
||||||
const art = queries.find((q) => q.attribute === 'art')
|
const art = queries.find((q) => q.attribute === 'art')
|
||||||
if (art.method === 'notEqual') return 'normal'
|
if (art.method === 'notEqual') return 'normal'
|
||||||
return art.values.includes('G01') ? 'vorrang' : 'nachrangig'
|
return art.values.includes('G01') ? 'vorrang' : 'nachrangig'
|
||||||
}
|
}
|
||||||
const abfrageKlassen = () => listenAbfragen().map(abfrageKlasse)
|
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) => {
|
const begrenzteLeseabfragen = (abfragen) => {
|
||||||
for (const abfrage of 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')
|
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(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')
|
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 () => {
|
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() })
|
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() })
|
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 })
|
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
|
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 (let i = 0; i < 100; i++) {
|
||||||
for (const teil of ['a', 'b']) {
|
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()
|
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()
|
const abfragen = listenAbfragen()
|
||||||
|
begrenzteLeseabfragen(abfragen)
|
||||||
const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang')
|
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')
|
assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 30, 30], '100 Funktionen blockiert, aber nur 30 in der Liste')
|
||||||
for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= 90)
|
assert.deepEqual(ausschluss(vorrang[1]), Array.from({ length: 30 }, (_, i) => feature(i)), 'die zuerst blockierten')
|
||||||
assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === 90), 'blockierte Funktionen gelten für alle Klassen')
|
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)
|
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 () => {
|
test('Reserve erreicht: keine Abfrage der nachrangigen Klasse mehr; Vorrang und normal kommen trotzdem dran', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user