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(``) b.WriteString("" + base + "") 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("/cal/" + name + "") } b.WriteString("") 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) }