Render maven's own reminders to a calendar she owns (#127)
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.
This commit is contained in:
+5
-164
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
||||
return int64(len(f.writeLog)), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseICal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
body := []byte(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
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 := parseICal(body, now)
|
||||
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events))
|
||||
}
|
||||
|
||||
// Morning standup — overlaps today.
|
||||
if events[0].summary != "Morning standup" {
|
||||
t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup")
|
||||
}
|
||||
wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)
|
||||
if !events[0].start.Equal(wantStart0) {
|
||||
t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0)
|
||||
}
|
||||
wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
|
||||
if !events[0].end.Equal(wantEnd0) {
|
||||
t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0)
|
||||
}
|
||||
|
||||
// Team sync — overlaps today.
|
||||
if events[1].summary != "Team sync" {
|
||||
t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync")
|
||||
}
|
||||
wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !events[1].start.Equal(wantStart1) {
|
||||
t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1)
|
||||
}
|
||||
wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
|
||||
if !events[1].end.Equal(wantEnd1) {
|
||||
t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVEVENT(t *testing.T) {
|
||||
// Normal event with TZID in DTSTART and UTC DTEND.
|
||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||
e := parseVEVENT(block)
|
||||
if e == nil {
|
||||
t.Fatal("expected non-nil icalEvent")
|
||||
}
|
||||
wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
if !e.start.Equal(wantStart) {
|
||||
t.Errorf("start = %v, want %v", e.start, wantStart)
|
||||
}
|
||||
wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !e.end.Equal(wantEnd) {
|
||||
t.Errorf("end = %v, want %v", e.end, wantEnd)
|
||||
}
|
||||
if e.summary != "Stand up meeting" {
|
||||
t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting")
|
||||
}
|
||||
|
||||
// All-day event (VALUE=DATE) → nil.
|
||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||
if e2 := parseVEVENT(allDay); e2 != nil {
|
||||
t.Error("expected nil for all-day event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
want time.Time
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "UTC",
|
||||
line: "DTEND:20260703T100000Z",
|
||||
want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "local time",
|
||||
line: "DTSTART;TZID=Europe/Moscow:20260703T130000",
|
||||
want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "all-day",
|
||||
line: "DTSTART;VALUE=DATE:20260703",
|
||||
want: time.Time{},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
line: "DTSTART:garbage",
|
||||
want: time.Time{},
|
||||
wantOK: 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 {
|
||||
input string
|
||||
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 {
|
||||
got := safeKey(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core logic tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("no previous fact writes", func(t *testing.T) {
|
||||
fc := &fakeCore{}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -280,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
writeErr: fmt.Errorf("disk full"),
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user