Terminplaner: CSV-Export seitenweise gestreamt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 09:32:14 +00:00
1 parent a1732613c9
commit d040a55f89
3 files changed
+187 -23

No files matched your search

+111
View File
@@ -38,6 +38,8 @@ const { speichereEinstellung, ladeEinstellung, resetKontextForTests } = await im
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { buchungsNummer } = await import('../server/services/termine/token.js')
const { datumKurz, uhrzeit } = await import('../server/services/termine/zeit.js')
const { exportSeite } = await import('../server/services/termine/buchungen.js')
const app = await startTestApp({
'/': kundenansichtGuard,
@@ -989,6 +991,115 @@ test('GET export.csv: Zellen mit = + - @ am Anfang (auch nach Leerraum, Tab, CR)
}
})
// Export seitenweise (Aufgabe A5): Grosse Mengen kommen Seite fuer Seite (limit 500, nach Beginn, Cursor = letzte Rohzeile)
// und werden gestreamt; Fehler vor dem ersten Byte sind ein normales JSON-500, danach bricht die Verbindung ab.
const START_MS = Date.UTC(2026, 9, 12, 6, 0)
const startZeit = (minuten) => new Date(START_MS + minuten * 60_000).toISOString().replace('Z', '+00:00')
const nr4 = (i) => String(i).padStart(5, '0')
/** Eine Buchung zum Index i (alle 30 Minuten eine, deshalb nie zwei mit gleichem Beginn); gleiche Form wie im Export erwartet. */
function massenBuchung(i, over = {}) {
return buchungsDoc(`b_m${nr4(i)}`, {
startsAt: startZeit(i * 30),
endsAt: startZeit(i * 30 + 25),
blockEndsAt: startZeit(i * 30 + 25),
gastName: `Gast ${nr4(i)}`,
gastEmail: `gast${nr4(i)}@example.com`,
gastTelefon: '',
gastNachricht: '',
...over,
})
}
const erwarteteZeile = (i) => {
const start = new Date(START_MS + i * 30 * 60_000)
return [buchungsNummer(`b_m${nr4(i)}`), 'Bestätigt', datumKurz(start), uhrzeit(start), 'Damenschnitt', 'Anna', `Gast ${nr4(i)}`, `gast${nr4(i)}@example.com`, '', ''].join(';')
}
test('GET export.csv: 1200 Buchungen + 40 Sperren + fremde Funktion kommen in drei Seiten zu je hoechstens 500, sortiert und im alten Format', async () => {
await speichere()
const reihenfolge = Array.from({ length: 1200 }, (_, i) => 1199 - i) // absteigend angelegt: die Sortierung kommt aus der Abfrage
for (const i of reihenfolge) aw.seed('termineBookings', massenBuchung(i))
for (let k = 0; k < 40; k += 1) {
aw.seed('termineBookings', massenBuchung(k, { $id: `s_m${nr4(k)}`, quelle: 'sperre', leistungName: 'Pause', gastName: '', gastEmail: '', startsAt: startZeit(k * 30 * 30 + 5), endsAt: startZeit(k * 30 * 30 + 15), blockEndsAt: startZeit(k * 30 * 30 + 15) }))
}
for (let k = 0; k < 30; k += 1) aw.seed('termineBookings', massenBuchung(k, { $id: `b_f${nr4(k)}`, featureId: 'f_anderer_termine', gastName: 'Fremder Gast' }))
aw.resetListCalls()
const { res, bytes, text } = await rohExport()
assert.equal(res.status, 200)
assert.equal(res.headers.get('content-type'), 'text/csv; charset=utf-8')
assert.match(res.headers.get('content-disposition'), /^attachment; filename="termine\.csv"$/)
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
const erwartet = `${['Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht', ...Array.from({ length: 1200 }, (_, i) => erwarteteZeile(i))].join('\r\n')}\r\n`
assert.equal(text.length, erwartet.length)
assert.ok(text === erwartet, '1200 Datenzeilen nach Beginn sortiert, Zeile fuer Zeile wie bisher')
assert.ok(!text.includes('Pause') && !text.includes('Fremder Gast'))
const abfragen = aw.listCalls('termineBookings')
assert.equal(abfragen.length, 3, '1240 Rohzeilen = 500 + 500 + 240 in genau drei Abfragen')
const sortiert = [...buchungen()].filter((b) => b.featureId === FEATURE).sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
abfragen.forEach((abfrage, nr) => {
const q = abfrage.queries
assert.deepEqual(q.find((x) => x.method === 'limit').values, [500], `Seite ${nr + 1}: limit 500`)
assert.deepEqual(q.find((x) => x.method === 'equal'), { method: 'equal', attribute: 'featureId', values: [FEATURE] })
assert.deepEqual(q.filter((x) => x.method === 'orderAsc'), [{ method: 'orderAsc', attribute: 'startsAt' }], 'nur nach Beginn sortiert, nie nach $id')
const cursor = q.find((x) => x.method === 'cursorAfter')
if (nr === 0) assert.equal(cursor, undefined)
else assert.deepEqual(cursor.values, [sortiert[nr * 500 - 1].$id], `Seite ${nr + 1}: Cursor = letzte Rohzeile der Seite davor (auch eine Sperre)`)
})
})
test('GET export.csv: 20100 Buchungen (ueber dem alten Deckel von 20000) werden vollstaendig geliefert, ohne list_limit_exceeded', async () => {
await speichere()
for (let i = 0; i < 20100; i += 1) aw.seed('termineBookings', massenBuchung(i))
aw.resetListCalls()
const { res, text } = await rohExport()
assert.equal(res.status, 200)
const zeilen = text.replace(/^/, '').split('\r\n')
assert.equal(zeilen.length, 20100 + 2, 'Kopf + 20100 Zeilen + leeres Ende')
assert.equal(zeilen[1], erwarteteZeile(0))
assert.equal(zeilen[20100], erwarteteZeile(20099))
assert.equal(zeilen[20101], '')
assert.equal(aw.listCalls('termineBookings').length, 41, '40 volle Seiten + 1 Restseite')
})
test('GET export.csv: Fehler bei der ersten Seite = normales JSON-500 ohne CSV-Anfang, Fehler bei der zweiten bricht den Download ab', async () => {
await speichere()
for (let i = 0; i < 600; i += 1) aw.seed('termineBookings', massenBuchung(i))
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500 })
const erste = await rohExport()
assert.equal(erste.res.status, 500)
assert.match(erste.res.headers.get('content-type'), /application\/json/)
assert.deepEqual(JSON.parse(erste.text), { error: 'Interner Serverfehler' })
assert.ok(!erste.text.includes('Nummer;') && !erste.text.includes(''), 'kein CSV-Anfang')
assert.equal(erste.res.headers.get('content-disposition'), null, 'kein Download-Kopf')
console.error.mock.resetCalls()
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, after: 1 })
const res = await fetch(`${app.url}/api/termine/export.csv?projectId=${P}`, { headers: { cookie } })
assert.equal(res.status, 200, 'die Kopfzeilen sind schon raus')
assert.match(res.headers.get('content-type'), /text\/csv/)
await assert.rejects(res.arrayBuffer(), 'die Antwort bricht ab statt als vollstaendige, aber gekuerzte Datei zu enden')
const log = JSON.stringify(console.error.mock.calls.map((c) => c.arguments))
assert.ok(console.error.mock.calls.length >= 1, 'der Abbruch steht im Log')
assert.ok(!/Gast \d|@example\.com|Max|Hauptstr/.test(log), `im Log kein Gastname, keine Adresse: ${log}`)
// danach geht der Export wieder
assert.equal((await rohExport()).text.split('\r\n').length, 600 + 2)
})
test('exportSeite: ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze TypeError (nie eine Abfrage ohne Funktionsfilter)', async () => {
await assert.rejects(exportSeite(''), TypeError)
await assert.rejects(exportSeite(undefined), TypeError)
await assert.rejects(exportSeite(FEATURE, { limit: 0 }), TypeError)
await assert.rejects(exportSeite(FEATURE, { limit: 1.5 }), TypeError)
await assert.rejects(exportSeite(FEATURE, { nach: '' }), TypeError)
await assert.rejects(exportSeite(FEATURE, { nach: 7 }), TypeError)
})
// ---------------------------------------------------------------- Abgeschaltet (disabled)
test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => {