From 6daa96b66eac98b2507410ae212091249e4e6244 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 5 Jul 2026 02:24:28 +0400 Subject: [PATCH] mavcaldav: add comprehensive test suite (14 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: - iCal parsing: filters today's events, excludes past/future/all-day - VEVENT parsing: TZID, UTC, and all-day (nil) events - DTSTART/DTEND parsing: UTC, local (treated as UTC), all-day, invalid - safeKey sanitization: spaces→dashes, strip special chars - writeIfChanged: no-prev writes, same-value skips, diff-value writes, read-error and write-error propagation - pollOnce end-to-end: httptest.Server + fakeCore verifies calendar_busy + calendar_event facts written with correct keys/values --- cmd/mavcaldav/main_test.go | 371 +++++++++++++++++++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 cmd/mavcaldav/main_test.go diff --git a/cmd/mavcaldav/main_test.go b/cmd/mavcaldav/main_test.go new file mode 100644 index 0000000..6972eda --- /dev/null +++ b/cmd/mavcaldav/main_test.go @@ -0,0 +1,371 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +type fakeCore struct { + ipc.CoreAPI + 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 +} + +// --------------------------------------------------------------------------- +// Parsing tests +// --------------------------------------------------------------------------- + +func TestParseICal(t *testing.T) { + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + + body := []byte(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260703T090000Z +DTEND:20260703T100000Z +SUMMARY:Morning standup +END:VEVENT +BEGIN:VEVENT +DTSTART:20260703T140000Z +DTEND:20260703T150000Z +SUMMARY:Team sync +END:VEVENT +BEGIN:VEVENT +DTSTART:20260702T140000Z +DTEND:20260702T150000Z +SUMMARY:Yesterday retro +END:VEVENT +BEGIN:VEVENT +DTSTART:20260704T090000Z +DTEND:20260704T100000Z +SUMMARY:Tomorrow standup +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20260704 +DTEND;VALUE=DATE:20260705 +SUMMARY:All-day event +END:VEVENT +END:VCALENDAR`) + + events := parseICal(body, now) + + if len(events) != 2 { + t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events)) + } + + // Morning standup — overlaps today. + if events[0].summary != "Morning standup" { + t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup") + } + wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC) + if !events[0].start.Equal(wantStart0) { + t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0) + } + wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC) + if !events[0].end.Equal(wantEnd0) { + t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0) + } + + // Team sync — overlaps today. + if events[1].summary != "Team sync" { + t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync") + } + wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC) + if !events[1].start.Equal(wantStart1) { + t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1) + } + wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC) + if !events[1].end.Equal(wantEnd1) { + t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1) + } +} + +func TestParseVEVENT(t *testing.T) { + // Normal event with TZID in DTSTART and UTC DTEND. + block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting" + e := parseVEVENT(block) + if e == nil { + t.Fatal("expected non-nil icalEvent") + } + wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) + if !e.start.Equal(wantStart) { + t.Errorf("start = %v, want %v", e.start, wantStart) + } + wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC) + if !e.end.Equal(wantEnd) { + t.Errorf("end = %v, want %v", e.end, wantEnd) + } + if e.summary != "Stand up meeting" { + t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting") + } + + // All-day event (VALUE=DATE) → nil. + allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day" + if e2 := parseVEVENT(allDay); e2 != nil { + t.Error("expected nil for all-day event") + } +} + +func TestParseDT(t *testing.T) { + tests := []struct { + name string + line string + want time.Time + wantOK bool + }{ + { + name: "UTC", + line: "DTEND:20260703T100000Z", + want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), + wantOK: true, + }, + { + name: "local time", + line: "DTSTART;TZID=Europe/Moscow:20260703T130000", + want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), + wantOK: true, + }, + { + name: "all-day", + line: "DTSTART;VALUE=DATE:20260703", + want: time.Time{}, + wantOK: false, + }, + { + name: "invalid", + line: "DTSTART:garbage", + want: time.Time{}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseDT(tt.line) + if ok != tt.wantOK { + t.Errorf("ok = %v, want %v", ok, tt.wantOK) + } + if !got.Equal(tt.want) { + t.Errorf("got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSafeKey(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Stand up meeting", "Stand-up-meeting"}, + {"Hello_World", "Hello-World"}, + {"special@#$chars!!", "specialchars"}, + {"ALL_CAPS_123", "ALL-CAPS-123"}, + } + + for _, tt := range tests { + got := safeKey(tt.input) + if got != tt.want { + t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// --------------------------------------------------------------------------- +// 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__ = " @ HH:MM-HH:MM" + eventReq := fc.writeLog[1] + expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting" + if eventReq.Key != expectedKey { + t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey) + } + expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.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) + } +}