|
|
|
@@ -0,0 +1,572 @@
|
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
|
import { fristAmpel } from '@/lib/fristen/ampel'
|
|
|
|
|
import {
|
|
|
|
|
alsIsoTag,
|
|
|
|
|
ausIsoTag,
|
|
|
|
|
feiertagName,
|
|
|
|
|
istWochenende,
|
|
|
|
|
nichtWerktagHinweis,
|
|
|
|
|
plusMonate,
|
|
|
|
|
plusTage,
|
|
|
|
|
tag,
|
|
|
|
|
} from '@/lib/fristen/datum'
|
|
|
|
|
import {
|
|
|
|
|
alsIcsDatum,
|
|
|
|
|
icsErzeugen,
|
|
|
|
|
signaturErzeugen,
|
|
|
|
|
signaturPruefen,
|
|
|
|
|
textEscapen,
|
|
|
|
|
zeileFalten,
|
|
|
|
|
} from '@/lib/fristen/ics'
|
|
|
|
|
import {
|
|
|
|
|
betreuungsjahrBeginn,
|
|
|
|
|
betreuungsjahrEnde,
|
|
|
|
|
laufendesBetreuungsjahr,
|
|
|
|
|
pflichtfristenAbleiten,
|
|
|
|
|
type Pflichtfrist,
|
|
|
|
|
} from '@/lib/fristen/pflichtfristen'
|
|
|
|
|
import { naechsteFristAusErledigter, naechsterTermin, rruleGueltig } from '@/lib/fristen/wiederholung'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Diese Tests beschreiben, was fachlich gelten soll - nicht, wie es
|
|
|
|
|
* programmiert ist. Sie kommen ohne Datenbank aus: geprueft wird die reine
|
|
|
|
|
* Rechenlogik, an der im Betreuungsalltag Geld und Ansprueche haengen.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const STAMMDATEN = {
|
|
|
|
|
befreiterBetreuer: true,
|
|
|
|
|
ehrenamtlich: true,
|
|
|
|
|
pflegegrad: 3,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function schluessel(fristen: Pflichtfrist[]): string[] {
|
|
|
|
|
return fristen.map((f) => f.systemSchluessel)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function finde(fristen: Pflichtfrist[], gesucht: string): Pflichtfrist {
|
|
|
|
|
const treffer = fristen.find((f) => f.systemSchluessel === gesucht)
|
|
|
|
|
if (!treffer) throw new Error(`Frist "${gesucht}" wurde nicht abgeleitet.`)
|
|
|
|
|
return treffer
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('Betreuungsjahr', () => {
|
|
|
|
|
it('läuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr', () => {
|
|
|
|
|
const bestellung = tag(2025, 3, 15)
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 1))).toBe('2025-03-15')
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2026-03-14')
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 2))).toBe('2026-03-15')
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 2))).toBe('2027-03-14')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reicht über den Jahreswechsel, ohne auf den 31.12. zu springen', () => {
|
|
|
|
|
const bestellung = tag(2025, 11, 20)
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2026-11-19')
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 3))).toBe('2027-11-20')
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 3))).toBe('2028-11-19')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('endet bei Bestellung am 29. Februar im Folgejahr am 28. Februar', () => {
|
|
|
|
|
// § 188 Abs. 3 BGB: Fehlt der massgebende Tag im letzten Monat, endet
|
|
|
|
|
// die Frist mit dem letzten Tag dieses Monats.
|
|
|
|
|
const bestellung = tag(2024, 2, 29)
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2025-02-28')
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 2))).toBe('2025-03-01')
|
|
|
|
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 2))).toBe('2026-02-28')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('lässt kein Datum zwischen zwei Betreuungsjahren durchfallen', () => {
|
|
|
|
|
const bestellung = tag(2024, 2, 29)
|
|
|
|
|
for (let n = 1; n <= 8; n++) {
|
|
|
|
|
const ende = betreuungsjahrEnde(bestellung, n)
|
|
|
|
|
const naechsterBeginn = betreuungsjahrBeginn(bestellung, n + 1)
|
|
|
|
|
expect(alsIsoTag(naechsterBeginn)).toBe(alsIsoTag(plusTage(ende, 1)))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('weiß, in welchem Betreuungsjahr ein Tag liegt', () => {
|
|
|
|
|
const bestellung = tag(2025, 3, 15)
|
|
|
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2025, 3, 15))).toBe(1)
|
|
|
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2026, 3, 14))).toBe(1)
|
|
|
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2026, 3, 15))).toBe(2)
|
|
|
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2030, 1, 1))).toBe(5)
|
|
|
|
|
// Vor der Bestellung gibt es noch kein abgelaufenes Jahr.
|
|
|
|
|
expect(laufendesBetreuungsjahr(bestellung, tag(2024, 1, 1))).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('Ausschlussfrist der Aufwandspauschale', () => {
|
|
|
|
|
const bestellung = tag(2025, 3, 15)
|
|
|
|
|
|
|
|
|
|
it('verfällt sechs Monate nach Ablauf des Betreuungsjahres', () => {
|
|
|
|
|
// Betreuungsjahr 1: 15.03.2025 bis 14.03.2026. Sechs Monate nach dessen
|
|
|
|
|
// Ablauf ist der 14.09.2026 - § 1878 Abs. 2 BGB.
|
|
|
|
|
const fristen = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute: tag(2025, 6, 1),
|
|
|
|
|
})
|
|
|
|
|
const pauschale = finde(fristen, 'aufwandspauschale-2026')
|
|
|
|
|
expect(alsIsoTag(pauschale.datum)).toBe('2026-09-14')
|
|
|
|
|
expect(pauschale.ausschlussfrist).toBe(true)
|
|
|
|
|
expect(pauschale.rechtsgrundlage).toBe('§ 1878 BGB')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('warnt früher vor als eine gewöhnliche Frist', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute: tag(2025, 6, 1),
|
|
|
|
|
})
|
|
|
|
|
expect(finde(fristen, 'aufwandspauschale-2026').vorwarnungTage).toEqual([90, 60, 30, 14, 7, 1])
|
|
|
|
|
expect(finde(fristen, 'jahresbericht-2026').vorwarnungTage).toEqual([60, 30, 7, 1])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('bleibt für das eben abgelaufene Betreuungsjahr stehen, solange sie läuft', () => {
|
|
|
|
|
// Am 01.06.2026 ist das erste Betreuungsjahr vorbei, die Pauschale
|
|
|
|
|
// dafür aber noch bis 14.09.2026 zu holen. Genau dieses Geld darf die
|
|
|
|
|
// Anwendung nicht verschweigen.
|
|
|
|
|
const fristen = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute: tag(2026, 6, 1),
|
|
|
|
|
})
|
|
|
|
|
expect(schluessel(fristen)).toContain('aufwandspauschale-2026')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('verschwindet, sobald sie endgültig verfallen ist', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute: tag(2026, 9, 15),
|
|
|
|
|
})
|
|
|
|
|
expect(schluessel(fristen)).not.toContain('aufwandspauschale-2026')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('entsteht gar nicht, wenn die Betreuung berufsmäßig geführt wird', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
ehrenamtlich: false,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute: tag(2025, 6, 1),
|
|
|
|
|
})
|
|
|
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('aufwandspauschale'))).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('Ableitung der Pflichtfristen', () => {
|
|
|
|
|
const bestellung = tag(2025, 3, 15)
|
|
|
|
|
const heute = tag(2025, 6, 1)
|
|
|
|
|
|
|
|
|
|
it('bringt Vermögensverzeichnis und Anfangsbericht drei Monate nach der Bestellung', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
|
|
|
expect(alsIsoTag(finde(fristen, 'vermoegensverzeichnis').datum)).toBe('2025-06-15')
|
|
|
|
|
expect(alsIsoTag(finde(fristen, 'anfangsbericht').datum)).toBe('2025-06-15')
|
|
|
|
|
expect(finde(fristen, 'anfangsbericht').rechtsgrundlage).toBe('§ 1863 Abs. 1 BGB')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('liefert bei gleichen Stammdaten zweimal genau dasselbe (Idempotenz)', () => {
|
|
|
|
|
const eingabe = { ...STAMMDATEN, bestellungAm: bestellung, heute }
|
|
|
|
|
const ersteLauf = pflichtfristenAbleiten(eingabe)
|
|
|
|
|
const zweiterLauf = pflichtfristenAbleiten(eingabe)
|
|
|
|
|
|
|
|
|
|
expect(schluessel(zweiterLauf)).toEqual(schluessel(ersteLauf))
|
|
|
|
|
expect(zweiterLauf.map((f) => alsIsoTag(f.datum))).toEqual(
|
|
|
|
|
ersteLauf.map((f) => alsIsoTag(f.datum)),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('vergibt jeden Systemschlüssel nur einmal — sonst gäbe es Dubletten', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
|
|
|
expect(new Set(schluessel(fristen)).size).toBe(fristen.length)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('erzeugt standardmäßig drei Jahre im Voraus', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
|
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('jahresbericht-'))).toHaveLength(3)
|
|
|
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('landespflegegeld-'))).toHaveLength(3)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('richtet sich nach dem Parameter jahreVoraus', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute,
|
|
|
|
|
jahreVoraus: 1,
|
|
|
|
|
})
|
|
|
|
|
expect(schluessel(fristen).filter((s) => s.startsWith('jahresbericht-'))).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('leitet ohne Bestellungsdatum gar nichts ab', () => {
|
|
|
|
|
expect(pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: null, heute })).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('lässt Landespflegegeld unter Pflegegrad 2 weg', () => {
|
|
|
|
|
const mitPflegegrad = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
pflegegrad: 2,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute,
|
|
|
|
|
})
|
|
|
|
|
const ohne = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
pflegegrad: 1,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute,
|
|
|
|
|
})
|
|
|
|
|
expect(schluessel(mitPflegegrad).some((s) => s.startsWith('landespflegegeld-'))).toBe(true)
|
|
|
|
|
expect(schluessel(ohne).some((s) => s.startsWith('landespflegegeld-'))).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('nennt beim befreiten Betreuer die Vermögensübersicht, sonst die Rechnungslegung', () => {
|
|
|
|
|
const befreit = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
|
|
|
const voll = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
befreiterBetreuer: false,
|
|
|
|
|
bestellungAm: bestellung,
|
|
|
|
|
heute,
|
|
|
|
|
})
|
|
|
|
|
expect(finde(befreit, 'vermoegensuebersicht-2026').rechtsgrundlage).toBe('§ 1859 Abs. 1 BGB')
|
|
|
|
|
expect(finde(voll, 'vermoegensuebersicht-2026').rechtsgrundlage).toBe('§ 1865 BGB')
|
|
|
|
|
// Der Schluessel bleibt gleich, damit ein Wechsel der Einstufung keine
|
|
|
|
|
// zweite Frist neben der bestehenden erzeugt.
|
|
|
|
|
expect(finde(voll, 'vermoegensuebersicht-2026').titel).toContain('Rechnungslegung')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('weist auf Stichtage hin, die auf ein Wochenende fallen — ohne sie zu verschieben', () => {
|
|
|
|
|
// Der 14.09.2025 ist ein Sonntag; Bestellung am 14.06.2025 legt den
|
|
|
|
|
// Anfangsbericht genau dorthin.
|
|
|
|
|
const fristen = pflichtfristenAbleiten({
|
|
|
|
|
...STAMMDATEN,
|
|
|
|
|
bestellungAm: tag(2025, 6, 14),
|
|
|
|
|
heute: tag(2025, 6, 20),
|
|
|
|
|
})
|
|
|
|
|
const bericht = finde(fristen, 'anfangsbericht')
|
|
|
|
|
expect(alsIsoTag(bericht.datum)).toBe('2025-09-14')
|
|
|
|
|
expect(istWochenende(bericht.datum)).toBe(true)
|
|
|
|
|
expect(bericht.beschreibung).toContain('Sonntag')
|
|
|
|
|
expect(bericht.beschreibung).toContain('§ 193 BGB')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('ist chronologisch sortiert', () => {
|
|
|
|
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
|
|
|
|
const daten = fristen.map((f) => f.datum.getTime())
|
|
|
|
|
expect([...daten].sort((a, b) => a - b)).toEqual(daten)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('Wiederkehrende Fristen', () => {
|
|
|
|
|
it('findet den nächsten Jahrestermin', () => {
|
|
|
|
|
const naechster = naechsterTermin('FREQ=YEARLY;INTERVAL=1', tag(2026, 3, 14))
|
|
|
|
|
expect(alsIsoTag(naechster!)).toBe('2027-03-14')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('gibt nie den Tag zurück, der gerade erledigt wurde', () => {
|
|
|
|
|
const naechster = naechsterTermin('FREQ=MONTHLY;INTERVAL=1', tag(2026, 1, 31))
|
|
|
|
|
expect(alsIsoTag(naechster!)).not.toBe('2026-01-31')
|
|
|
|
|
// Der Februar hat keinen 31. - die naechste Wiederholung ist der 31.03.
|
|
|
|
|
expect(alsIsoTag(naechster!)).toBe('2026-03-31')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rechnet vierteljährlich in Dreimonatsschritten', () => {
|
|
|
|
|
expect(alsIsoTag(naechsterTermin('FREQ=MONTHLY;INTERVAL=3', tag(2026, 2, 10))!)).toBe(
|
|
|
|
|
'2026-05-10',
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('endet, wenn die Regel erschöpft ist', () => {
|
|
|
|
|
expect(naechsterTermin('FREQ=YEARLY;COUNT=1', tag(2026, 3, 14))).toBeNull()
|
|
|
|
|
expect(naechsterTermin('FREQ=MONTHLY;UNTIL=20260201T000000Z', tag(2026, 1, 15))).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('erkennt unbrauchbare Regeln, statt sie stillschweigend zu schlucken', () => {
|
|
|
|
|
expect(rruleGueltig('FREQ=YEARLY;INTERVAL=1')).toBe(true)
|
|
|
|
|
expect(rruleGueltig('jeden Montag')).toBe(false)
|
|
|
|
|
expect(naechsterTermin('Unsinn', tag(2026, 1, 1))).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('verkettet die Nachfolgefrist mit der erledigten', () => {
|
|
|
|
|
const nachfolger = naechsteFristAusErledigter({
|
|
|
|
|
id: 'frist-1',
|
|
|
|
|
betreuteId: 'betreute-1',
|
|
|
|
|
vorgangId: 'vorgang-9',
|
|
|
|
|
titel: 'Kontoauszüge holen',
|
|
|
|
|
beschreibung: 'Bei der Sparkasse abholen.',
|
|
|
|
|
datum: tag(2026, 1, 15),
|
|
|
|
|
typ: 'WIEDERVORLAGE',
|
|
|
|
|
rechtsgrundlage: null,
|
|
|
|
|
vorwarnungTage: [7, 1],
|
|
|
|
|
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=1',
|
|
|
|
|
ausschlussfrist: false,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(nachfolger).not.toBeNull()
|
|
|
|
|
expect(nachfolger!.vorgaengerFristId).toBe('frist-1')
|
|
|
|
|
expect(alsIsoTag(nachfolger!.datum)).toBe('2026-02-15')
|
|
|
|
|
expect(nachfolger!.titel).toBe('Kontoauszüge holen')
|
|
|
|
|
expect(nachfolger!.vorgangId).toBe('vorgang-9')
|
|
|
|
|
expect(nachfolger!.wiederholungRrule).toBe('FREQ=MONTHLY;INTERVAL=1')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rechnet ab dem Solltermin, nicht ab dem Tag der Erledigung', () => {
|
|
|
|
|
// Wer drei Tage zu spaet abhakt, soll nicht dauerhaft nachrutschen.
|
|
|
|
|
const nachfolger = naechsteFristAusErledigter({
|
|
|
|
|
id: 'frist-2',
|
|
|
|
|
betreuteId: 'betreute-1',
|
|
|
|
|
titel: 'Heimkosten prüfen',
|
|
|
|
|
datum: tag(2026, 1, 1),
|
|
|
|
|
typ: 'WIEDERVORLAGE',
|
|
|
|
|
vorwarnungTage: [7],
|
|
|
|
|
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=1',
|
|
|
|
|
ausschlussfrist: false,
|
|
|
|
|
})
|
|
|
|
|
expect(alsIsoTag(nachfolger!.datum)).toBe('2026-02-01')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('legt ohne Wiederholungsregel keine Nachfolgefrist an', () => {
|
|
|
|
|
const nachfolger = naechsteFristAusErledigter({
|
|
|
|
|
id: 'frist-3',
|
|
|
|
|
betreuteId: 'betreute-1',
|
|
|
|
|
titel: 'Einmalige Sache',
|
|
|
|
|
datum: tag(2026, 1, 1),
|
|
|
|
|
typ: 'SELBST_GESETZT',
|
|
|
|
|
vorwarnungTage: [],
|
|
|
|
|
wiederholungRrule: null,
|
|
|
|
|
ausschlussfrist: false,
|
|
|
|
|
})
|
|
|
|
|
expect(nachfolger).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('Fristenampel', () => {
|
|
|
|
|
const heute = tag(2026, 5, 20)
|
|
|
|
|
|
|
|
|
|
it('färbt Überfälliges rot und sagt seit wann', () => {
|
|
|
|
|
const gestern = fristAmpel(tag(2026, 5, 19), 'OFFEN', heute)
|
|
|
|
|
expect(gestern.stufe).toBe('ueberfaellig')
|
|
|
|
|
expect(gestern.variante).toBe('rot')
|
|
|
|
|
expect(gestern.text).toBe('Überfällig seit gestern')
|
|
|
|
|
|
|
|
|
|
const vorheriger = fristAmpel(tag(2026, 5, 10), 'OFFEN', heute)
|
|
|
|
|
expect(vorheriger.text).toBe('Überfällig seit 10 Tagen')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('zählt heute und morgen zur laufenden Woche', () => {
|
|
|
|
|
expect(fristAmpel(heute, 'OFFEN', heute)).toMatchObject({
|
|
|
|
|
stufe: 'diese_woche',
|
|
|
|
|
variante: 'orange',
|
|
|
|
|
text: 'Heute fällig',
|
|
|
|
|
})
|
|
|
|
|
expect(fristAmpel(tag(2026, 5, 21), 'OFFEN', heute)).toMatchObject({
|
|
|
|
|
stufe: 'diese_woche',
|
|
|
|
|
text: 'Morgen fällig',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('trennt „diese Woche“ von „dieser Monat“ genau nach sieben Tagen', () => {
|
|
|
|
|
const siebter = fristAmpel(tag(2026, 5, 27), 'OFFEN', heute)
|
|
|
|
|
const achter = fristAmpel(tag(2026, 5, 28), 'OFFEN', heute)
|
|
|
|
|
|
|
|
|
|
expect(siebter.tage).toBe(7)
|
|
|
|
|
expect(siebter.stufe).toBe('diese_woche')
|
|
|
|
|
expect(siebter.variante).toBe('orange')
|
|
|
|
|
|
|
|
|
|
expect(achter.tage).toBe(8)
|
|
|
|
|
expect(achter.stufe).toBe('dieser_monat')
|
|
|
|
|
expect(achter.variante).toBe('gelb')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('schaltet nach 31 Tagen auf „später“', () => {
|
|
|
|
|
expect(fristAmpel(tag(2026, 6, 20), 'OFFEN', heute).stufe).toBe('dieser_monat')
|
|
|
|
|
expect(fristAmpel(tag(2026, 6, 21), 'OFFEN', heute).stufe).toBe('spaeter')
|
|
|
|
|
expect(fristAmpel(tag(2026, 6, 21), 'OFFEN', heute).variante).toBe('gruen')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('nimmt Abgeschlossenes aus der Ampel heraus', () => {
|
|
|
|
|
for (const status of ['ERLEDIGT', 'VERSCHOBEN', 'ENTFALLEN'] as const) {
|
|
|
|
|
const ampel = fristAmpel(tag(2026, 1, 1), status, heute)
|
|
|
|
|
expect(ampel.stufe).toBe('erledigt')
|
|
|
|
|
expect(ampel.variante).toBe('secondary')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('liefert zu jeder Stufe einen Text — Farbe allein ist keine Information', () => {
|
|
|
|
|
const tage = [-30, -1, 0, 1, 5, 7, 8, 20, 31, 32, 400]
|
|
|
|
|
for (const versatz of tage) {
|
|
|
|
|
const ampel = fristAmpel(plusTage(heute, versatz), 'OFFEN', heute)
|
|
|
|
|
expect(ampel.text.length).toBeGreaterThan(3)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('Tagesdatum und Feiertage', () => {
|
|
|
|
|
it('rechnet Monatsschritte kalendarisch, nicht in 30 Tagen', () => {
|
|
|
|
|
expect(alsIsoTag(plusMonate(tag(2026, 1, 31), 1))).toBe('2026-02-28')
|
|
|
|
|
expect(alsIsoTag(plusMonate(tag(2024, 1, 31), 1))).toBe('2024-02-29')
|
|
|
|
|
expect(alsIsoTag(plusMonate(tag(2026, 3, 15), 6))).toBe('2026-09-15')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('liest und schreibt ISO-Tagesdaten verlustfrei', () => {
|
|
|
|
|
expect(alsIsoTag(ausIsoTag('2026-09-14')!)).toBe('2026-09-14')
|
|
|
|
|
expect(ausIsoTag('2026-02-31')).toBeNull()
|
|
|
|
|
expect(ausIsoTag('14.09.2026')).toBeNull()
|
|
|
|
|
expect(ausIsoTag('')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('kennt die beweglichen bayerischen Feiertage', () => {
|
|
|
|
|
// Ostersonntag 2026 ist der 05.04.
|
|
|
|
|
expect(feiertagName(tag(2026, 4, 3))).toBe('Karfreitag')
|
|
|
|
|
expect(feiertagName(tag(2026, 4, 6))).toBe('Ostermontag')
|
|
|
|
|
expect(feiertagName(tag(2026, 5, 14))).toBe('Christi Himmelfahrt')
|
|
|
|
|
expect(feiertagName(tag(2026, 6, 4))).toBe('Fronleichnam')
|
|
|
|
|
expect(feiertagName(tag(2026, 1, 6))).toBe('Heilige Drei Könige')
|
|
|
|
|
expect(feiertagName(tag(2026, 7, 7))).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('warnt vor arbeitsfreien Stichtagen, ohne das Datum zu ändern', () => {
|
|
|
|
|
const hinweis = nichtWerktagHinweis(tag(2026, 4, 3))
|
|
|
|
|
expect(hinweis).toContain('Karfreitag')
|
|
|
|
|
expect(hinweis).toContain('§ 193 BGB')
|
|
|
|
|
expect(nichtWerktagHinweis(tag(2026, 7, 7))).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('ICS-Kalender', () => {
|
|
|
|
|
const frist = {
|
|
|
|
|
id: 'abc123',
|
|
|
|
|
titel: 'Jahresbericht 2026 an das Betreuungsgericht',
|
|
|
|
|
beschreibung: 'Bericht über das Betreuungsjahr; mit Vermögensübersicht, Belegen.',
|
|
|
|
|
datum: tag(2026, 3, 14),
|
|
|
|
|
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
|
|
|
|
|
typ: 'GESETZLICH',
|
|
|
|
|
ausschlussfrist: false,
|
|
|
|
|
vorwarnungTage: [60, 30, 7, 1],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const kalender = () =>
|
|
|
|
|
icsErzeugen({
|
|
|
|
|
kalendername: 'Betreuung EM — Fristen',
|
|
|
|
|
fristen: [frist],
|
|
|
|
|
jetzt: new Date(Date.UTC(2026, 1, 1, 10, 15, 0)),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('faltet keine Zeile über 75 Oktette hinaus', () => {
|
|
|
|
|
const lang =
|
|
|
|
|
'SUMMARY:Vermögensübersicht 2027 einreichen — Kontostände, Rückkaufswerte, ' +
|
|
|
|
|
'Verbindlichkeiten, jeweils mit Nachweis für das Betreuungsgericht'
|
|
|
|
|
const gefaltet = zeileFalten(lang)
|
|
|
|
|
|
|
|
|
|
for (const zeile of gefaltet.split('\r\n')) {
|
|
|
|
|
expect(Buffer.byteLength(zeile, 'utf8')).toBeLessThanOrEqual(75)
|
|
|
|
|
}
|
|
|
|
|
// Jede Folgezeile beginnt mit genau einem Leerzeichen.
|
|
|
|
|
for (const zeile of gefaltet.split('\r\n').slice(1)) {
|
|
|
|
|
expect(zeile.startsWith(' ')).toBe(true)
|
|
|
|
|
}
|
|
|
|
|
// Entfaltet muss wieder das Original herauskommen.
|
|
|
|
|
expect(gefaltet.split('\r\n ').join('')).toBe(lang)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('zerschneidet keine Umlaute beim Falten', () => {
|
|
|
|
|
const zeile = `DESCRIPTION:${'ä'.repeat(120)}`
|
|
|
|
|
const gefaltet = zeileFalten(zeile)
|
|
|
|
|
expect(gefaltet).not.toContain('�')
|
|
|
|
|
expect(gefaltet.split('\r\n ').join('')).toBe(zeile)
|
|
|
|
|
for (const teil of gefaltet.split('\r\n')) {
|
|
|
|
|
expect(Buffer.byteLength(teil, 'utf8')).toBeLessThanOrEqual(75)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('lässt kurze Zeilen unangetastet', () => {
|
|
|
|
|
expect(zeileFalten('VERSION:2.0')).toBe('VERSION:2.0')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('maskiert Sonderzeichen nach RFC 5545', () => {
|
|
|
|
|
expect(textEscapen('a;b,c\\d')).toBe('a\\;b\\,c\\\\d')
|
|
|
|
|
expect(textEscapen('Zeile 1\nZeile 2')).toBe('Zeile 1\\nZeile 2')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('baut ein gültiges Gerüst mit CRLF-Zeilenenden', () => {
|
|
|
|
|
const ics = kalender()
|
|
|
|
|
expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true)
|
|
|
|
|
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true)
|
|
|
|
|
expect(ics).toContain('VERSION:2.0')
|
|
|
|
|
// Kein nacktes Zeilenende ohne Wagenruecklauf.
|
|
|
|
|
expect(/[^\r]\n/.test(ics)).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('schreibt ganztägige Termine mit Ende am Folgetag', () => {
|
|
|
|
|
const ics = kalender()
|
|
|
|
|
expect(ics).toContain('DTSTART;VALUE=DATE:20260314')
|
|
|
|
|
expect(ics).toContain('DTEND;VALUE=DATE:20260315')
|
|
|
|
|
expect(alsIcsDatum(tag(2026, 3, 14))).toBe('20260314')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('macht aus jeder Vorwarnstufe einen Wecker', () => {
|
|
|
|
|
const ics = kalender()
|
|
|
|
|
for (const tage of [60, 30, 7, 1]) {
|
|
|
|
|
expect(ics).toContain(`TRIGGER;RELATED=START:-P${tage}D`)
|
|
|
|
|
}
|
|
|
|
|
expect(ics).not.toContain('-P0D')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('markiert Ausschlussfristen schon im Titel', () => {
|
|
|
|
|
const ics = icsErzeugen({
|
|
|
|
|
kalendername: 'Test',
|
|
|
|
|
fristen: [{ ...frist, ausschlussfrist: true }],
|
|
|
|
|
jetzt: new Date(Date.UTC(2026, 1, 1)),
|
|
|
|
|
})
|
|
|
|
|
expect(ics).toContain('Ausschlussfrist!')
|
|
|
|
|
expect(ics).toContain('PRIORITY:1')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('Signatur des Kalender-Abonnements', () => {
|
|
|
|
|
it('lässt sich erzeugen und wieder auflösen', () => {
|
|
|
|
|
const signatur = signaturErzeugen('betreute-1', 'user-1')
|
|
|
|
|
expect(signaturPruefen(signatur)).toEqual({ betreuteId: 'betreute-1', userId: 'user-1' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('enthält die Kennungen nicht im Klartext in der Adresse', () => {
|
|
|
|
|
expect(signaturErzeugen('betreute-1', 'user-1')).not.toContain('betreute-1')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('weist eine veränderte Signatur ab', () => {
|
|
|
|
|
const echt = signaturErzeugen('betreute-1', 'user-1')
|
|
|
|
|
const gefaelscht = Buffer.from('betreute-2.user-1.irgendwas', 'utf8').toString('base64url')
|
|
|
|
|
|
|
|
|
|
expect(signaturPruefen(gefaelscht)).toBeNull()
|
|
|
|
|
expect(signaturPruefen(`${echt}xyz`)).toBeNull()
|
|
|
|
|
expect(signaturPruefen('kein-base64url-inhalt')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('gilt nur für genau dieses Paar aus Person und Benutzer', () => {
|
|
|
|
|
const fuerA = signaturErzeugen('betreute-1', 'user-1')
|
|
|
|
|
const fuerB = signaturErzeugen('betreute-1', 'user-2')
|
|
|
|
|
expect(fuerA).not.toBe(fuerB)
|
|
|
|
|
expect(signaturPruefen(fuerB)!.userId).toBe('user-2')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('verträgt die Endung .ics an der Adresse', () => {
|
|
|
|
|
const signatur = signaturErzeugen('betreute-1', 'user-1')
|
|
|
|
|
expect(signaturPruefen(`${signatur}.ics`)).toEqual({
|
|
|
|
|
betreuteId: 'betreute-1',
|
|
|
|
|
userId: 'user-1',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|