package calendar import ( "strings" "testing" "time" ) func TestParseICalDayKeepsOnlyToday(t *testing.T) { now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) body := []byte(`BEGIN:VCALENDAR BEGIN:VEVENT UID:a@example 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 := ParseICalDay(body, now) if len(events) != 2 { t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events)) } if events[0].Summary != "Morning standup" || events[0].UID != "a@example" { t.Errorf("events[0] = %+v", events[0]) } if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) { t.Errorf("events[0].Start = %v", events[0].Start) } if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) { t.Errorf("events[0].End = %v", events[0].End) } if events[1].Summary != "Team sync" { t.Errorf("events[1].Summary = %q", events[1].Summary) } } // Regression: "today" is the owner's day, in the owner's location. Taking the // day number off a local clock but building the boundaries in UTC made the // evening fall outside the window on any box east of Greenwich. func TestParseICalDayUsesOwnersDay(t *testing.T) { plus4 := time.FixedZone("+04", 4*60*60) // 01:00 on Aug 1 local is 21:00 on Jul 31 UTC. now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4) body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + "DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" + "END:VEVENT\nEND:VCALENDAR") events := ParseICalDay(body, now) if len(events) != 1 { t.Fatalf("got %d events, want the in-progress one", len(events)) } if !Busy(events, now.UTC()) { t.Error("an event in progress right now must read as busy") } } func TestParseVEVENT(t *testing.T) { block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting" e, ok := parseVEVENT(block, time.UTC) if !ok { t.Fatal("expected a parsed event") } // 13:00 Moscow is 10:00Z. Reading it as 13:00Z is the bug that put the // event three hours late in the day plan. if !e.Start.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) { t.Errorf("start = %v", e.Start) } if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) { t.Errorf("end = %v", e.End) } if e.Summary != "Stand up meeting" { t.Errorf("summary = %q", e.Summary) } allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day" if _, ok := parseVEVENT(allDay, time.UTC); ok { t.Error("all-day event should be rejected") } } func TestParseDT(t *testing.T) { plus4 := time.FixedZone("+04", 4*60*60) tests := []struct { name string line string loc *time.Location want time.Time wantOK bool }{ {"UTC", "DTEND:20260703T100000Z", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, {"tzid", "DTSTART;TZID=Europe/Moscow:20260703T130000", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, {"tzid quoted", `DTSTART;TZID="Europe/Moscow":20260703T130000`, plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, {"tzid with other params", "DTSTART;VALUE=DATE-TIME;TZID=Asia/Tokyo:20260703T130000", plus4, time.Date(2026, 7, 3, 4, 0, 0, 0, time.UTC), true}, // An unloadable zone falls back to the reader's own clock, not to UTC. {"unknown tzid", "DTSTART;TZID=Mars/Olympus:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true}, // Floating: no Z, no TZID. Local to whoever reads it. {"floating", "DTSTART:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true}, {"all-day", "DTSTART;VALUE=DATE:20260703", plus4, time.Time{}, false}, {"garbage", "DTSTART:garbage", plus4, time.Time{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := parseDT(tt.line, tt.loc) 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{ in, want string }{ {"Stand up meeting", "Stand-up-meeting"}, {"Hello_World", "Hello-World"}, {"special@#$chars!!", "specialchars"}, {"ALL_CAPS_123", "ALL-CAPS-123"}, // His calendar is Russian. These reduced to "--" and "--" (Vikunja #443). {"Встреча с Аней", "Встреча-с-Аней"}, {"Обед с мамой", "Обед-с-мамой"}, } for _, tt := range tests { if got := safeKey(tt.in); got != tt.want { t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestFactKeyAndValue(t *testing.T) { e := Event{ Summary: "Team sync", Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC), End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC), } if got, want := FactKeyIn(e, time.UTC), "calendar_event_20260703_Team-sync"; got != want { t.Errorf("FactKey = %q, want %q", got, want) } if got, want := FactValueIn(e, time.UTC), "Team sync @ 14:00-15:00"; got != want { t.Errorf("FactValue = %q, want %q", got, want) } if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want { t.Errorf("KeyPrefixForDay = %q, want %q", got, want) } if !strings.HasPrefix(FactKeyIn(e, time.UTC), KeyPrefixForDay(e.Start)) { t.Error("FactKey must start with the day prefix the store range-scans on") } } // The key date and the printed time are the owner's, not the calendar // server's. A 23:00 Moscow event read on a +04 box belongs to the next local // day, and filing it under the Moscow day would hide it from the day plan the // store range-scans for. func TestFactKeyAndValueUseTheOwnersClock(t *testing.T) { msk := time.FixedZone("MSK", 3*60*60) plus4 := time.FixedZone("+04", 4*60*60) e := Event{ Summary: "Late sync", Start: time.Date(2026, 7, 3, 23, 30, 0, 0, msk), End: time.Date(2026, 7, 4, 0, 30, 0, 0, msk), } if got, want := FactKeyIn(e, plus4), "calendar_event_20260704_Late-sync"; got != want { t.Errorf("FactKeyIn = %q, want %q", got, want) } if got, want := FactValueIn(e, plus4), "Late sync @ 00:30-01:30"; got != want { t.Errorf("FactValueIn = %q, want %q", got, want) } } func TestFactSummaryDropsTheTimeTail(t *testing.T) { if got, want := FactSummary("Standup @ 14:00-14:30"), "Standup"; got != want { t.Errorf("FactSummary = %q, want %q", got, want) } // Nothing that is not the exact tail FactValue writes is touched. for _, in := range []string{"Coffee @ home", "Standup", "Standup @ 14:00-14:3", "Standup @ 1a:00-14:30"} { if got := FactSummary(in); got != in { t.Errorf("FactSummary(%q) = %q, want it unchanged", in, got) } } } // Regression for the mirror image of the window bug: the window is local, so // the event must be a real instant too. A 22:00 event stated in the poller's // own zone used to parse as 22:00Z, which on a +03 box is past the end of the // local day, and the whole evening dropped out of both the busy gate and the // day plan. func TestParseICalDayKeepsTheEveningInAZonedCalendar(t *testing.T) { plus3 := time.FixedZone("+03", 3*60*60) now := time.Date(2026, 8, 1, 12, 0, 0, 0, plus3) body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + "DTSTART;TZID=Europe/Moscow:20260801T220000\nDTEND;TZID=Europe/Moscow:20260801T230000\n" + "SUMMARY:Evening call\nEND:VEVENT\nEND:VCALENDAR") events := ParseICalDay(body, now) if len(events) != 1 { t.Fatalf("got %d events, want the evening one", len(events)) } if got := events[0].Start.In(plus3).Format("15:04"); got != "22:00" { t.Errorf("start reads %s locally, want 22:00", got) } } func TestBusyAndOverlapping(t *testing.T) { base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) events := []Event{ {Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)}, {Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)}, } if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) { t.Error("should be busy inside the early event") } if Busy(events, base.Add(12*time.Hour)) { t.Error("should be free at noon") } // Half-open: the end instant is free. if Busy(events, base.Add(10*time.Hour)) { t.Error("the end instant should not count as busy") } got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour)) if len(got) != 1 || got[0].Summary != "early" { t.Fatalf("Overlapping = %+v", got) } all := Overlapping(events, base, base.AddDate(0, 0, 1)) if len(all) != 2 || all[0].Summary != "early" { t.Fatalf("Overlapping must sort by start: %+v", all) } } func TestSourceTrust(t *testing.T) { if ReadOnlySource(SourcePersonal) { t.Error("the personal calendar is the one maven may render to") } if !ReadOnlySource(SourceWork) { t.Error("the work calendar must be read-only") } if !ReadOnlySource(SourceAmbient) { t.Error("an ambient notification is not a writable calendar") } if AmbientConfidence >= 1.0 { t.Error("ambient events must be less trusted than a calendar read") } if len(Sources()) != 3 { t.Errorf("Sources() = %v", Sources()) } } // Two Russian events on one day must not share a key. They did: safeKey kept // ASCII only, so both summaries collapsed to their spaces and the second event // overwrote the first in the store (Vikunja #443). func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) { day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)} b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)} if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) { t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC)) } // The day prefix still has to survive, because the store range-scans on it. if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) { t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) } }