diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index e7c14fb..8a90d10 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -91,6 +91,14 @@ export const Query = { cursorAfter: (documentId) => ({ method: 'cursorAfter', values: [documentId] }), orderDesc: (attribute) => ({ method: 'orderDesc', attribute }), 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], + }), } export const ID = { @@ -169,6 +177,15 @@ export async function listDocuments(collectionId, queries = []) { return result.documents } +/** + * Anzahl der Treffer: ein Aufruf mit limit(1), gelesen wird das Feld total der Antwort (Treffer ohne limit/cursor). + * queries sind nur Filter, nie limit/order/cursor. Liefert null, wenn die Antwort kein total hat. + */ +export async function countDocuments(collectionId, queries = []) { + const result = await adminFetch(collectionPath(collectionId), { queries: [...queries, Query.limit(1)] }) + return typeof result?.total === 'number' ? result.total : null +} + /** * Ein Aufruf mit allen Seiten: sortiert nach $id, Seite fuer Seite per cursorAfter, bis eine Seite kuerzer als * pageSize ist. queries enthalten nur Filter, nie limit/order/cursor (die setzt die Funktion selbst). Mehr als max diff --git a/test/appwriteBulk.test.js b/test/appwriteBulk.test.js index 5667a80..6b9c3a9 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 } = 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') diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index 4d102c7..a13d720 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -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 }