Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden, genauerer Nachbau
appwriteAdmin: adminFetch kennt die Option raw (Body unverpackt), createDocuments (Bulk-Create, alles oder nichts, 409 bei vorhandener ID), listAllDocuments (seitenweise per cursorAfter, sortiert nach $id, Fehler ueber max) und Query.between/greaterThanEqual/ lessThanEqual/cursorAfter. Nachbau: Bulk-Create mit Alles-oder-nichts (409/400, hoechstens 100), die neuen Abfragen samt cursorAfter, Zeitpunktvergleich fuer ISO-Datumswerte, Optionen datetimes (Normalisierung auf +00:00, leeres Datum => 400) und defaultLimit (Standard-Limit wie Appwrite). Ohne die Optionen unveraendert. testApp exportiert send(method, path, opts). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
334dc1e669
commit
a5e12d7f34
4 files changed
+407
-23
No files matched your search
+128
-17
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
Reference in new issue
Block a user