3af290152c
Radicale becomes a write-only render target, not a store. sqlite stays canonical: every poll mavcaldav reads the pending reminders out of core and publishes each one as a single-event iCal resource, withdrawing the ones that have fired or been cancelled. Losing the collection costs nothing — the next tick rebuilds it, and nothing is ever read back from it. It structurally cannot write to a calendar maven only reads. The render URL and credential are their own flags, and -render-url is refused at startup when it names the collection -url reads; the only paths it addresses carry the maven-reminder- prefix, so even aimed at the wrong collection it can only touch resources it created. Rendering is off unless -render-url is given. The calendar data model now lives in one place, internal/calendar: the Event, the iCal parse it comes from and the render it goes to, the fact key/value encoding, and the source constants that say which calendars may be written to. It was a parse inlined in cmd/mavcaldav and a Sprintf in two files; #126 and #128 both need to agree with it. Fixes a latent day-boundary bug moved out of that inline parse: it took the day number off a local clock reading but built the window boundaries in UTC, so on a box east of Greenwich part of the evening fell outside "today" and the poller saw an empty calendar after 20:00 UTC. Today is now the owner's day in the owner's location, which is what the busy gate and the day plan mean.
203 lines
6.0 KiB
Go
203 lines
6.0 KiB
Go
package calendar
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestParseICalDayKeepsOnlyToday(t *testing.T) {
|
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
|
|
|
body := []byte(`BEGIN:VCALENDAR
|
|
BEGIN:VEVENT
|
|
UID:a@example
|
|
DTSTART:20260703T090000Z
|
|
DTEND:20260703T100000Z
|
|
SUMMARY:Morning standup
|
|
END:VEVENT
|
|
BEGIN:VEVENT
|
|
DTSTART:20260703T140000Z
|
|
DTEND:20260703T150000Z
|
|
SUMMARY:Team sync
|
|
END:VEVENT
|
|
BEGIN:VEVENT
|
|
DTSTART:20260702T140000Z
|
|
DTEND:20260702T150000Z
|
|
SUMMARY:Yesterday retro
|
|
END:VEVENT
|
|
BEGIN:VEVENT
|
|
DTSTART:20260704T090000Z
|
|
DTEND:20260704T100000Z
|
|
SUMMARY:Tomorrow standup
|
|
END:VEVENT
|
|
BEGIN:VEVENT
|
|
DTSTART;VALUE=DATE:20260704
|
|
DTEND;VALUE=DATE:20260705
|
|
SUMMARY:All-day event
|
|
END:VEVENT
|
|
END:VCALENDAR`)
|
|
|
|
events := ParseICalDay(body, now)
|
|
if len(events) != 2 {
|
|
t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events))
|
|
}
|
|
if events[0].Summary != "Morning standup" || events[0].UID != "a@example" {
|
|
t.Errorf("events[0] = %+v", events[0])
|
|
}
|
|
if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("events[0].Start = %v", events[0].Start)
|
|
}
|
|
if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("events[0].End = %v", events[0].End)
|
|
}
|
|
if events[1].Summary != "Team sync" {
|
|
t.Errorf("events[1].Summary = %q", events[1].Summary)
|
|
}
|
|
}
|
|
|
|
// Regression: "today" is the owner's day, in the owner's location. Taking the
|
|
// day number off a local clock but building the boundaries in UTC made the
|
|
// evening fall outside the window on any box east of Greenwich.
|
|
func TestParseICalDayUsesOwnersDay(t *testing.T) {
|
|
plus4 := time.FixedZone("+04", 4*60*60)
|
|
// 01:00 on Aug 1 local is 21:00 on Jul 31 UTC.
|
|
now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4)
|
|
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
|
|
"DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" +
|
|
"END:VEVENT\nEND:VCALENDAR")
|
|
|
|
events := ParseICalDay(body, now)
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want the in-progress one", len(events))
|
|
}
|
|
if !Busy(events, now.UTC()) {
|
|
t.Error("an event in progress right now must read as busy")
|
|
}
|
|
}
|
|
|
|
func TestParseVEVENT(t *testing.T) {
|
|
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
|
e, ok := parseVEVENT(block)
|
|
if !ok {
|
|
t.Fatal("expected a parsed event")
|
|
}
|
|
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("start = %v", e.Start)
|
|
}
|
|
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("end = %v", e.End)
|
|
}
|
|
if e.Summary != "Stand up meeting" {
|
|
t.Errorf("summary = %q", e.Summary)
|
|
}
|
|
|
|
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
|
if _, ok := parseVEVENT(allDay); ok {
|
|
t.Error("all-day event should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestParseDT(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
want time.Time
|
|
wantOK bool
|
|
}{
|
|
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
|
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
|
|
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
|
|
{"garbage", "DTSTART:garbage", time.Time{}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := parseDT(tt.line)
|
|
if ok != tt.wantOK {
|
|
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
|
}
|
|
if !got.Equal(tt.want) {
|
|
t.Errorf("got %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSafeKey(t *testing.T) {
|
|
tests := []struct{ in, want string }{
|
|
{"Stand up meeting", "Stand-up-meeting"},
|
|
{"Hello_World", "Hello-World"},
|
|
{"special@#$chars!!", "specialchars"},
|
|
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := safeKey(tt.in); got != tt.want {
|
|
t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFactKeyAndValue(t *testing.T) {
|
|
e := Event{
|
|
Summary: "Team sync",
|
|
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
|
|
}
|
|
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
|
|
t.Errorf("FactKey = %q, want %q", got, want)
|
|
}
|
|
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
|
|
t.Errorf("FactValue = %q, want %q", got, want)
|
|
}
|
|
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
|
|
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
|
|
}
|
|
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
|
|
t.Error("FactKey must start with the day prefix the store range-scans on")
|
|
}
|
|
}
|
|
|
|
func TestBusyAndOverlapping(t *testing.T) {
|
|
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)},
|
|
{Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)},
|
|
}
|
|
if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) {
|
|
t.Error("should be busy inside the early event")
|
|
}
|
|
if Busy(events, base.Add(12*time.Hour)) {
|
|
t.Error("should be free at noon")
|
|
}
|
|
// Half-open: the end instant is free.
|
|
if Busy(events, base.Add(10*time.Hour)) {
|
|
t.Error("the end instant should not count as busy")
|
|
}
|
|
got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour))
|
|
if len(got) != 1 || got[0].Summary != "early" {
|
|
t.Fatalf("Overlapping = %+v", got)
|
|
}
|
|
all := Overlapping(events, base, base.AddDate(0, 0, 1))
|
|
if len(all) != 2 || all[0].Summary != "early" {
|
|
t.Fatalf("Overlapping must sort by start: %+v", all)
|
|
}
|
|
}
|
|
|
|
func TestSourceTrust(t *testing.T) {
|
|
if ReadOnlySource(SourcePersonal) {
|
|
t.Error("the personal calendar is the one maven may render to")
|
|
}
|
|
if !ReadOnlySource(SourceWork) {
|
|
t.Error("the work calendar must be read-only")
|
|
}
|
|
if !ReadOnlySource(SourceAmbient) {
|
|
t.Error("an ambient notification is not a writable calendar")
|
|
}
|
|
if AmbientConfidence >= 1.0 {
|
|
t.Error("ambient events must be less trusted than a calendar read")
|
|
}
|
|
if len(Sources()) != 3 {
|
|
t.Errorf("Sources() = %v", Sources())
|
|
}
|
|
}
|