Initial project setup for a privacy-focused, self-hosted scheduling app: - Go backend with JWT auth, SQLite storage, and chi router - Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links - Public booking pages with configurable slots and booking requests - AES-256-GCM encryption for stored provider tokens - Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router - Docs, .gitignore, and .env.example for local development
145 lines
3.2 KiB
Go
145 lines
3.2 KiB
Go
package calendar
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
const sampleICS = `BEGIN:VCALENDAR
|
||
VERSION:2.0
|
||
PRODID:-//Test//EN
|
||
BEGIN:VTIMEZONE
|
||
TZID:Europe/Berlin
|
||
BEGIN:STANDARD
|
||
DTSTART:19701025T030000
|
||
TZOFFSETFROM:+0200
|
||
TZOFFSETTO:+0100
|
||
END:STANDARD
|
||
END:VTIMEZONE
|
||
BEGIN:VEVENT
|
||
UID:1
|
||
DTSTART;TZID=Europe/Berlin:20260825T090000
|
||
DTEND;TZID=Europe/Berlin:20260825T100000
|
||
SUMMARY:Zahnarzt
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:2
|
||
DTSTART:20260826T120000Z
|
||
DURATION:PT45M
|
||
SUMMARY:Call
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:3
|
||
DTSTART;VALUE=DATE:20260827
|
||
SUMMARY:Ganztägig
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:4
|
||
DTSTART:20260828T080000Z
|
||
DTEND:20260828T090000Z
|
||
STATUS:CANCELLED
|
||
SUMMARY:Abgesagt
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:5
|
||
DTSTART:20260828T100000Z
|
||
DTEND:20260828T110000Z
|
||
TRANSP:TRANSPARENT
|
||
SUMMARY:Verfügbar
|
||
END:VEVENT
|
||
BEGIN:VEVENT
|
||
UID:6
|
||
DTSTART:20260829T080000Z
|
||
DTEND:202609
|
||
01T080000Z
|
||
SUMMARY:Gefaltet
|
||
END:VEVENT
|
||
END:VCALENDAR
|
||
`
|
||
|
||
func TestParseICS(t *testing.T) {
|
||
berlin, err := time.LoadLocation("Europe/Berlin")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
events := parseICS(sampleICS, berlin)
|
||
if len(events) != 6 {
|
||
t.Fatalf("6 Events erwartet, got %d", len(events))
|
||
}
|
||
|
||
// TZID-Event
|
||
if !events[0].start.Equal(time.Date(2026, 8, 25, 9, 0, 0, 0, berlin)) {
|
||
t.Errorf("TZID-Start falsch: %v", events[0].start)
|
||
}
|
||
if !events[0].end.Equal(time.Date(2026, 8, 25, 10, 0, 0, 0, berlin)) {
|
||
t.Errorf("TZID-Ende falsch: %v", events[0].end)
|
||
}
|
||
|
||
// UTC + DURATION
|
||
if !events[1].end.Equal(events[1].start.Add(45*time.Minute)) {
|
||
t.Errorf("DURATION nicht angewandt: %v", events[1].end)
|
||
}
|
||
|
||
// Ganztägig
|
||
if !events[2].allDay {
|
||
t.Error("VALUE=DATE nicht erkannt")
|
||
}
|
||
|
||
// Cancelled / Transparent markiert
|
||
if !events[3].cancelled {
|
||
t.Error("CANCELLED nicht erkannt")
|
||
}
|
||
if !events[4].transparent {
|
||
t.Error("TRANSPARENT nicht erkannt")
|
||
}
|
||
|
||
// Gefaltete Zeile (DTEND über zwei Zeilen)
|
||
if !events[5].end.Equal(time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)) {
|
||
t.Errorf("Folding nicht aufgelöst, Ende: %v", events[5].end)
|
||
}
|
||
}
|
||
|
||
func TestParseISODuration(t *testing.T) {
|
||
cases := map[string]time.Duration{
|
||
"PT15M": 15 * time.Minute,
|
||
"PT1H30M": 90 * time.Minute,
|
||
"P1D": 24 * time.Hour,
|
||
"P2W": 14 * 24 * time.Hour,
|
||
"PT45S": 45 * time.Second,
|
||
"": 0,
|
||
"X": 0,
|
||
"P-1D": 0,
|
||
}
|
||
for in, want := range cases {
|
||
got, ok := parseISODuration(in)
|
||
if in == "" || in == "X" || in == "P-1D" {
|
||
if ok {
|
||
t.Errorf("%q sollte ungültig sein", in)
|
||
}
|
||
continue
|
||
}
|
||
if !ok || got != want {
|
||
t.Errorf("parseISODuration(%q) = %v (ok=%v), want %v", in, got, ok, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestClampIntervals(t *testing.T) {
|
||
from := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC)
|
||
to := from.Add(48 * time.Hour)
|
||
in := []Interval{
|
||
{Start: from.Add(-2 * time.Hour), End: from.Add(time.Hour)},
|
||
{Start: from.Add(3 * time.Hour), End: from.Add(2 * time.Hour)}, // ungültig
|
||
{Start: from.Add(10 * time.Hour), End: to.Add(time.Hour)},
|
||
}
|
||
out := clampIntervals(in, from, to)
|
||
if len(out) != 2 {
|
||
t.Fatalf("2 Intervalle erwartet, got %d", len(out))
|
||
}
|
||
if !out[0].Start.Equal(from) || !out[0].End.Equal(from.Add(time.Hour)) {
|
||
t.Errorf("Intervall 1 nicht geklemmt: %v–%v", out[0].Start, out[0].End)
|
||
}
|
||
if !out[1].End.Equal(to) {
|
||
t.Errorf("Intervall 2 nicht geklemmt: %v", out[1].End)
|
||
}
|
||
}
|