Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 08:38:21 +00:00
1 parent 8c1055e8a0
commit 2dd9c0dec7
3 files changed
+190 -7

No files matched your search

+144 -1
View File
@@ -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 } = await import(
const { Query, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments, countDocuments } = await import(
'../server/services/appwriteAdmin.js'
)
@@ -39,6 +39,9 @@ test('Query-Helfer bauen die Appwrite-Abfragen', () => {
assert.deepEqual(Query.greaterThanEqual('startsAt', 'a'), { method: 'greaterThanEqual', attribute: 'startsAt', values: ['a'] })
assert.deepEqual(Query.lessThanEqual('startsAt', 'b'), { method: 'lessThanEqual', attribute: 'startsAt', values: ['b'] })
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'] })
})
test('Bulk-Create legt 3 Dokumente in einer Anfrage an', async () => {
@@ -164,6 +167,146 @@ test('defaultLimit 25 liefert ohne limit-Abfrage 25 von 30', async () => {
assert.equal((await listDocuments('termineOutbox', [...filter, Query.limit(5)])).length, 5)
})
const seedOutbox = (feature, count, over = () => ({})) => {
for (let i = 0; i < count; i += 1) {
aw.seed('termineOutbox', { $id: `t2_${feature}${String(i).padStart(2, '0')}`, featureId: feature, status: 'wartet', ...over(i) })
}
}
const totalOf = async (queries) => {
const url = `${rawDocs(aw, 'termineOutbox')}?${queries.map((q) => `queries[]=${encodeURIComponent(JSON.stringify(q))}`).join('&')}`
return (await (await fetch(url)).json()).total
}
test('select liefert nur die genannten Felder plus alle $-Felder', async () => {
seedOutbox('f_sel', 2, (i) => ({ status: i === 0 ? 'wartet' : 'gesendet', empfaenger: 'a@example.invalid' }))
const filter = [Query.equal('featureId', 'f_sel'), Query.orderAsc('$id')]
const full = await listDocuments('termineOutbox', filter)
assert.ok('featureId' in full[0] && 'status' in full[0] && 'empfaenger' in full[0])
const slim = await listDocuments('termineOutbox', [...filter, Query.select(['status'])])
assert.deepEqual(slim.map((d) => d.$id), ['t2_f_sel00', 't2_f_sel01'])
assert.deepEqual(slim.map((d) => d.status), ['wartet', 'gesendet'])
for (const doc of slim) {
assert.deepEqual(
Object.keys(doc).filter((key) => !key.startsWith('$')),
['status']
)
// alle $-Felder bleiben, der interne Zaehler des Nachbaus nicht
assert.deepEqual(Object.keys(doc).filter((key) => key.startsWith('$')).sort(), ['$collectionId', '$createdAt', '$id', '$updatedAt'])
}
// nur $id: die Abfrage filtert und sortiert trotzdem nach Feldern, die nicht ausgewaehlt sind
const ids = await listDocuments('termineOutbox', [Query.equal('status', 'gesendet'), Query.equal('featureId', 'f_sel'), Query.select(['$id'])])
assert.deepEqual(ids.map((d) => d.$id), ['t2_f_sel01'])
assert.deepEqual(Object.keys(ids[0]).filter((key) => !key.startsWith('$')), [])
// ein Feld, das das Dokument nicht hat, erscheint nicht
const missing = await listDocuments('termineOutbox', [...filter, Query.select(['gibtEsNicht'])])
assert.deepEqual(Object.keys(missing[0]).filter((key) => !key.startsWith('$')), [])
// Einzelabruf bleibt vollstaendig
assert.equal((await getDocument('termineOutbox', 't2_f_sel00')).featureId, 'f_sel')
})
test('notEqual schliesst einen Wert oder alle Werte der Liste aus', 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)
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)
const both = await listDocuments('termineOutbox', [filter, Query.notEqual('status', 'wartet'), Query.notEqual('status', 'fehler'), order])
assert.deepEqual(both.map((d) => d.status), ['gesendet', 'abgebrochen'])
})
test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countDocuments liefert es', async () => {
seedOutbox('f_total', 5)
seedOutbox('f_total_fremd', 3)
const filter = Query.equal('featureId', 'f_total')
assert.equal(await totalOf([filter, Query.limit(2)]), 5)
assert.equal(await totalOf([filter, Query.orderAsc('$id'), Query.cursorAfter('t2_f_total01'), Query.limit(2)]), 5)
// das Cursor-Dokument zaehlt nur mit, wenn es zu den Filtern passt
assert.equal(await totalOf([Query.equal('featureId', 'f_total'), Query.orderAsc('$id'), Query.cursorAfter('t2_f_total_fremd00'), Query.limit(2)]), 5)
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', [Query.equal('featureId', 'f_nirgends')]), 0)
// 30 Treffer ueber dem Standardlimit 25: total sagt 30, die Liste hat 25
seedOutbox('f_total_dreissig', 30)
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_total_dreissig')]), 30)
assert.equal((await listDocuments('termineOutbox', [Query.equal('featureId', 'f_total_dreissig')])).length, 25)
})
test('countDocuments fragt mit limit(1) in einem Aufruf und liefert null ohne total', async () => {
seedOutbox('f_count', 4)
aw.resetListCalls()
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_count')]), 4)
assert.deepEqual(aw.listCalls('termineOutbox'), [
{
collection: 'termineOutbox',
queries: [
{ method: 'equal', attribute: 'featureId', values: ['f_count'] },
{ method: 'limit', values: [1] },
],
},
])
assert.equal(await countDocuments('termineOutbox'), await countDocuments('termineOutbox', []))
// Antwort ohne total (oder mit einem Nicht-Zahlenwert): null, nicht 0 und nicht die Seitenlaenge
const realFetch = globalThis.fetch
const answer = (body) => () => Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
try {
globalThis.fetch = answer({ documents: [{ $id: 'x' }] })
assert.equal(await countDocuments('termineOutbox', []), null)
globalThis.fetch = answer({ total: 'viele', documents: [] })
assert.equal(await countDocuments('termineOutbox', []), null)
globalThis.fetch = answer({ total: 7, documents: [{ $id: 'x' }] })
assert.equal(await countDocuments('termineOutbox', []), 7)
} finally {
globalThis.fetch = realFetch
}
// ein Fehler der Abfrage wird nicht verschluckt
aw.failNext({ collection: 'termineOutbox', method: 'GET', status: 500 })
await assert.rejects(countDocuments('termineOutbox', []), (err) => err.status === 500)
})
test('listCalls zeichnet nur Listenabfragen auf, geparst, je Collection filterbar', async () => {
aw.resetListCalls()
assert.deepEqual(aw.listCalls(), [])
await createDocument('termineClaims', cell('t2_lc1', { featureId: 'f_lc' }), 't2_lc1')
await createDocuments('termineClaims', [cell('t2_lc2', { featureId: 'f_lc' })])
await getDocument('termineClaims', 't2_lc1')
await updateDocument('termineClaims', 't2_lc1', { bookingId: 'b9' })
assert.deepEqual(aw.listCalls(), [], 'Anlegen, Bulk, Einzelabruf und Aendern sind keine Listenabfragen')
await listDocuments('termineClaims', [Query.equal('featureId', 'f_lc'), Query.orderAsc('$id'), Query.limit(3)])
await listDocuments('termineBookings')
await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_lc')], { pageSize: 1 })
assert.deepEqual(aw.listCalls('termineClaims').slice(0, 1), [
{
collection: 'termineClaims',
queries: [
{ method: 'equal', attribute: 'featureId', values: ['f_lc'] },
{ method: 'orderAsc', attribute: '$id' },
{ method: 'limit', values: [3] },
],
},
])
assert.deepEqual(aw.listCalls('termineBookings'), [{ collection: 'termineBookings', queries: [] }])
assert.deepEqual(aw.listCalls().map((c) => c.collection), ['termineClaims', 'termineBookings', 'termineClaims', 'termineClaims', 'termineClaims'])
assert.deepEqual(aw.listCalls('gibtEsNicht'), [])
// listAllDocuments liest ueber Cursor weiter: 2 Treffer bei pageSize 1 = 3 Seiten, die zweite ab dem ersten Treffer
const pages = aw.listCalls('termineClaims').slice(1)
assert.equal(pages.length, 3)
assert.equal(pages[0].queries.some((q) => q.method === 'cursorAfter'), false)
assert.deepEqual(pages[1].queries.at(-1), { method: 'cursorAfter', values: ['t2_lc1'] })
assert.deepEqual(pages[2].queries.at(-1), { method: 'cursorAfter', values: ['t2_lc2'] })
// die Rueckgabe ist eine Kopie: Aendern wirkt nicht auf die Aufzeichnung
aw.listCalls()[0].queries.length = 0
assert.equal(aw.listCalls('termineClaims')[0].queries.length, 3)
aw.resetListCalls()
assert.deepEqual(aw.listCalls(), [])
assert.deepEqual(aw.listCalls('termineClaims'), [])
})
test('Datum mit Z kommt als +00:00 zurueck (Anlegen, Aendern, Bulk, Seed)', async () => {
const made = await createDocument('termineBookings', { featureId: 'f_dt', startsAt: '2026-10-05T10:00:00Z', endsAt: '2026-10-05T12:30:00+02:00', status: 'x' }, 't2_dt1')
assert.equal(made.startsAt, '2026-10-05T10:00:00.000+00:00')
+29 -6
View File
@@ -11,9 +11,12 @@ 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, greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/
* orderDesc, limit, cursorAfter. Zwei Werte, die beide wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte
* (Schreibweise Z, +00:00, +02:00 ist egal), alle anderen wie bisher.
* 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
* 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
* anderen wie bisher.
*
* Optionen (ohne sie verhaelt sich der Nachbau wie zuvor):
* datetimes { collection: ['feld', ...] } Datumsfelder; beim Schreiben (Anlegen, Aendern, Bulk, seed) wie
@@ -33,6 +36,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
let lastLocale = null
let failUserLookups = false
let sessionDeletes = 0
// Aufzeichnung der Listenabfragen (GET .../documents ohne ID): [{ collection, queries: [geparste Abfragen] }]
let listLog = []
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
@@ -127,6 +132,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
switch (query.method) {
case 'equal':
return query.values.some((candidate) => same(value, candidate))
case 'notEqual':
return !query.values.some((candidate) => same(value, candidate))
case 'greaterThan':
return greater(value, first)
case 'greaterThanEqual':
@@ -142,7 +149,10 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
}
}
const supportedQueries = ['equal', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', 'between', 'orderAsc', 'orderDesc', 'limit', 'cursorAfter']
const supportedQueries = ['equal', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', 'between', 'notEqual', 'orderAsc', 'orderDesc', 'limit', 'cursorAfter', 'select']
// Nur die genannten Felder plus alle $-Felder (wie Appwrite); $seq ist intern und faellt in publicDoc weg
const project = (doc, attributes) => Object.fromEntries(Object.entries(doc).filter(([key]) => key.startsWith('$') || attributes.includes(key)))
const list = (collection, rawQueries) => {
const queries = rawQueries.map((q) => JSON.parse(q))
@@ -153,7 +163,11 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
return [400, { message: 'The cursor is invalid. This can happen if the item represented by the cursor has been deleted.', code: 400, type: 'general_cursor_not_found' }]
}
let result = [...docs(collection).values()].filter((doc) => doc.$id === cursorId || queries.every((q) => matches(doc, q)))
const all = [...docs(collection).values()]
const hit = (doc) => queries.every((q) => matches(doc, q))
// total wie Appwrite: alle Treffer der Filter, ohne limit und cursorAfter
const total = all.filter(hit).length
let result = all.filter((doc) => doc.$id === cursorId || hit(doc))
for (const q of queries.filter((q) => q.method === 'orderAsc' || q.method === 'orderDesc')) {
const dir = q.method === 'orderAsc' ? 1 : -1
result.sort((a, b) => {
@@ -165,7 +179,9 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
if (cursorId !== undefined) result = result.slice(result.findIndex((doc) => doc.$id === cursorId) + 1)
const limit = queries.find((q) => q.method === 'limit')?.values[0] ?? defaultLimit
if (limit !== null) result = result.slice(0, limit)
return [200, { total: result.length, documents: result.map(publicDoc) }]
const selected = queries.filter((q) => q.method === 'select').flatMap((q) => q.values)
if (queries.some((q) => q.method === 'select')) result = result.map((doc) => project(doc, selected))
return [200, { total, documents: result.map(publicDoc) }]
}
const publicDoc = ({ $seq, ...doc }) => doc
@@ -324,6 +340,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
const store = docs(collection)
const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }]
if (!id && req.method === 'GET') listLog.push({ collection, queries: url.searchParams.getAll('queries[]').map((q) => JSON.parse(q)) })
// Eingeplanter Fehler (failNext): genau eine passende Anfrage scheitert
if (
pendingFailure &&
@@ -389,6 +407,11 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
url: `http://127.0.0.1:${server.address().port}/v1`,
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
seed,
/** Aufgezeichnete Listenabfragen (nur GET auf eine Collection, auch fehlgeschlagene), je Aufruf eine Kopie; optional einer Collection */
listCalls: (collection = null) => structuredClone(listLog.filter((call) => !collection || call.collection === collection)),
resetListCalls: () => {
listLog = []
},
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig); after passende Anfragen gehen vorher durch. */
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { collection, method, status, message, after }