Files
Maven/cmd/mavcaldav/main_test.go
T
kami 3af290152c 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.
2026-08-01 01:55:51 +04:00

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)
}
}