feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen

Reine Logik ohne fetch: die Antworten beider Anbieter werden mit zod
geprüft und in eine gemeinsame Form gebracht. Geprüft wird gegen
anonymisierte Beispielantworten in test/fixtures — 61 Tests.

Zwei Stellen, an denen bewusst abgebrochen statt geraten wird:
- Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir
  fragen mit Prefer: outlook.timezone="UTC" an; kommt etwas anderes
  zurück, wäre jede Umrechnung geraten und würde Termine um Stunden
  verschieben.
- Ein unbekanntes showAs wird UNBEKANNT und zählt damit als belegt. Lieber
  eine Stunde zu viel als eine Doppelbuchung.

Zur Belegung: Ganztägige Termine zählen nicht — ein Urlaubstag oder
Geburtstag im Kalender blockiert sonst den ganzen Tag, obwohl er keine
Arbeitszeit kostet. Ebenso wenig zählen ausdrücklich als frei markierte
Termine. Überlappende Termine werden zusammengelegt, damit eine
Doppelbuchung nicht doppelt zählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 18:04:41 +02:00
co-authored by Claude Opus 5
parent 79e6c0d556
commit 6fda2a579e
13 changed files with 1221 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph'
import { AntwortUnbrauchbar } from './typen'
/** Beispielantworten aus test/fixtures, anonymisiert. */
function fixture(name: string): unknown {
return JSON.parse(
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
) as unknown
}
describe('zeigtAlsAusGraph', () => {
it('übersetzt die bekannten Werte', () => {
expect(zeigtAlsAusGraph('free')).toBe('FREI')
expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT')
expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT')
expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND')
expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS')
})
it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => {
expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT')
expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT')
})
})
describe('graphZeitpunkt', () => {
it('liest eine UTC-Angabe', () => {
expect(
graphZeitpunkt({
dateTime: '2026-03-17T08:00:00.0000000',
timeZone: 'UTC',
}).toISOString(),
).toBe('2026-03-17T08:00:00.000Z')
})
it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => {
expect(
graphZeitpunkt({
dateTime: '2026-03-17T08:00:00.1234567',
timeZone: 'UTC',
}).toISOString(),
).toBe('2026-03-17T08:00:00.123Z')
})
it('kommt auch ohne Nachkommastellen zurecht', () => {
expect(
graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(),
).toBe('2026-03-17T08:00:00.000Z')
})
it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => {
expect(() =>
graphZeitpunkt({
dateTime: '2026-03-17T09:00:00.0000000',
timeZone: 'W. Europe Standard Time',
}),
).toThrow(AntwortUnbrauchbar)
})
it('bricht bei unlesbarer Zeitangabe ab', () => {
expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow(
AntwortUnbrauchbar,
)
})
})
describe('graphSeiteLesen', () => {
it('liest die erste Seite samt Verweis auf die nächste', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite1'))
expect(seite.geaendert).toHaveLength(2)
expect(seite.entfernt).toEqual([])
expect(seite.weiter).toContain('$skiptoken=')
expect(seite.deltaVerweis).toBeNull()
})
it('übernimmt Betreff, Kennung und Etag', () => {
const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert
expect(erster?.extId).toBe('BEISPIEL-TERMIN-1')
expect(erster?.betreff).toBe('Wochenbesprechung')
expect(erster?.etag).toContain('BEISPIELETAG1')
expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z')
expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z')
expect(erster?.ganztaegig).toBe(false)
expect(erster?.zeigtAls).toBe('GEBUCHT')
})
it('erkennt ganztägige Termine', () => {
const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1]
expect(ganztaegig?.ganztaegig).toBe(true)
expect(ganztaegig?.zeigtAls).toBe('ABWESEND')
})
it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
expect(seite.weiter).toBeNull()
expect(seite.deltaVerweis).toContain('$deltatoken=')
})
it('trennt Entferntes von Geändertem', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9'])
expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3'])
})
it('erkennt einen als frei markierten Termin', () => {
const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert
expect(frei?.zeigtAls).toBe('FREI')
})
it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => {
expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow(
AntwortUnbrauchbar,
)
})
it('weist eine Antwort ohne value-Liste ab', () => {
expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar)
})
it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => {
const seite = graphSeiteLesen({
value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }],
})
expect(seite.geaendert).toEqual([])
expect(seite.entfernt).toEqual([])
})
})