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