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.
187 lines
5.4 KiB
Go
187 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// reminderCore is a fakeCore that also answers ListReminders.
|
|
type reminderCore struct {
|
|
fakeCore
|
|
reminders []ipc.Reminder
|
|
listErr error
|
|
}
|
|
|
|
func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) {
|
|
if c.listErr != nil {
|
|
return nil, c.listErr
|
|
}
|
|
return c.reminders, nil
|
|
}
|
|
|
|
// calSrv records what a CalDAV collection received.
|
|
type calSrv struct {
|
|
mu sync.Mutex
|
|
puts map[string]string
|
|
dels []string
|
|
status int
|
|
*httptest.Server
|
|
}
|
|
|
|
func newCalSrv() *calSrv {
|
|
s := &calSrv{puts: map[string]string{}, status: http.StatusCreated}
|
|
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
|
case http.MethodDelete:
|
|
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
|
}
|
|
w.WriteHeader(s.status)
|
|
}))
|
|
return s
|
|
}
|
|
|
|
func (s *calSrv) putCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.puts)
|
|
}
|
|
|
|
func TestRenderOncePublishesPendingReminders(t *testing.T) {
|
|
fire := time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC)
|
|
srv := newCalSrv()
|
|
defer srv.Close()
|
|
|
|
core := &reminderCore{reminders: []ipc.Reminder{
|
|
{ID: 7, FireTs: fire, Payload: "позвонить маме", Status: "pending"},
|
|
{ID: 8, FireTs: fire, Payload: "уже сделано", Status: "fired"},
|
|
{ID: 9, FireTs: fire, Payload: "отменено", Status: "cancelled"},
|
|
}}
|
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal/", "u", "p", 0)
|
|
r.renderOnce(context.Background())
|
|
|
|
srv.mu.Lock()
|
|
body, ok := srv.puts["maven-reminder-7.ics"]
|
|
n := len(srv.puts)
|
|
srv.mu.Unlock()
|
|
|
|
if n != 1 {
|
|
t.Fatalf("expected exactly the pending reminder to be published, got %d PUTs", n)
|
|
}
|
|
if !ok {
|
|
t.Fatal("pending reminder 7 was not published")
|
|
}
|
|
if !strings.Contains(body, "SUMMARY:позвонить маме") {
|
|
t.Errorf("payload missing from rendered body:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "UID:maven-reminder-7") {
|
|
t.Errorf("UID missing from rendered body:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestRenderOnceSkipsUnchanged(t *testing.T) {
|
|
srv := newCalSrv()
|
|
defer srv.Close()
|
|
core := &reminderCore{reminders: []ipc.Reminder{
|
|
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
|
}}
|
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
|
r.renderOnce(context.Background())
|
|
r.renderOnce(context.Background())
|
|
if got := srv.putCount(); got != 1 {
|
|
t.Fatalf("an unchanged reminder was re-published: %d distinct PUTs", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderOnceWithdrawsResolvedReminders(t *testing.T) {
|
|
srv := newCalSrv()
|
|
defer srv.Close()
|
|
core := &reminderCore{reminders: []ipc.Reminder{
|
|
{ID: 5, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "встреча", Status: "pending"},
|
|
}}
|
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
|
r.renderOnce(context.Background())
|
|
|
|
core.reminders[0].Status = "fired"
|
|
r.renderOnce(context.Background())
|
|
|
|
srv.mu.Lock()
|
|
dels := append([]string(nil), srv.dels...)
|
|
srv.mu.Unlock()
|
|
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
|
t.Fatalf("resolved reminder was not withdrawn: %v", dels)
|
|
}
|
|
if len(r.published) != 0 {
|
|
t.Errorf("published map still holds %v", r.published)
|
|
}
|
|
}
|
|
|
|
// A calendar maven cannot reach must never break anything: sqlite is canonical.
|
|
func TestRenderOnceSurvivesServerErrors(t *testing.T) {
|
|
srv := newCalSrv()
|
|
srv.status = http.StatusInternalServerError
|
|
defer srv.Close()
|
|
core := &reminderCore{reminders: []ipc.Reminder{
|
|
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "x", Status: "pending"},
|
|
}}
|
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
|
r.renderOnce(context.Background())
|
|
if len(r.published) != 0 {
|
|
t.Error("a failed PUT must not be recorded as published, or it never retries")
|
|
}
|
|
}
|
|
|
|
func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
|
srv := newCalSrv()
|
|
defer srv.Close()
|
|
next := time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC)
|
|
core := &reminderCore{reminders: []ipc.Reminder{{
|
|
ID: 3,
|
|
FireTs: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
|
NextFireTs: next,
|
|
Payload: "зарядка",
|
|
Status: "pending",
|
|
Cron: "0 7 * * *",
|
|
}}}
|
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
|
r.renderOnce(context.Background())
|
|
|
|
srv.mu.Lock()
|
|
body := srv.puts["maven-reminder-3.ics"]
|
|
srv.mu.Unlock()
|
|
if !strings.Contains(body, "DTSTART:20260802T070000Z") {
|
|
t.Errorf("recurring reminder should render its next occurrence:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
|
read := "http://localhost:5232/kami/personal"
|
|
if err := checkRenderTarget(read, ""); err != nil {
|
|
t.Fatalf("rendering off must be fine: %v", err)
|
|
}
|
|
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
|
t.Fatalf("a distinct collection must be accepted: %v", err)
|
|
}
|
|
if err := checkRenderTarget(read, read); err == nil {
|
|
t.Error("rendering into the read calendar must be refused")
|
|
}
|
|
if err := checkRenderTarget(read, read+"/"); err == nil {
|
|
t.Error("a trailing slash must not defeat the check")
|
|
}
|
|
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
|
t.Error("case must not defeat the check")
|
|
}
|
|
}
|