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.
213 lines
5.9 KiB
Go
213 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
type fakeCore struct {
|
|
ipc.UnimplementedCoreAPI
|
|
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
|
writeLog []ipc.WriteFactReq
|
|
writeErr error
|
|
readErr error
|
|
}
|
|
|
|
func (f *fakeCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
|
if f.readErr != nil {
|
|
return ipc.Fact{}, f.readErr
|
|
}
|
|
if f.facts == nil {
|
|
return ipc.Fact{}, ipc.ErrNoFact
|
|
}
|
|
fk := key + "|" + source
|
|
fact, ok := f.facts[fk]
|
|
if !ok {
|
|
return ipc.Fact{}, ipc.ErrNoFact
|
|
}
|
|
return fact, nil
|
|
}
|
|
|
|
func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
|
if f.writeErr != nil {
|
|
return 0, f.writeErr
|
|
}
|
|
if f.facts == nil {
|
|
f.facts = make(map[string]ipc.Fact)
|
|
}
|
|
fk := req.Key + "|" + req.Source
|
|
f.facts[fk] = ipc.Fact{
|
|
Key: req.Key,
|
|
Value: req.Value,
|
|
Source: req.Source,
|
|
}
|
|
f.writeLog = append(f.writeLog, req)
|
|
return int64(len(f.writeLog)), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core logic tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestWriteIfChanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
|
|
|
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, 1.0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(fc.writeLog) != 1 {
|
|
t.Fatalf("expected 1 write, got %d", len(fc.writeLog))
|
|
}
|
|
if fc.writeLog[0].Value != "hello" {
|
|
t.Errorf("value = %q, want %q", fc.writeLog[0].Value, "hello")
|
|
}
|
|
if fc.writeLog[0].Key != "test_key" {
|
|
t.Errorf("key = %q, want %q", fc.writeLog[0].Key, "test_key")
|
|
}
|
|
if fc.writeLog[0].Source != "poll:caldav" {
|
|
t.Errorf("source = %q, want %q", fc.writeLog[0].Source, "poll:caldav")
|
|
}
|
|
if fc.writeLog[0].Kind != "env" {
|
|
t.Errorf("kind = %q, want %q", fc.writeLog[0].Kind, "env")
|
|
}
|
|
})
|
|
|
|
t.Run("same value skips write", func(t *testing.T) {
|
|
fc := &fakeCore{
|
|
facts: map[string]ipc.Fact{
|
|
"test_key|poll:caldav": {Value: "hello"},
|
|
},
|
|
}
|
|
p := &poller{core: fc}
|
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(fc.writeLog) != 0 {
|
|
t.Errorf("expected 0 writes, got %d", len(fc.writeLog))
|
|
}
|
|
})
|
|
|
|
t.Run("different value writes", func(t *testing.T) {
|
|
fc := &fakeCore{
|
|
facts: map[string]ipc.Fact{
|
|
"test_key|poll:caldav": {Value: "old"},
|
|
},
|
|
}
|
|
p := &poller{core: fc}
|
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(fc.writeLog) != 1 {
|
|
t.Fatalf("expected 1 write, got %d", len(fc.writeLog))
|
|
}
|
|
if fc.writeLog[0].Value != "new" {
|
|
t.Errorf("value = %q, want %q", fc.writeLog[0].Value, "new")
|
|
}
|
|
})
|
|
|
|
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, 1.0)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("write error returns error", func(t *testing.T) {
|
|
fc := &fakeCore{
|
|
facts: map[string]ipc.Fact{},
|
|
writeErr: fmt.Errorf("disk full"),
|
|
}
|
|
p := &poller{core: fc}
|
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Poll cycle test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPollOnce(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
start := now.Add(-2 * time.Hour).Truncate(time.Second)
|
|
end := now.Add(2 * time.Hour).Truncate(time.Second)
|
|
|
|
ical := fmt.Sprintf("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:%sT%sZ\nDTEND:%sT%sZ\nSUMMARY:Current meeting\nEND:VEVENT\nEND:VCALENDAR",
|
|
start.Format("20060102"), start.Format("150405"),
|
|
end.Format("20060102"), end.Format("150405"))
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(ical))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
fc := &fakeCore{}
|
|
p := &poller{
|
|
core: fc,
|
|
http: srv.Client(),
|
|
url: srv.URL,
|
|
}
|
|
|
|
p.pollOnce(context.Background())
|
|
|
|
if len(fc.writeLog) != 2 {
|
|
t.Fatalf("expected 2 writes (calendar_busy + calendar_event), got %d", len(fc.writeLog))
|
|
}
|
|
|
|
// First write: calendar_busy = "true"
|
|
busyReq := fc.writeLog[0]
|
|
if busyReq.Key != "calendar_busy" {
|
|
t.Errorf("first write key = %q, want %q", busyReq.Key, "calendar_busy")
|
|
}
|
|
if busyReq.Value != "true" {
|
|
t.Errorf("calendar_busy value = %q, want %q", busyReq.Value, "true")
|
|
}
|
|
if busyReq.Source != "poll:caldav" {
|
|
t.Errorf("source = %q, want %q", busyReq.Source, "poll:caldav")
|
|
}
|
|
if busyReq.Kind != "env" {
|
|
t.Errorf("kind = %q, want %q", busyReq.Kind, "env")
|
|
}
|
|
if busyReq.Ts.IsZero() {
|
|
t.Errorf("calendar_busy ts is zero")
|
|
}
|
|
|
|
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM"
|
|
eventReq := fc.writeLog[1]
|
|
expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting"
|
|
if eventReq.Key != expectedKey {
|
|
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
|
|
}
|
|
expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.Format("15:04")
|
|
if eventReq.Value != expectedVal {
|
|
t.Errorf("event value = %q, want %q", eventReq.Value, expectedVal)
|
|
}
|
|
if eventReq.Source != "poll:caldav" {
|
|
t.Errorf("event source = %q, want %q", eventReq.Source, "poll:caldav")
|
|
}
|
|
if eventReq.Kind != "env" {
|
|
t.Errorf("event kind = %q, want %q", eventReq.Kind, "env")
|
|
}
|
|
if !eventReq.Ts.Equal(start) {
|
|
t.Errorf("event ts = %v, want %v", eventReq.Ts, start)
|
|
}
|
|
}
|