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.
215 lines
6.0 KiB
Go
215 lines
6.0 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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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".
|
|
// The iCal states the event in UTC and the fact is stamped on the owner's
|
|
// clock, so the expected key date and times are the local reading of it.
|
|
eventReq := fc.writeLog[1]
|
|
expectedKey := "calendar_event_" + start.Local().Format("20060102") + "_Current-meeting"
|
|
if eventReq.Key != expectedKey {
|
|
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
|
|
}
|
|
expectedVal := "Current meeting @ " + start.Local().Format("15:04") + "-" + end.Local().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)
|
|
}
|
|
}
|