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, keinerVon, dokumenteUrl, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments, countDocuments } = 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'] }) assert.deepEqual(Query.select(['a', 'b']), { method: 'select', values: ['a', 'b'] }) assert.deepEqual(Query.notEqual('status', 'x'), { method: 'notEqual', attribute: 'status', values: ['x'] }) }) // 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 () => { 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) }) 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 aus, mehrere notEqual schneiden sich (keinerVon)', async () => { seedOutbox('f_ne', 4, (i) => ({ status: ['wartet', 'gesendet', 'fehler', 'abgebrochen'][i] })) const filter = Query.equal('featureId', 'f_ne') const order = Query.orderAsc('$id') const without = async (...werte) => (await listDocuments('termineOutbox', [filter, ...keinerVon('status', werte), order])).map((d) => d.status) assert.deepEqual(await without('wartet'), ['gesendet', 'fehler', 'abgebrochen']) assert.deepEqual(await without('gesendet', 'fehler'), ['wartet', 'abgebrochen']) assert.deepEqual(await without('wartet', 'gesendet', 'fehler', 'abgebrochen'), []) assert.deepEqual(await without('gibtEsNicht'), ['wartet', 'gesendet', 'fehler', 'abgebrochen']) assert.deepEqual(await without(), ['wartet', 'gesendet', 'fehler', 'abgebrochen'], 'ohne Werte keine Abfrage') // von Hand zwei notEqual auf demselben Feld: beide Bedingungen muessen gelten const both = await listDocuments('termineOutbox', [filter, Query.notEqual('status', 'wartet'), Query.notEqual('status', 'fehler'), order]) assert.deepEqual(both.map((d) => d.status), ['gesendet', 'abgebrochen']) }) // Appwrite 1.8.1 (M1): HTTP 400 general_query_invalid; der Nachbau lehnt eine Liste genauso ab test('Nachbau: notEqual mit Liste (oder ohne Wert) => HTTP 400 wie Appwrite, auch ueber den rohen Pfad', async () => { seedOutbox('f_ne400', 2) const roh = (values) => JSON.stringify({ method: 'notEqual', attribute: 'status', values }) for (const values of [['a', 'b'], ['a', 'b', 'c'], []]) { const antwort = await fetch(`${rawDocs(aw, 'termineOutbox')}?queries[]=${encodeURIComponent(roh(values))}`) assert.equal(antwort.status, 400, JSON.stringify(values)) assert.deepEqual(await antwort.json(), { message: 'Invalid query: NotEqual queries require exactly one value.', code: 400, type: 'general_query_invalid' }) } // durch appwriteAdmin: derselbe Fehler (status, type), kein Treffer await assert.rejects( listDocuments('termineOutbox', [roh(['a', 'b'])]), (err) => err.status === 400 && err.type === 'general_query_invalid' && /exactly one value/.test(err.message), ) // ein Wert geht assert.equal((await listDocuments('termineOutbox', [roh(['a']), Query.equal('featureId', 'f_ne400')])).length, 2) }) // Appwrite 1.8.1 (M3): SQL-Semantik, ein Dokument ohne Wert (null oder nie gesetzt) passt zu notEqual nicht test('Nachbau: ein Dokument mit Feld null oder ohne Feld passt nicht zu notEqual', async () => { aw.seed('termineOutbox', { $id: 't2_f_nl00', featureId: 'f_nl', status: 'wartet' }) aw.seed('termineOutbox', { $id: 't2_f_nl01', featureId: 'f_nl', status: null }) aw.seed('termineOutbox', { $id: 't2_f_nl02', featureId: 'f_nl' }) aw.seed('termineOutbox', { $id: 't2_f_nl03', featureId: 'f_nl', status: 'gesendet' }) const ids = async (...queries) => (await listDocuments('termineOutbox', [Query.equal('featureId', 'f_nl'), ...queries, Query.orderAsc('$id')])).map((d) => d.$id) assert.deepEqual(await ids(Query.notEqual('status', 'wartet')), ['t2_f_nl03'], 'null und fehlend sind ausgeschlossen') assert.deepEqual(await ids(Query.notEqual('status', 'gibtEsNicht')), ['t2_f_nl00', 't2_f_nl03']) assert.deepEqual(await ids(...keinerVon('status', ['wartet', 'gesendet'])), []) assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nl'), Query.notEqual('status', 'wartet')]), 1) }) test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countDocuments liefert es', async () => { seedOutbox('f_total', 5) seedOutbox('f_total_fremd', 3) 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, ...keinerVon('$id', ['t2_f_total00', 't2_f_total01'])]), 3) assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nirgends')]), 0) // 30 Treffer ueber dem Standardlimit 25: total sagt 30, die Liste hat 25 seedOutbox('f_total_dreissig', 30) 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') 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() } })