diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index b116c85..7b9805d 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -25,7 +25,8 @@ function formatRequestBody(body, method, { raw = false } = {}) { return payload } -async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) { +/** raw: true schickt den Body unverpackt (sonst wird er zu { data, documentId }; Pfade unter /users sind immer roh). */ +async function adminFetch(path, { method = 'GET', body, queries = [], raw = false } = {}) { if (!config.appwrite.apiKey) { const error = new Error('APPWRITE_API_KEY fehlt in .env') error.status = 500 @@ -37,7 +38,7 @@ async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) { url.searchParams.append('queries[]', q) } - const useRawBody = path.startsWith('/users') + const useRawBody = raw || path.startsWith('/users') const requestBody = formatRequestBody(body, method, { raw: useRawBody }) const response = await fetch(url.toString(), { @@ -77,6 +78,12 @@ export const Query = { limit: (n) => ({ method: 'limit', values: [n] }), greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }), lessThan: (attribute, value) => ({ method: 'lessThan', attribute, values: [value] }), + greaterThanEqual: (attribute, value) => ({ method: 'greaterThanEqual', attribute, values: [value] }), + lessThanEqual: (attribute, value) => ({ method: 'lessThanEqual', attribute, values: [value] }), + /** Beide Grenzen inklusiv */ + between: (attribute, start, end) => ({ method: 'between', attribute, values: [start, end] }), + /** Seite nach dem Dokument mit dieser ID (zusammen mit orderAsc/orderDesc und limit) */ + cursorAfter: (documentId) => ({ method: 'cursorAfter', values: [documentId] }), orderDesc: (attribute) => ({ method: 'orderDesc', attribute }), orderAsc: (attribute) => ({ method: 'orderAsc', attribute }), } @@ -157,6 +164,34 @@ export async function listDocuments(collectionId, queries = []) { return result.documents } +/** + * 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 + * Treffer ist ein Fehler (Schutz vor unbeabsichtigtem Vollabzug). + */ +export async function listAllDocuments(collectionId, queries = [], { pageSize = 500, max = 20000 } = {}) { + if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error(`listAllDocuments: pageSize muss eine ganze Zahl ab 1 sein, nicht ${pageSize}`) + const all = [] + let cursor = null + for (;;) { + const page = await listDocuments(collectionId, [ + ...queries, + Query.limit(pageSize), + Query.orderAsc('$id'), + ...(cursor ? [Query.cursorAfter(cursor)] : []), + ]) + all.push(...page) + if (all.length > max) { + const error = new Error(`listAllDocuments: mehr als ${max} Dokumente in ${collectionId}`) + error.status = 500 + error.type = 'list_limit_exceeded' + throw error + } + if (page.length < pageSize) return all + cursor = page[page.length - 1].$id + } +} + export async function getCustomerByAppwriteUserId(appwriteUserId) { const docs = await listDocuments(config.collections.customers, [ Query.equal('appwriteUserId', appwriteUserId), @@ -218,6 +253,17 @@ export async function createDocument(collectionId, data, documentId = ID.unique( }) } +/** + * Mehrere Dokumente in einer Anfrage (Appwrite 1.8.1 Bulk-Create, hoechstens 100): alles oder nichts. Ist eine + * $id schon vergeben, antwortet Appwrite 409 document_already_exists und schreibt nichts (err.status === 409). + * Jedes Dokument bringt seine $id mit: [{ $id, ...felder }]. Liefert die angelegten Dokumente. + */ +export async function createDocuments(collectionId, documents) { + if (documents.length === 0) return [] + const result = await adminFetch(collectionPath(collectionId), { method: 'POST', body: { documents }, raw: true }) + return result.documents +} + export async function deleteDocument(collectionId, documentId) { return adminFetch(`${collectionPath(collectionId)}/${documentId}`, { method: 'DELETE', diff --git a/test/appwriteBulk.test.js b/test/appwriteBulk.test.js new file mode 100644 index 0000000..5667a80 --- /dev/null +++ b/test/appwriteBulk.test.js @@ -0,0 +1,226 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Nachbau wie in den Termine-Tests (Schema, Datumsfelder, Standard-Limit 25) und einer ohne Optionen +const aw = await startAppwriteMock({ + schema: { termineClaims: schemaKeys('termineClaims'), termineBookings: schemaKeys('termineBookings'), termineOutbox: schemaKeys('termineOutbox') }, + datetimes: TERMINE_DATETIMES, + defaultLimit: 25, +}) +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( + '../server/services/appwriteAdmin.js' +) + +const idsOf = (collection, prefix) => [...aw.docs(collection).keys()].filter((id) => id.startsWith(prefix)).sort() +const cell = (id, over = {}) => ({ + $id: id, + featureId: 'f1', + ressourceId: 'r1', + bookingId: 'b1', + zelle: '2026-10-05T10:00:00.000Z', + bis: '2026-10-05T10:30:00.000Z', + ...over, +}) +const rawDocs = (mock, collection) => `${mock.url}/databases/woms-database/collections/${collection}/documents` +const postJson = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + +test('Query-Helfer bauen die Appwrite-Abfragen', () => { + assert.deepEqual(Query.between('startsAt', 'a', 'b'), { method: 'between', attribute: 'startsAt', values: ['a', 'b'] }) + 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'] }) +}) + +test('Bulk-Create legt 3 Dokumente in einer Anfrage an', async () => { + const created = await createDocuments('termineClaims', [cell('t2_a1'), cell('t2_a2', { bookingId: 'b2' }), cell('t2_a3')]) + assert.deepEqual(created.map((d) => d.$id), ['t2_a1', 't2_a2', 't2_a3']) + assert.deepEqual(idsOf('termineClaims', 't2_a'), ['t2_a1', 't2_a2', 't2_a3']) + const second = await getDocument('termineClaims', 't2_a2') + assert.equal(second.bookingId, 'b2') + assert.equal(second.featureId, 'f1') +}) + +test('Bulk-Create ohne Dokumente sendet nichts', async () => { + assert.deepEqual(await createDocuments('termineClaims', []), []) +}) + +test('Bulk mit vorhandener ID: 409 und nichts geschrieben', async () => { + await createDocument('termineClaims', cell('t2_b2', { bookingId: 'alt' }), 't2_b2') + const before = [...aw.docs('termineClaims').entries()].length + await assert.rejects( + createDocuments('termineClaims', [cell('t2_b1'), cell('t2_b2', { bookingId: 'neu' }), cell('t2_b3')]), + (err) => err.status === 409 && err.type === 'document_already_exists' + ) + assert.deepEqual(idsOf('termineClaims', 't2_b'), ['t2_b2']) + assert.equal(aw.docs('termineClaims').size, before) + assert.equal(aw.docs('termineClaims').get('t2_b2').bookingId, 'alt') +}) + +test('Bulk mit doppelter ID innerhalb der Anfrage: 409 und nichts geschrieben', async () => { + await assert.rejects(createDocuments('termineClaims', [cell('t2_c1'), cell('t2_c1')]), (err) => err.status === 409) + assert.deepEqual(idsOf('termineClaims', 't2_c'), []) +}) + +test('Bulk mit unbekanntem Attribut oder ungueltigem Datum: 400 und nichts geschrieben', async () => { + await assert.rejects( + createDocuments('termineClaims', [cell('t2_d1'), cell('t2_d2', { bogus: 1 })]), + (err) => err.status === 400 && /Unknown attribute: "bogus"/.test(err.message) + ) + await assert.rejects(createDocuments('termineClaims', [cell('t2_d3'), cell('t2_d4', { zelle: '' })]), (err) => err.status === 400) + await assert.rejects(createDocuments('termineClaims', [cell('t2_d5'), { featureId: 'ohne-id' }]), (err) => err.status === 400) + assert.deepEqual(idsOf('termineClaims', 't2_d'), []) +}) + +test('Bulk ueber 100 Dokumente: 400 wie Appwrite, nichts geschrieben', async () => { + const many = Array.from({ length: 101 }, (_, i) => cell(`t2_e${String(i).padStart(3, '0')}`)) + await assert.rejects(createDocuments('termineClaims', many), (err) => err.status === 400) + assert.deepEqual(idsOf('termineClaims', 't2_e'), []) + const exactly = await createDocuments('termineClaims', many.slice(0, 100)) + assert.equal(exactly.length, 100) +}) + +test('between ist inklusiv, greaterThanEqual/lessThanEqual ebenso', async () => { + const starts = ['10:00', '10:30', '11:00', '11:30', '12:00'] + for (const [i, t] of starts.entries()) { + await createDocument('termineBookings', { featureId: 'f_between', startsAt: `2026-10-05T${t}:00.000Z`, status: 'bestaetigt' }, `t2_bk${i}`) + } + const inFeature = Query.equal('featureId', 'f_between') + const order = Query.orderAsc('startsAt') + const between = await listDocuments('termineBookings', [inFeature, Query.between('startsAt', '2026-10-05T10:30:00.000Z', '2026-10-05T11:30:00.000Z'), order]) + assert.deepEqual(between.map((d) => d.$id), ['t2_bk1', 't2_bk2', 't2_bk3']) + // dieselben Zeitpunkte in anderer Schreibweise (Berlin, Sommerzeit +02:00) + const offset = await listDocuments('termineBookings', [inFeature, Query.between('startsAt', '2026-10-05T12:30:00+02:00', '2026-10-05T13:30:00+02:00'), order]) + assert.deepEqual(offset.map((d) => d.$id), ['t2_bk1', 't2_bk2', 't2_bk3']) + const from = await listDocuments('termineBookings', [inFeature, Query.greaterThanEqual('startsAt', '2026-10-05T11:30:00.000Z'), order]) + assert.deepEqual(from.map((d) => d.$id), ['t2_bk3', 't2_bk4']) + const until = await listDocuments('termineBookings', [inFeature, Query.lessThanEqual('startsAt', '2026-10-05T10:30:00.000Z'), order]) + assert.deepEqual(until.map((d) => d.$id), ['t2_bk0', 't2_bk1']) + // greaterThan/lessThan bleiben exklusiv + const later = await listDocuments('termineBookings', [inFeature, Query.greaterThan('startsAt', '2026-10-05T11:30:00Z'), order]) + assert.deepEqual(later.map((d) => d.$id), ['t2_bk4']) + const equal = await listDocuments('termineBookings', [inFeature, Query.equal('startsAt', '2026-10-05T13:00:00+02:00')]) + assert.deepEqual(equal.map((d) => d.$id), ['t2_bk2']) +}) + +test('cursorAfter liefert die Dokumente nach dem genannten', async () => { + for (let i = 0; i < 6; i += 1) aw.seed('termineClaims', cell(`t2_f${i}`, { featureId: 'f_cursor' })) + const page = await listDocuments('termineClaims', [Query.equal('featureId', 'f_cursor'), Query.orderAsc('$id'), Query.cursorAfter('t2_f1'), Query.limit(3)]) + assert.deepEqual(page.map((d) => d.$id), ['t2_f2', 't2_f3', 't2_f4']) + await assert.rejects(listDocuments('termineClaims', [Query.cursorAfter('gibt-es-nicht'), Query.limit(3)]), (err) => err.status === 400) +}) + +test('listAllDocuments laedt ueber 3 Seiten, in $id-Reihenfolge und nur die passenden', async () => { + for (let i = 0; i < 25; i += 1) aw.seed('termineClaims', cell(`t2_g${String(i).padStart(2, '0')}`, { featureId: 'f_pages' })) + for (let i = 0; i < 7; i += 1) aw.seed('termineClaims', cell(`t2_g_fremd${i}`, { featureId: 'f_andere' })) + const realFetch = globalThis.fetch + let requests = 0 + globalThis.fetch = (...args) => { + requests += 1 + return realFetch(...args) + } + let all + try { + all = await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_pages')], { pageSize: 10 }) + } finally { + globalThis.fetch = realFetch + } + assert.equal(requests, 3) + assert.equal(all.length, 25) + assert.deepEqual(all.map((d) => d.$id), Array.from({ length: 25 }, (_, i) => `t2_g${String(i).padStart(2, '0')}`)) +}) + +test('listAllDocuments: Vielfaches der Seitengroesse, leere Treffermenge und Standard-Seitengroesse', async () => { + for (let i = 0; i < 20; i += 1) aw.seed('termineClaims', cell(`t2_h${String(i).padStart(2, '0')}`, { featureId: 'f_vielfach' })) + assert.equal((await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_vielfach')], { pageSize: 10 })).length, 20) + assert.deepEqual(await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_leer')]), []) + // 30 Treffer > defaultLimit 25: nur mit eigenem limit-Aufruf vollstaendig + for (let i = 0; i < 30; i += 1) aw.seed('termineClaims', cell(`t2_h_x${String(i).padStart(2, '0')}`, { featureId: 'f_dreissig' })) + assert.equal((await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_dreissig')])).length, 30) +}) + +test('listAllDocuments: mehr Treffer als max ist ein Fehler, genau max nicht', async () => { + for (let i = 0; i < 21; i += 1) aw.seed('termineClaims', cell(`t2_i${String(i).padStart(2, '0')}`, { featureId: 'f_max' })) + const filter = [Query.equal('featureId', 'f_max')] + await assert.rejects(listAllDocuments('termineClaims', filter, { pageSize: 10, max: 20 }), /mehr als 20 /) + assert.equal((await listAllDocuments('termineClaims', filter, { pageSize: 10, max: 21 })).length, 21) + await assert.rejects(listAllDocuments('termineClaims', filter, { pageSize: 0 }), /pageSize/) +}) + +test('defaultLimit 25 liefert ohne limit-Abfrage 25 von 30', async () => { + for (let i = 0; i < 30; i += 1) aw.seed('termineOutbox', { $id: `t2_j${String(i).padStart(2, '0')}`, featureId: 'f_limit', status: 'wartet' }) + const filter = [Query.equal('featureId', 'f_limit')] + assert.equal((await listDocuments('termineOutbox', filter)).length, 25) + assert.equal((await listDocuments('termineOutbox', [...filter, Query.limit(30)])).length, 30) + assert.equal((await listDocuments('termineOutbox', [...filter, Query.limit(5)])).length, 5) +}) + +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') + assert.equal(made.endsAt, '2026-10-05T10:30:00.000+00:00') + assert.equal((await getDocument('termineBookings', 't2_dt1')).startsAt, '2026-10-05T10:00:00.000+00:00') + const patched = await updateDocument('termineBookings', 't2_dt1', { heldUntil: '2026-10-05T09:59:00.5Z' }) + assert.equal(patched.heldUntil, '2026-10-05T09:59:00.500+00:00') + assert.equal(patched.startsAt, '2026-10-05T10:00:00.000+00:00') + const [bulk] = await createDocuments('termineClaims', [cell('t2_dt2', { zelle: '2026-10-05T10:05:00Z' })]) + assert.equal(bulk.zelle, '2026-10-05T10:05:00.000+00:00') + assert.equal(bulk.bis, '2026-10-05T10:30:00.000+00:00') + aw.seed('termineBookings', { $id: 't2_dt3', startsAt: '2026-10-05T10:00:00Z' }) + assert.equal(aw.docs('termineBookings').get('t2_dt3').startsAt, '2026-10-05T10:00:00.000+00:00') + // Textfelder, die zufaellig wie ein Datum aussehen, bleiben unveraendert + const text = await createDocument('termineBookings', { gastNachricht: '2026-10-05T10:00:00Z' }, 't2_dt4') + assert.equal(text.gastNachricht, '2026-10-05T10:00:00Z') + // null bleibt erlaubt (optionales Datumsfeld leeren) + const cleared = await updateDocument('termineBookings', 't2_dt1', { heldUntil: null }) + assert.equal(cleared.heldUntil, null) +}) + +test('leeres oder unlesbares Datum: 400 beim Anlegen und Aendern', async () => { + await assert.rejects(createDocument('termineBookings', { startsAt: '' }, 't2_dt5'), (err) => err.status === 400) + await assert.rejects(createDocument('termineBookings', { startsAt: 'morgen' }, 't2_dt6'), (err) => err.status === 400) + await assert.rejects(updateDocument('termineBookings', 't2_dt1', { endsAt: '' }), (err) => err.status === 400) + assert.equal(aw.docs('termineBookings').has('t2_dt5'), false) + assert.equal((await getDocument('termineBookings', 't2_dt1')).endsAt, '2026-10-05T10:30:00.000+00:00') +}) + +test('Nachbau ohne Optionen verhaelt sich wie bisher', async () => { + const col = rawDocs(plain, 'irgendwas') + for (let i = 0; i < 30; i += 1) { + const res = await postJson(col, { documentId: `p${String(i).padStart(2, '0')}`, data: { n: i, am: '2026-10-05T10:00:00Z' } }) + assert.equal(res.status, 201) + } + const listed = await (await fetch(col)).json() + assert.equal(listed.documents.length, 30) + assert.equal(listed.documents[0].am, '2026-10-05T10:00:00Z') + const queries = [{ method: 'between', attribute: 'n', values: [5, 7] }, { method: 'orderAsc', attribute: 'n' }] + const between = await (await fetch(`${col}?${queries.map((q) => `queries[]=${encodeURIComponent(JSON.stringify(q))}`).join('&')}`)).json() + assert.deepEqual(between.documents.map((d) => d.n), [5, 6, 7]) + const bulk = await postJson(col, { documents: [{ $id: 'q1', n: 100 }, { $id: 'q2', n: 101 }] }) + assert.equal(bulk.status, 201) + assert.equal((await postJson(col, { documents: [{ $id: 'q3', n: 102 }, { $id: 'q1', n: 103 }] })).status, 409) + assert.equal(plain.docs('irgendwas').has('q3'), false) +}) + +test('testApp.send ruft beliebige Methoden auf', async () => { + const { startTestApp, send } = await import('./support/testApp.js') + const router = express.Router() + router.put('/echo', (req, res) => res.json({ method: req.method, body: req.body, via: req.get('x-test') })) + const app = await startTestApp({ '/t': router }) + try { + const res = await send('PUT', '/t/echo', { body: { a: 1 }, headers: { 'x-test': 'ja' } }) + assert.equal(res.status, 200) + assert.deepEqual(res.body, { method: 'PUT', body: { a: 1 }, via: 'ja' }) + } finally { + await app.close() + } +}) diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index 391fbf3..facd0ce 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -8,8 +8,19 @@ import http from 'node:http' * Anfang). failNext({ collection?, method?, status, message?, after? }) lässt die nächste passende Anfrage * mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt"); * after = Zahl passender Anfragen, die vorher noch durchgehen (z. B. erst die zweite PATCH scheitert). + * + * 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. + * + * Optionen (ohne sie verhaelt sich der Nachbau wie zuvor): + * datetimes { collection: ['feld', ...] } Datumsfelder; beim Schreiben (Anlegen, Aendern, Bulk, seed) wie + * Appwrite auf "...000+00:00" (UTC) normalisiert, '' oder Unlesbares => 400, null bleibt erlaubt + * defaultLimit Zahl: gilt ohne limit-Abfrage wie Appwrites 25 (Termine-Tests: 25), null = unbegrenzt */ -export async function startAppwriteMock({ schema = {} } = {}) { +export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLimit = null } = {}) { const collections = new Map() let sequence = 0 let pendingFailure = null @@ -34,6 +45,8 @@ export async function startAppwriteMock({ schema = {} } = {}) { }, ] + const documentExists = [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }] + const docs = (collection) => { if (!collections.has(collection)) collections.set(collection, new Map()) return collections.get(collection) @@ -46,9 +59,62 @@ export async function startAppwriteMock({ schema = {} } = {}) { const seed = (collection, doc) => { if (!doc?.$id) throw new Error('seed: $id fehlt') - docs(collection).set(doc.$id, stamp(doc, collection)) + const [normalized, badDate] = normalizeDates(collection, doc) + if (badDate) throw new Error(`seed: ${collection}.${badDate} ist kein gueltiges Datum`) + docs(collection).set(doc.$id, stamp(normalized, collection)) } + // Zeitpunkt eines Textes wie "2026-10-05T10:00:00Z" (ohne Zonenangabe gilt UTC); NaN, wenn es keiner ist + const isoStart = /^\d{4}-\d\d-\d\dT/ + const hasZone = /(?:Z|[+-]\d\d(?::?\d\d)?)$/i + const instant = (value) => (typeof value === 'string' && isoStart.test(value) ? Date.parse(hasZone.test(value) ? value : `${value}Z`) : Number.NaN) + + // Beide Seiten Zeitpunkte => deren Millisekunden, sonst die Werte selbst (Vergleich wie bisher) + const comparable = (a, b) => { + const x = instant(a) + const y = instant(b) + return Number.isNaN(x) || Number.isNaN(y) ? [a, b] : [x, y] + } + const same = (a, b) => { + const [x, y] = comparable(a, b) + return x === y + } + const greater = (a, b) => { + const [x, y] = comparable(a, b) + return x > y + } + const less = (a, b) => { + const [x, y] = comparable(a, b) + return x < y + } + const atLeast = (a, b) => { + const [x, y] = comparable(a, b) + return x >= y + } + const atMost = (a, b) => { + const [x, y] = comparable(a, b) + return x <= y + } + + // Datumsfelder der Collection wie Appwrite speichern: UTC mit Millisekunden und "+00:00". Liefert + // [Daten, Schluessel des ersten ungueltigen Datums | null] + const normalizeDates = (collection, data) => { + const keys = datetimes[collection] + if (!keys?.length) return [data, null] + const out = { ...data } + for (const key of keys) { + const value = out[key] + if (value === undefined || value === null) continue + let ms = Number.NaN + if (value instanceof Date) ms = value.getTime() + else if (typeof value === 'string') ms = isoStart.test(value) ? instant(value) : /^\d{4}-\d\d-\d\d$/.test(value) ? Date.parse(value) : Number.NaN + if (Number.isNaN(ms)) return [data, key] + out[key] = new Date(ms).toISOString().replace('Z', '+00:00') + } + return [out, null] + } + const invalidDate = (key) => [400, { message: `Invalid document structure: Attribute "${key}" has invalid format. Value must be a valid date`, code: 400, type: 'document_invalid_structure' }] + const unknownAttribute = (collection, data) => { const allowed = schema[collection] if (!allowed) return null @@ -57,34 +123,48 @@ export async function startAppwriteMock({ schema = {} } = {}) { const matches = (doc, query) => { const value = doc[query.attribute] + const [first, last] = query.values || [] switch (query.method) { case 'equal': - return query.values.includes(value) + return query.values.some((candidate) => same(value, candidate)) case 'greaterThan': - return value > query.values[0] + return greater(value, first) + case 'greaterThanEqual': + return atLeast(value, first) case 'lessThan': - return value < query.values[0] + return less(value, first) + case 'lessThanEqual': + return atMost(value, first) + case 'between': + return atLeast(value, first) && atMost(value, last) default: return true } } + const supportedQueries = ['equal', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', 'between', 'orderAsc', 'orderDesc', 'limit', 'cursorAfter'] + const list = (collection, rawQueries) => { const queries = rawQueries.map((q) => JSON.parse(q)) - const unsupported = queries.find((q) => !['equal', 'greaterThan', 'lessThan', 'orderAsc', 'orderDesc', 'limit'].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 }] - let result = [...docs(collection).values()].filter((doc) => queries.every((q) => matches(doc, q))) + // 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)) { + 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))) for (const q of queries.filter((q) => q.method === 'orderAsc' || q.method === 'orderDesc')) { const dir = q.method === 'orderAsc' ? 1 : -1 result.sort((a, b) => { - const x = a[q.attribute] - const y = b[q.attribute] + const [x, y] = comparable(a[q.attribute], b[q.attribute]) if (x === y) return (a.$seq - b.$seq) * dir return (x > y ? 1 : -1) * dir }) } - const limit = queries.find((q) => q.method === 'limit') - if (limit) result = result.slice(0, limit.values[0]) + 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) }] } @@ -162,6 +242,33 @@ export async function startAppwriteMock({ schema = {} } = {}) { return null } + // Bulk-Create (Appwrite 1.8.1, API-Schluessel): alles oder nichts. Erst jedes Dokument pruefen (400), dann auf + // vorhandene oder doppelte IDs (409); geschrieben wird erst, wenn alles durch ist. Anders als Appwrite (das eine + // fehlende $id selbst vergibt) verlangt der Nachbau die $id, die Belegungszellen brauchen feste IDs. + const createMany = (collection, documents) => { + if (documents.length < 1 || documents.length > 100) { + return [400, { message: 'Invalid `documents` param: Value must be a valid array and contain between 1 and 100 items', code: 400, type: 'general_argument_invalid' }] + } + const prepared = [] + for (const document of documents) { + if (!document || typeof document !== 'object' || Array.isArray(document) || !validDocumentId(document.$id)) return invalidDocumentId + const bad = unknownAttribute(collection, document) + if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }] + const [dated, badDate] = normalizeDates(collection, document) + if (badDate) return invalidDate(badDate) + prepared.push(dated) + } + const store = docs(collection) + const ids = new Set() + for (const { $id } of prepared) { + if (store.has($id) || ids.has($id)) return documentExists + ids.add($id) + } + const created = prepared.map((document) => stamp(document, collection)) + for (const document of created) store.set(document.$id, document) + return [201, { total: created.length, documents: created.map(publicDoc) }] + } + const handle = async (req) => { const url = new URL(req.url, 'http://127.0.0.1') const auth = await handleAuth(req, url) @@ -211,14 +318,16 @@ export async function startAppwriteMock({ schema = {} } = {}) { if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]')) if (!id && req.method === 'POST') { - const { documentId, data = {} } = await readBody(req) + const body = await readBody(req) + if (Array.isArray(body.documents)) return createMany(collection, body.documents) + const { documentId, data = {} } = body if (!validDocumentId(documentId)) return invalidDocumentId - if (store.has(documentId)) { - return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }] - } + if (store.has(documentId)) return documentExists const bad = unknownAttribute(collection, data) if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }] - const doc = stamp({ ...data, $id: documentId }, collection) + const [dated, badDate] = normalizeDates(collection, data) + if (badDate) return invalidDate(badDate) + const doc = stamp({ ...dated, $id: documentId }, collection) store.set(documentId, doc) return [201, publicDoc(doc)] } @@ -228,7 +337,9 @@ export async function startAppwriteMock({ schema = {} } = {}) { const { data = {} } = await readBody(req) const bad = unknownAttribute(collection, data) if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }] - const doc = { ...store.get(id), ...data, $updatedAt: new Date().toISOString() } + const [dated, badDate] = normalizeDates(collection, data) + if (badDate) return invalidDate(badDate) + const doc = { ...store.get(id), ...dated, $updatedAt: new Date().toISOString() } store.set(id, doc) return [200, publicDoc(doc)] } diff --git a/test/support/testApp.js b/test/support/testApp.js index c0ce0f1..12fda08 100644 --- a/test/support/testApp.js +++ b/test/support/testApp.js @@ -69,7 +69,8 @@ export async function startTestApp(routers = {}) { return { url: baseUrl, close: () => new Promise((resolve) => server.close(resolve)) } } -async function request(method, path, { body, cookie, headers = {} } = {}) { +/** Anfrage an die zuletzt gestartete App (beliebige Methode) -> { status, body, headers }; body ist geparstes JSON, sonst Text. */ +export async function send(method, path, { body, cookie, headers = {} } = {}) { if (!baseUrl) throw new Error('testApp: startTestApp() zuerst aufrufen') const res = await fetch(baseUrl + path, { method, @@ -92,7 +93,7 @@ async function request(method, path, { body, cookie, headers = {} } = {}) { } /** GET -> { status, body } */ -export const rawGet = (path, opts = {}) => request('GET', path, opts) +export const rawGet = (path, opts = {}) => send('GET', path, opts) /** GET -> JSON-Body; ab Status 400 fliegt ein Fehler mit err.status und err.body. */ export async function get(path, opts = {}) { @@ -107,7 +108,7 @@ export async function get(path, opts = {}) { } /** POST mit JSON-Body -> { status, body } */ -export const post = (path, body, opts = {}) => request('POST', path, { ...opts, body }) +export const post = (path, body, opts = {}) => send('POST', path, { ...opts, body }) // Signiert wie Stripe (whsec_test aus useTestEnv); der Schluessel wird nie fuer Netzaufrufe benutzt const webhookSigner = new Stripe('sk_test_fake') @@ -116,5 +117,5 @@ const webhookSigner = new Stripe('sk_test_fake') export function postWebhook(event, { path = '/api/abo/webhook', secret = config.stripe.webhookSecret || 'whsec_test' } = {}) { const payload = JSON.stringify(event) const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret }) - return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } }) + return send('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } }) }