busyfeed: Initial commit mit Tag-Deployment

Bestehende App plus Gitea-Actions-Pipeline: Tag v* loest Build, Tests
und Container-Neustart auf dem VPS aus.

- .gitea/workflows/deploy.yml: npm ci/build/test als Gate, danach
  docker compose up -d --build. Die .env wird aus dem Repo-Secret DOTENV
  erzeugt, damit auf dem Host keine Secret-Datei gepflegt werden muss.
- docker-compose.yml: fester Projektname (der CI-Job hat ein anderes
  Arbeitsverzeichnis als der Host), Image mit Versionstag fuer Rollback,
  fester Volume-Name.
- README: Deployment- und Rollback-Abschnitt, Token-Rotation angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:56:54 +02:00
commit 45d881f9a9
14 changed files with 1401 additions and 0 deletions

118
test/ics.test.js Normal file
View File

@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildIcs, parseIntervals } from '../dist/ics.js'
const build = (raw, name = 'Belegt (Exchange)') => {
const { intervals } = parseIntervals(raw)
return buildIcs({ syncedAt: '2026-08-19T12:04:11.000Z', intervals }, name)
}
test('rechnet Ortszeit mit Offset korrekt nach UTC', () => {
const { intervals } = parseIntervals('2026-08-19T14:00:00+02:00|2026-08-19T15:30:00+02:00')
assert.equal(intervals.length, 1)
assert.equal(intervals[0].start, '2026-08-19T12:00:00.000Z')
assert.equal(intervals[0].end, '2026-08-19T13:30:00.000Z')
})
test('normalisiert Offset ohne Doppelpunkt (+0200)', () => {
const { intervals } = parseIntervals('2026-08-19T14:00:00+0200|2026-08-19T15:00:00+0200')
assert.equal(intervals[0].start, '2026-08-19T12:00:00.000Z')
})
test('behandelt Winterzeit und Sommerzeit unterschiedlich', () => {
const winter = parseIntervals('2026-01-15T14:00:00+01:00|2026-01-15T15:00:00+01:00').intervals[0]
const sommer = parseIntervals('2026-07-15T14:00:00+02:00|2026-07-15T15:00:00+02:00').intervals[0]
assert.equal(winter.start, '2026-01-15T13:00:00.000Z')
assert.equal(sommer.start, '2026-07-15T12:00:00.000Z')
})
test('ganztaegiger Termin deckt den vollen lokalen Tag ab', () => {
const { intervals } = parseIntervals('2026-08-19T00:00:00+02:00|2026-08-20T00:00:00+02:00')
assert.equal(intervals[0].start, '2026-08-18T22:00:00.000Z')
assert.equal(intervals[0].end, '2026-08-19T22:00:00.000Z')
})
test('ueberspringt kaputte und sinnlose Zeilen, meldet sie zurueck', () => {
const { intervals, skipped } = parseIntervals(
[
'2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00',
'voelliger unsinn',
'2026-08-19T14:00:00+02:00',
'2026-08-19T15:00:00+02:00|2026-08-19T14:00:00+02:00',
'',
].join('\n'),
)
assert.equal(intervals.length, 1)
assert.deepEqual(skipped, [
'voelliger unsinn',
'2026-08-19T14:00:00+02:00',
'2026-08-19T15:00:00+02:00|2026-08-19T14:00:00+02:00',
])
})
test('erzeugt niemals TZID - das ist der Cal.com-Bug', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(!ics.includes('TZID'), 'ICS darf kein TZID enthalten')
assert.ok(!ics.includes('BEGIN:VTIMEZONE'), 'ICS darf keine VTIMEZONE enthalten')
})
test('schreibt alle Zeitstempel als UTC mit Z', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
const stamps = ics.match(/^(DTSTART|DTEND|DTSTAMP):.*$/gm)
assert.equal(stamps.length, 3)
for (const stamp of stamps) {
assert.match(stamp, /:\d{8}T\d{6}Z$/, `nicht UTC: ${stamp}`)
}
assert.ok(ics.includes('DTSTART:20260819T120000Z'))
assert.ok(ics.includes('DTEND:20260819T130000Z'))
})
test('erzeugt kein RRULE', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(!ics.includes('RRULE'))
})
test('gibt ausschliesslich Belegt als Titel aus', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
const summaries = ics.match(/^SUMMARY:.*$/gm)
assert.deepEqual(summaries, ['SUMMARY:Belegt'])
})
test('UID ist deterministisch ueber mehrere Laeufe', () => {
const raw = '2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00'
const first = build(raw).match(/^UID:.*$/m)[0]
const second = build(raw).match(/^UID:.*$/m)[0]
assert.equal(first, second)
})
test('verwendet CRLF als Zeilenende', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(ics.startsWith('BEGIN:VCALENDAR\r\n'))
assert.ok(ics.endsWith('END:VCALENDAR\r\n'))
assert.ok(!/[^\r]\n/.test(ics), 'jedes LF muss ein CR vorangehen')
})
test('escaped Sonderzeichen im Kalendernamen', () => {
const ics = build('', 'Belegt, extern; intern')
const bs = String.fromCharCode(92)
assert.ok(ics.includes(`X-WR-CALNAME:Belegt${bs}, extern${bs}; intern`))
})
test('faltet Zeilen laenger als 75 Oktette', () => {
const ics = build('', 'A'.repeat(200))
for (const line of ics.split('\r\n')) {
assert.ok(Buffer.byteLength(line, 'utf8') <= 75, `zu lang: ${line.length}`)
}
})
test('leerer Snapshot ergibt gueltigen, leeren Kalender', () => {
const ics = buildIcs(null, 'Belegt (Exchange)')
assert.ok(ics.includes('X-WR-CALDESC:Noch kein Sync empfangen'))
assert.ok(!ics.includes('BEGIN:VEVENT'))
assert.ok(ics.includes('END:VCALENDAR'))
})
test('meldet den Sync-Zeitpunkt im CALDESC', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(ics.includes('X-WR-CALDESC:Letzter Sync 2026-08-19T12:04:11.000Z'))
})