1c94df76b7
Withdrawal read published, which is in-memory, so the second loop only ever withdrew reminders this process had published. Fire a reminder, restart mavcaldav, and its event stayed in the collection forever with nothing left to revisit it. "Losing it costs nothing, the next tick rebuilds it" holds for events that should be there and not for the ones that should not. The first tick now PROPFINDs the collection and reconciles what it finds against what is pending. Only hrefs carrying ReminderUIDPrefix are read back, so the pass can never propose deleting a file maven did not create. A failed read is retried on the next tick rather than skipped for the life of the process. Two smaller things from the same review. checkRenderTarget takes the whole read set, so a second calendar to read cannot quietly fall outside the guarantee the package comment makes. writeIfChanged loses its confidence parameter, which every caller passed 1.0 and nothing read. Found in review of #56.
302 lines
9.7 KiB
Go
302 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"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. existing seeds resources
|
|
// that were already in the collection before this process started, which is
|
|
// what a restart looks like from the renderer's side.
|
|
type calSrv struct {
|
|
mu sync.Mutex
|
|
puts map[string]string
|
|
dels []string
|
|
existing []string
|
|
propfind int
|
|
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/"))
|
|
case "PROPFIND":
|
|
s.propfind++
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
w.WriteHeader(http.StatusMultiStatus)
|
|
io.WriteString(w, s.multistatusLocked(r.URL.Path))
|
|
return
|
|
}
|
|
w.WriteHeader(s.status)
|
|
}))
|
|
return s
|
|
}
|
|
|
|
// multistatusLocked renders the collection listing. Caller holds the lock.
|
|
func (s *calSrv) multistatusLocked(base string) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0"?><D:multistatus xmlns:D="DAV:">`)
|
|
b.WriteString("<D:response><D:href>" + base + "</D:href></D:response>")
|
|
names := append([]string{}, s.existing...)
|
|
for name := range s.puts {
|
|
names = append(names, name)
|
|
}
|
|
for _, name := range names {
|
|
if slices.Contains(s.dels, name) {
|
|
continue
|
|
}
|
|
b.WriteString("<D:response><D:href>/cal/" + name + "</D:href></D:response>")
|
|
}
|
|
b.WriteString("</D:multistatus>")
|
|
return b.String()
|
|
}
|
|
|
|
func (s *calSrv) deleted() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]string{}, s.dels...)
|
|
}
|
|
|
|
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([]string{read}, ""); err != nil {
|
|
t.Fatalf("rendering off must be fine: %v", err)
|
|
}
|
|
if err := checkRenderTarget([]string{read}, "http://localhost:5232/kami/maven"); err != nil {
|
|
t.Fatalf("a distinct collection must be accepted: %v", err)
|
|
}
|
|
if err := checkRenderTarget([]string{read}, read); err == nil {
|
|
t.Error("rendering into the read calendar must be refused")
|
|
}
|
|
if err := checkRenderTarget([]string{read}, read+"/"); err == nil {
|
|
t.Error("a trailing slash must not defeat the check")
|
|
}
|
|
if err := checkRenderTarget([]string{read}, strings.ToUpper(read)); err == nil {
|
|
t.Error("case must not defeat the check")
|
|
}
|
|
// Every read target is checked, not the first one. A second calendar to
|
|
// read must not fall outside the guarantee just by being added later.
|
|
work := "http://localhost:5232/kami/work"
|
|
if err := checkRenderTarget([]string{read, work}, work); err == nil {
|
|
t.Error("rendering into the second read calendar must be refused")
|
|
}
|
|
if err := checkRenderTarget([]string{read, work}, "http://localhost:5232/kami/maven"); err != nil {
|
|
t.Fatalf("a collection maven owns must still be accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// Withdrawal has to survive a restart. published is in-memory, so a fresh
|
|
// process knows nothing about the events an earlier one wrote: fire a reminder,
|
|
// restart mavcaldav, and its event used to sit in the collection forever
|
|
// because nothing ever revisited it. The first tick reads the collection and
|
|
// reconciles what it finds against what is pending.
|
|
func TestRenderOnceWithdrawsAfterRestart(t *testing.T) {
|
|
srv := newCalSrv()
|
|
defer srv.Close()
|
|
// Left behind by a previous process: 4 is still pending, 5 has fired.
|
|
// The third file is not maven's and must not be touched.
|
|
srv.existing = []string{"maven-reminder-4.ics", "maven-reminder-5.ics", "dentist.ics"}
|
|
|
|
core := &reminderCore{reminders: []ipc.Reminder{
|
|
{ID: 4, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
|
{ID: 5, FireTs: time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC), Payload: "уже прозвенело", Status: "fired"},
|
|
}}
|
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
|
r.renderOnce(context.Background())
|
|
|
|
dels := srv.deleted()
|
|
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
|
t.Fatalf("deleted %v, want only the fired reminder's event", dels)
|
|
}
|
|
|
|
// The collection is read once, not on every tick.
|
|
r.renderOnce(context.Background())
|
|
srv.mu.Lock()
|
|
n := srv.propfind
|
|
srv.mu.Unlock()
|
|
if n != 1 {
|
|
t.Errorf("PROPFIND ran %d times, want once per process", n)
|
|
}
|
|
}
|
|
|
|
// A collection maven cannot read is not a reason to stop publishing to it, and
|
|
// the reconciliation must be retried rather than skipped for the process.
|
|
func TestRenderOnceRetriesReconcile(t *testing.T) {
|
|
srv := newCalSrv()
|
|
defer srv.Close()
|
|
srv.existing = []string{"maven-reminder-6.ics"}
|
|
failing := &http.Client{Transport: &propfindFailure{base: srv.Client().Transport}}
|
|
|
|
core := &reminderCore{}
|
|
r := newRenderer(core, failing, srv.URL+"/cal", "u", "p", 0)
|
|
r.renderOnce(context.Background())
|
|
if got := srv.deleted(); len(got) != 0 {
|
|
t.Fatalf("nothing can be withdrawn on a failed read: %v", got)
|
|
}
|
|
if r.reconciled {
|
|
t.Fatal("a failed read must not count as reconciled")
|
|
}
|
|
|
|
r.http = srv.Client()
|
|
r.renderOnce(context.Background())
|
|
if got := srv.deleted(); len(got) != 1 || got[0] != "maven-reminder-6.ics" {
|
|
t.Fatalf("deleted %v, want the orphaned event on the retry", got)
|
|
}
|
|
}
|
|
|
|
// propfindFailure fails PROPFIND and passes everything else through.
|
|
type propfindFailure struct{ base http.RoundTripper }
|
|
|
|
func (f *propfindFailure) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if req.Method == "PROPFIND" {
|
|
return nil, errors.New("collection unreachable")
|
|
}
|
|
return f.base.RoundTrip(req)
|
|
}
|