367 lines
23 KiB
JavaScript
367 lines
23 KiB
JavaScript
import { test, after, beforeEach, afterEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { startFakeStripe } from './support/fakeStripe.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
import { mailSink } from './support/mailSink.js'
|
|
|
|
// Buchungskalender Task 9: iCal-Parser/-Export (ical.js), Abruf + Abgleich (icalAbgleich.js), Export-Route (unterkunftSeite.js)
|
|
const NAMEN = ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukSperren', 'ukAnfragen', 'customers', 'websiteProjects']
|
|
const aw = await startAppwriteMock({
|
|
schema: Object.fromEntries(NAMEN.filter((n) => !['customers', 'websiteProjects'].includes(n)).map((n) => [n, schemaKeys(n)])),
|
|
datetimes: { ukAnfragen: ['fristAt'], ukEinstellungen: ['icalFehlerMailAt'] },
|
|
defaultLimit: 25,
|
|
})
|
|
const st = await startFakeStripe()
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
|
|
const { config } = await import('../server/config.js')
|
|
const { startTestApp, rawGet } = await import('./support/testApp.js')
|
|
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
|
|
const mails = await import('../server/services/unterkunft/mails.js')
|
|
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
|
const { warteAufVersand } = await import('../server/services/termine/outbox.js')
|
|
const { parseIcs, baueIcs } = await import('../server/services/unterkunft/ical.js')
|
|
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
|
const { holeIcs, gleicheAb, gleicheAlleAb, istPrivateAdresse } = await import('../server/services/unterkunft/icalAbgleich.js')
|
|
const { viertelstundenJob } = await import('../server/services/unterkunft/jobs.js')
|
|
const { unterkunftSeiteRouter } = await import('../server/routes/unterkunftSeite.js')
|
|
const { ladeUnterkunft, listeSperren, ladeEinstellungen } = await import('../server/services/unterkunft/speicher.js')
|
|
const app = await startTestApp({ '/': unterkunftSeiteRouter() })
|
|
after(() => { setMailTransportForTests(null); return Promise.all([app.close(), aw.close(), st.close()]) })
|
|
|
|
const fx = (n) => readFileSync(new URL(`./fixtures/ical/${n}`, import.meta.url), 'utf8')
|
|
const FEATURE = 'f_p1_unterkunft'
|
|
const vorher = { ...config.unterkunft }
|
|
let sink
|
|
const OPT = { heute: '2026-11-07', horizontTage: 540 }
|
|
const antwort = (text, status = 200, headers = {}) => new Response(text, { status, headers })
|
|
const oeffentlich = async () => [{ address: '93.184.216.34', family: 4 }]
|
|
|
|
beforeEach(async () => {
|
|
for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
|
|
Object.assign(config.unterkunft, vorher, { mode: 'on' })
|
|
mails._aufrufe.length = 0
|
|
config.smtp.from = 'noreply@webklar.com'
|
|
sink = mailSink()
|
|
setMailTransportForTests(sink)
|
|
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true })
|
|
aw.seed('customers', { $id: 'c1', email: 'mimi@example.com' })
|
|
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau' })
|
|
})
|
|
afterEach(() => Object.assign(config.unterkunft, vorher))
|
|
|
|
// ------------------------------------------------------------------------------------------------ Parser
|
|
|
|
test('parseIcs: Booking-Fixture liefert DATE-Ereignisse, Airbnb ebenso', () => {
|
|
const b = parseIcs(fx('booking.ics'), OPT)
|
|
assert.deepEqual(b.ereignisse.map((e) => [e.von, e.bis, e.uid]), [['2026-11-10', '2026-11-13', 'b1@booking.com'], ['2026-12-01', '2026-12-03', 'b2@booking.com']])
|
|
const a = parseIcs(fx('airbnb.ics'), OPT)
|
|
assert.deepEqual(a.ereignisse.map((e) => [e.von, e.bis]), [['2026-11-20', '2026-11-25'], ['2026-12-01', '2026-12-02']])
|
|
})
|
|
|
|
test('parseIcs: UTC-, TZID- und Datumswerte, fehlendes DTEND = 1 Nacht, CANCELLED, Fenster', () => {
|
|
const t = parseIcs(fx('tzid.ics'), OPT).ereignisse
|
|
const nach = Object.fromEntries(t.map((e) => [e.uid, [e.von, e.bis]]))
|
|
assert.deepEqual(nach.z1, ['2026-11-10', '2026-11-12'])
|
|
assert.deepEqual(nach.z2, ['2026-11-10', '2026-11-12'])
|
|
assert.deepEqual(nach.z3, ['2026-12-01', '2026-12-02'], '23:30 UTC am 30.11. ist in Berlin schon der 1.12.')
|
|
assert.deepEqual(nach.z4, ['2026-12-15', '2026-12-16'])
|
|
assert.equal(nach.z5, undefined, 'CANCELLED ignoriert')
|
|
assert.equal(nach.z6, undefined, 'vergangen')
|
|
assert.equal(nach.z7, undefined, 'hinter dem Horizont')
|
|
})
|
|
|
|
test('parseIcs: gefaltete Zeilen stoeren nicht, DTEND-Text in SUMMARY wird nicht gelesen', () => {
|
|
const g = parseIcs(fx('gefaltet.ics'), OPT).ereignisse
|
|
assert.deepEqual(g.map((e) => [e.von, e.bis]), [['2026-11-10', '2026-11-12']])
|
|
})
|
|
|
|
test('parseIcs: HTML oder Unsinn => kein_ics', () => {
|
|
assert.throws(() => parseIcs(fx('fehlerseite.html'), OPT), { message: 'kein_ics' })
|
|
assert.throws(() => parseIcs('', OPT), { message: 'kein_ics' })
|
|
assert.throws(() => parseIcs(null, OPT), { message: 'kein_ics' })
|
|
assert.deepEqual(parseIcs('BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n', OPT).ereignisse, [])
|
|
})
|
|
|
|
test('baueIcs: Semikolon, Komma und Backslash im Namen werden maskiert', () => {
|
|
const s = baueIcs({ name: 'A;B,C\\D', ereignisse: [], jetzt: new Date('2026-11-07T10:00:00Z') })
|
|
assert.match(s, /X-WR-CALNAME:A\\;B\\,C\\\\D\r\n/)
|
|
})
|
|
|
|
test('baueIcs: CRLF, gefaltete Zeilen <= 75 Oktette, Pflichtfelder', () => {
|
|
const s = baueIcs({ name: 'Wohnung mit sehr langem Namen äöü '.repeat(4), ereignisse: [{ uid: 'a1@project.webklar.com', von: '2026-11-10', bis: '2026-11-12' }], jetzt: new Date('2026-11-07T10:00:00Z') })
|
|
assert.ok(s.startsWith('BEGIN:VCALENDAR\r\n') && s.endsWith('END:VCALENDAR\r\n'))
|
|
assert.ok(!/(^|[^\r])\n/.test(s), 'nur CRLF')
|
|
for (const z of s.split('\r\n')) assert.ok(Buffer.byteLength(z) <= 75, z)
|
|
assert.match(s, /PRODID:-\/\/WEBklar\/\/Buchungskalender\/\/DE/)
|
|
assert.match(s, /SUMMARY:Belegt/)
|
|
assert.match(s, /DTSTART;VALUE=DATE:20261110/)
|
|
assert.match(s, /DTEND;VALUE=DATE:20261112/)
|
|
assert.match(s, /UID:a1@project\.webklar\.com/)
|
|
assert.match(s, /DTSTAMP:20261107T100000Z/)
|
|
assert.equal(parseIcs(s, OPT).ereignisse.length, 1, 'Rundreise')
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Abruf, SSRF
|
|
|
|
test('istPrivateAdresse: private, Loopback, Link-Local, CGNAT, ULA, Multicast, unspecified, IPv4-mapped', () => {
|
|
for (const a of ['127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '255.255.255.255', '::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1', '::ffff:7f00:1', '64:ff9b::a00:1', '2002:7f00:1::1']) {
|
|
assert.equal(istPrivateAdresse(a), true, a)
|
|
}
|
|
for (const a of ['93.184.216.34', '8.8.8.8', '172.32.0.1', '2606:2800:220:1:248:1893:25c8:1946', '::ffff:8.8.8.8']) assert.equal(istPrivateAdresse(a), false, a)
|
|
assert.equal(istPrivateAdresse('kein-ip'), true, 'Unklares gilt als privat')
|
|
})
|
|
|
|
test('holeIcs: nur https, keine privaten Ziele, kein Abruf bei Ablehnung', async () => {
|
|
let aufrufe = 0
|
|
const fetch = async () => (aufrufe++, antwort(fx('booking.ics')))
|
|
await assert.rejects(holeIcs('http://example.com/a.ics', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' })
|
|
for (const u of ['https://127.0.0.1/a.ics', 'https://10.0.0.5/', 'https://[::1]/', 'https://[::ffff:127.0.0.1]/', 'https://2130706433/', 'https://169.254.169.254/latest']) {
|
|
await assert.rejects(holeIcs(u, { fetch, lookup: oeffentlich }), { code: 'private_adresse' }, u)
|
|
}
|
|
await assert.rejects(holeIcs('https://intern.example/a.ics', { fetch, lookup: async () => [{ address: '93.184.216.34', family: 4 }, { address: '10.1.2.3', family: 4 }] }), { code: 'private_adresse' }, 'eine private Antwort genuegt')
|
|
await assert.rejects(holeIcs('https://u:p@example.com/a.ics', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' })
|
|
assert.equal(aufrufe, 0)
|
|
})
|
|
|
|
test('holeIcs: Erfolg, Adresse wird dem Abruf mitgegeben (Pinning), Fehlerstatus, zu gross, Timeout', async () => {
|
|
let gesehen
|
|
const text = await holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async (u, o) => ((gesehen = o), antwort(fx('booking.ics'))) })
|
|
assert.match(text, /BEGIN:VCALENDAR/)
|
|
assert.deepEqual(gesehen.adresse, { address: '93.184.216.34', family: 4 })
|
|
assert.equal(gesehen.redirect, 'manual')
|
|
await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x', 503) }), { code: 'http_503' })
|
|
config.unterkunft.icalMaxBytes = 100
|
|
await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x'.repeat(5000)) }), { code: 'zu_gross' })
|
|
await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x'.repeat(50), 200, { 'content-length': '5000' }) }), { code: 'zu_gross' })
|
|
config.unterkunft.icalMaxBytes = 1_000_000
|
|
config.unterkunft.icalTimeoutMs = 50
|
|
await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: (u, o) => new Promise((_, rej) => o.signal.addEventListener('abort', () => rej(new Error('abort')))) }), { code: 'timeout' })
|
|
})
|
|
|
|
test('holeIcs: Weiterleitungen werden je erneut geprueft, hoechstens 3', async () => {
|
|
const ziele = []
|
|
const weiter = (nach) => antwort('', 302, { location: nach })
|
|
let fetch = async (u) => (ziele.push(u), u.endsWith('/ziel.ics') ? antwort(fx('booking.ics')) : weiter(u.replace(/\/\d$/, '') + '/ziel.ics'))
|
|
assert.match(await holeIcs('https://example.com/start/1', { fetch, lookup: oeffentlich }), /VCALENDAR/)
|
|
fetch = async () => weiter('http://example.com/x')
|
|
await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' })
|
|
fetch = async () => weiter('https://127.0.0.1/geheim')
|
|
await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'private_adresse' })
|
|
// relativer Pfad, Rebinding auf zweiten Host: der zweite Host loest privat auf
|
|
fetch = async () => weiter('https://zweiter.example/x')
|
|
await assert.rejects(
|
|
holeIcs('https://example.com/a', { fetch, lookup: async (h) => [{ address: h === 'example.com' ? '93.184.216.34' : '10.0.0.9', family: 4 }] }),
|
|
{ code: 'private_adresse' },
|
|
)
|
|
let n = 0
|
|
fetch = async () => (n++, weiter(`https://example.com/${n}`))
|
|
await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'zu_viele_weiterleitungen' })
|
|
assert.equal(n, 4, 'erster Abruf + 3 Weiterleitungen')
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Abgleich
|
|
|
|
const imp = (extra = {}) => ({ id: 'imp1', name: 'Booking.com', url: 'https://example.com/geheim-token/cal.ics', letzterAbrufAt: null, letzterErfolgAt: null, fehler: '', ...extra })
|
|
function seedU(importe = [imp()], extra = {}) {
|
|
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', aktiv: true, sort: 1, icalExportToken: 'T'.repeat(43), icalImporte: JSON.stringify(importe), ...extra })
|
|
}
|
|
const sperren = async () => (await listeSperren('u1')).map((s) => [s.von, s.bis, s.quelle]).sort()
|
|
const NOW = new Date('2026-11-07T10:00:00Z')
|
|
|
|
test('gleicheAb: Erfolg legt Sperren an (quelle ical:<id>), ersetzt alte, laesst manuelle stehen', async () => {
|
|
seedU()
|
|
aw.seed('ukSperren', { $id: 's_alt', unterkunftId: 'u1', featureId: FEATURE, von: '2026-10-01', bis: '2026-10-03', quelle: 'ical:imp1' })
|
|
aw.seed('ukSperren', { $id: 's_fremd', unterkunftId: 'u1', featureId: FEATURE, von: '2026-10-01', bis: '2026-10-03', quelle: 'ical:imp2' })
|
|
aw.seed('ukSperren', { $id: 's_man', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-01', bis: '2026-11-02', quelle: 'manuell' })
|
|
const u = await ladeUnterkunft('u1')
|
|
const r = await gleicheAb(u, u.icalImporte[0], { now: NOW, lookup: oeffentlich, fetch: async () => antwort(fx('booking.ics')) })
|
|
assert.deepEqual(r, { ok: true, anzahl: 2 })
|
|
assert.deepEqual(await sperren(), [['2026-10-01', '2026-10-03', 'ical:imp2'], ['2026-11-01', '2026-11-02', 'manuell'], ['2026-11-10', '2026-11-13', 'ical:imp1'], ['2026-12-01', '2026-12-03', 'ical:imp1']])
|
|
const i = (await ladeUnterkunft('u1')).icalImporte[0]
|
|
assert.equal(i.letzterErfolgAt, NOW.toISOString())
|
|
assert.equal(i.letzterAbrufAt, NOW.toISOString())
|
|
assert.equal(i.fehler, '')
|
|
// zweiter Lauf mit gleichem Feed: unveraendert
|
|
const vorIds = (await listeSperren('u1')).map((s) => s.$id).sort()
|
|
await gleicheAb(await ladeUnterkunft('u1'), i, { now: NOW, lookup: oeffentlich, fetch: async () => antwort(fx('booking.ics')) })
|
|
assert.deepEqual((await listeSperren('u1')).map((s) => s.$id).sort(), vorIds)
|
|
})
|
|
|
|
test('gleicheAb: Fehler laesst alte Sperren stehen, setzt fehler, URL steht nie im Fehlertext', async () => {
|
|
seedU([imp({ letzterErfolgAt: '2026-11-07T08:00:00.000Z' })])
|
|
aw.seed('ukSperren', { $id: 's_alt', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-13', quelle: 'ical:imp1' })
|
|
const u = await ladeUnterkunft('u1')
|
|
for (const [fetch, code] of [[async () => antwort(fx('fehlerseite.html')), 'kein_ics'], [async () => antwort('x', 500), 'http_500'], [async () => { throw new Error('ECONNRESET https://example.com/geheim-token/cal.ics') }, 'netzwerk']]) {
|
|
const r = await gleicheAb(u, u.icalImporte[0], { now: NOW, lookup: oeffentlich, fetch })
|
|
assert.deepEqual(r, { ok: false, fehler: code })
|
|
}
|
|
assert.deepEqual(await sperren(), [['2026-11-10', '2026-11-13', 'ical:imp1']])
|
|
const i = (await ladeUnterkunft('u1')).icalImporte[0]
|
|
assert.equal(i.fehler, 'netzwerk')
|
|
assert.equal(i.letzterErfolgAt, '2026-11-07T08:00:00.000Z', 'letzter Erfolg bleibt')
|
|
assert.equal(i.letzterAbrufAt, NOW.toISOString())
|
|
assert.ok(!JSON.stringify(i.fehler).includes('geheim'))
|
|
})
|
|
|
|
test('gleicheAb: wurde der Import waehrend des Abrufs entfernt, bleiben keine Sperren zurueck', async () => {
|
|
seedU()
|
|
const u = await ladeUnterkunft('u1')
|
|
const r = await gleicheAb(u, u.icalImporte[0], {
|
|
now: NOW, lookup: oeffentlich,
|
|
fetch: async () => {
|
|
await updateDocument(config.collections.ukUnterkuenfte, 'u1', { icalImporte: '[]' })
|
|
return antwort(fx('booking.ics'))
|
|
},
|
|
})
|
|
assert.equal(r.ok, false)
|
|
assert.deepEqual(await sperren(), [])
|
|
})
|
|
|
|
test('K33: erst nach icalFehlerStunden ohne Erfolg, danach hoechstens 1x je 24 h', async () => {
|
|
const fetch = async () => antwort('x', 500)
|
|
const lauf = async (now) => {
|
|
const u = await ladeUnterkunft('u1')
|
|
return gleicheAb(u, u.icalImporte[0], { now, lookup: oeffentlich, fetch })
|
|
}
|
|
const K33 = () => mails._aufrufe.filter((a) => a.template === 'K33')
|
|
seedU([imp({ letzterErfolgAt: new Date(NOW.getTime() - 5 * 3600e3).toISOString() })])
|
|
await lauf(NOW)
|
|
assert.equal(K33().length, 0, 'nach 5 h noch keine Mail')
|
|
await lauf(new Date(NOW.getTime() + 2 * 3600e3))
|
|
assert.equal(K33().length, 1, 'nach 7 h Mail')
|
|
// das Testprotokoll haelt keine Werte mehr fest (nur {an, template, anfrageId}); die Platzhalter prueft unterkunftMails.test.js
|
|
assert.deepEqual(Object.keys(K33()[0]).sort(), ['an', 'anfrageId', 'template'])
|
|
await warteAufVersand()
|
|
const k33 = sink.sent.find((m) => /Erste/.test(m.text ?? ''))
|
|
assert.ok(k33, 'Mail nennt die Unterkunft (K33 kennt keine Platzhalter quelle/seit)')
|
|
assert.deepEqual(k33.to.map((x) => x.address), ['mimi@example.com'])
|
|
assert.ok(!JSON.stringify(k33).includes('geheim'))
|
|
assert.ok(!JSON.stringify(K33()[0]).includes('geheim'))
|
|
assert.ok((await ladeEinstellungen(FEATURE)).icalFehlerMailAt)
|
|
await lauf(new Date(NOW.getTime() + 10 * 3600e3))
|
|
assert.equal(K33().length, 1, 'binnen 24 h keine zweite')
|
|
await lauf(new Date(NOW.getTime() + 27 * 3600e3))
|
|
assert.equal(K33().length, 2, 'nach 24 h wieder')
|
|
})
|
|
|
|
test('K33: Import ohne jeden Erfolg zaehlt ab dem ersten Fehler', async () => {
|
|
seedU([imp()])
|
|
const fetch = async () => antwort('x', 404)
|
|
const lauf = async (now) => { const u = await ladeUnterkunft('u1'); return gleicheAb(u, u.icalImporte[0], { now, lookup: oeffentlich, fetch }) }
|
|
await lauf(NOW)
|
|
await lauf(new Date(NOW.getTime() + 3 * 3600e3))
|
|
assert.equal(mails._aufrufe.length, 0)
|
|
await lauf(new Date(NOW.getTime() + 7 * 3600e3))
|
|
assert.equal(mails._aufrufe.length, 1)
|
|
})
|
|
|
|
test('gleicheAlleAb: nur aktive Funktionen, nur faellige Importe, Deckel, Fehler eines Imports stoppt die anderen nicht', async () => {
|
|
seedU([imp({ id: 'a1', url: 'https://example.com/a' }), imp({ id: 'a2', url: 'https://example.com/b' }), imp({ id: 'a3', url: 'https://example.com/c', letzterAbrufAt: new Date(NOW.getTime() - 2 * 60e3).toISOString() })])
|
|
aw.seed('portalFeatures', { $id: 'f_aus', customerId: 'c2', projectId: 'p2', featureKey: 'unterkunft', state: 'off' })
|
|
aw.seed('ukUnterkuenfte', { $id: 'u_aus', featureId: 'f_aus', customerId: 'c2', projectId: 'p2', name: 'Aus', aktiv: true, icalImporte: JSON.stringify([imp({ id: 'x', url: 'https://example.com/x' })]) })
|
|
const abgerufen = []
|
|
const fetch = async (u) => {
|
|
abgerufen.push(u)
|
|
if (u.endsWith('/a')) throw new Error('boom')
|
|
return antwort(fx('booking.ics'))
|
|
}
|
|
const r = await gleicheAlleAb(NOW, { fetch, lookup: oeffentlich })
|
|
assert.deepEqual(abgerufen.sort(), ['https://example.com/a', 'https://example.com/b'])
|
|
assert.equal(r.importe, 2)
|
|
assert.equal(r.fehler, 1)
|
|
const zweiter = await gleicheAlleAb(NOW, { fetch, lookup: oeffentlich, maxImporte: 0 })
|
|
assert.equal(zweiter.importe, 0)
|
|
})
|
|
|
|
test('gleicheAlleAb: Log enthaelt nie die volle URL', async () => {
|
|
seedU([imp({ url: 'https://ical.example.com/SECRET123/cal.ics' })])
|
|
const zeilen = []
|
|
const orig = { log: console.log, warn: console.warn, error: console.error }
|
|
console.log = console.warn = console.error = (...a) => zeilen.push(a.join(' '))
|
|
try {
|
|
await gleicheAlleAb(NOW, { lookup: oeffentlich, fetch: async () => { throw new Error('kaputt https://ical.example.com/SECRET123/cal.ics') } })
|
|
} finally {
|
|
Object.assign(console, orig)
|
|
}
|
|
assert.ok(zeilen.length > 0)
|
|
assert.ok(!zeilen.join('\n').includes('SECRET123'), zeilen.join('\n'))
|
|
assert.ok(zeilen.join('\n').includes('ical.example.com'))
|
|
})
|
|
|
|
test('viertelstundenJob: aus bei off, laeuft bei on', async () => {
|
|
seedU([])
|
|
config.unterkunft.mode = 'off'
|
|
assert.deepEqual(await viertelstundenJob(NOW), { aus: true })
|
|
config.unterkunft.mode = 'on'
|
|
const r = await viertelstundenJob(NOW)
|
|
assert.equal(r.importe, 0)
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Export
|
|
|
|
const TOKEN = 'ExportTokenAbcdefghijklmnopqrstuvwxyz0123456'
|
|
test('Export: Buchung + manuelle Sperre + Reservierung mit Frist, nie ical, nie Gastname; CRLF; Header', async () => {
|
|
seedU([imp()], { icalExportToken: TOKEN })
|
|
const basis = { featureId: FEATURE, customerId: 'c1', projectId: 'p1', unterkunftId: 'u1', gast: JSON.stringify({ name: 'Eva Gast', email: 'eva@example.com' }), tokenHash: 'h', ipHash: 'i', personen: 2 }
|
|
aw.seed('ukAnfragen', { $id: 'a_geb', ...basis, status: 'gebucht', von: '2027-01-10', bis: '2027-01-12', nummer: 'A-1' })
|
|
aw.seed('ukAnfragen', { $id: 'a_bez', ...basis, status: 'bezahlt', von: '2027-02-10', bis: '2027-02-12', nummer: 'A-2' })
|
|
aw.seed('ukAnfragen', { $id: 'a_res', ...basis, status: 'freigegeben', fristAt: new Date(Date.now() + 86400e3).toISOString(), von: '2027-03-10', bis: '2027-03-12', nummer: 'A-3' })
|
|
aw.seed('ukAnfragen', { $id: 'a_alt', ...basis, status: 'freigegeben', fristAt: new Date(Date.now() - 86400e3).toISOString(), von: '2027-04-10', bis: '2027-04-12', nummer: 'A-4' })
|
|
aw.seed('ukAnfragen', { $id: 'a_neu', ...basis, status: 'neu', von: '2027-05-10', bis: '2027-05-12', nummer: 'A-5' })
|
|
aw.seed('ukAnfragen', { $id: 'a_sto', ...basis, status: 'storniert', von: '2027-06-10', bis: '2027-06-12', nummer: 'A-6' })
|
|
aw.seed('ukSperren', { $id: 's_man', unterkunftId: 'u1', featureId: FEATURE, von: '2027-07-01', bis: '2027-07-03', quelle: 'manuell', notiz: 'Eigenbedarf privat' })
|
|
aw.seed('ukSperren', { $id: 's_ical', unterkunftId: 'u1', featureId: FEATURE, von: '2027-08-01', bis: '2027-08-03', quelle: 'ical:imp1', notiz: 'Booking' })
|
|
const r = await rawGet(`/unterkunft/ical/${TOKEN}.ics`)
|
|
assert.equal(r.status, 200)
|
|
assert.match(r.headers.get('content-type'), /^text\/calendar; charset=utf-8/)
|
|
assert.equal(r.headers.get('cache-control'), 'no-store')
|
|
const body = r.body
|
|
assert.ok(!/(^|[^\r])\n/.test(body))
|
|
const e = parseIcs(body, { heute: '2026-11-07', horizontTage: 900 }).ereignisse
|
|
assert.deepEqual(e.map((x) => x.von).sort(), ['2027-01-10', '2027-02-10', '2027-03-10', '2027-07-01'])
|
|
assert.ok(body.includes('UID:a_geb@project.webklar.com') && body.includes('UID:s_man@project.webklar.com'))
|
|
assert.ok(!body.includes('Eva') && !body.includes('eva@') && !body.includes('Eigenbedarf') && !body.includes('Booking'))
|
|
})
|
|
|
|
test('Export: unbekanntes Token, falsches Format, Funktion aus/inaktiv => 404', async () => {
|
|
seedU([], { icalExportToken: TOKEN })
|
|
assert.equal((await rawGet(`/unterkunft/ical/${'Z'.repeat(43)}.ics`)).status, 404)
|
|
assert.equal((await rawGet('/unterkunft/ical/kurz.ics')).status, 404)
|
|
assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}`)).status, 404)
|
|
assert.equal((await rawGet(`/unterkunft/ical/..%2f${TOKEN}.ics`)).status, 404)
|
|
assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 200)
|
|
config.unterkunft.mode = 'off'
|
|
assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404)
|
|
config.unterkunft.mode = 'on'
|
|
await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'off' })
|
|
assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404)
|
|
})
|
|
|
|
test('Export: nur aktive Unterkuenfte; nur Ereignisse ab gestern', async () => {
|
|
const basis = { featureId: FEATURE, customerId: 'c1', projectId: 'p1', unterkunftId: 'u1', gast: JSON.stringify({ name: 'Eva' }), tokenHash: 'h', ipHash: 'i', personen: 2, status: 'gebucht' }
|
|
const tag = (n) => new Date(Date.now() + n * 86400e3).toISOString().slice(0, 10)
|
|
seedU([], { icalExportToken: TOKEN })
|
|
aw.seed('ukAnfragen', { $id: 'a_lang_vorbei', ...basis, von: tag(-30), bis: tag(-10), nummer: 'A-1' })
|
|
aw.seed('ukAnfragen', { $id: 'a_laeuft', ...basis, von: tag(-3), bis: tag(+3), nummer: 'A-2' })
|
|
aw.seed('ukSperren', { $id: 's_vorbei', unterkunftId: 'u1', featureId: FEATURE, von: tag(-20), bis: tag(-15), quelle: 'manuell' })
|
|
const r = await rawGet(`/unterkunft/ical/${TOKEN}.ics`)
|
|
assert.equal(r.status, 200)
|
|
assert.ok(r.body.includes('UID:a_laeuft@') && !r.body.includes('a_lang_vorbei') && !r.body.includes('s_vorbei'))
|
|
await updateDocument(config.collections.ukUnterkuenfte, 'u1', { aktiv: false })
|
|
assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404, 'inaktive Unterkunft')
|
|
})
|
|
|
|
test('Export: hoechstens 60 Abrufe je Minute und IP, danach 429', async () => {
|
|
resetTermineLimitsForTests()
|
|
seedU([], { icalExportToken: TOKEN })
|
|
for (let i = 0; i < 60; i++) assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 200)
|
|
const r = await rawGet(`/unterkunft/ical/${TOKEN}.ics`)
|
|
assert.equal(r.status, 429)
|
|
assert.equal(r.headers.get('retry-after'), '60')
|
|
resetTermineLimitsForTests()
|
|
})
|