feat: add WannPassts booking app with Go backend and Vue frontend
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
This commit is contained in:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
145
backend/internal/calendar/ics_test.go
Normal file
145
backend/internal/calendar/ics_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue