6daa96b66e
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
372 lines
9.9 KiB
Go
372 lines
9.9 KiB
Go
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_<date>_<summary> = "<summary> @ 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)
|
|
}
|
|
}
|