908d92a7e8
safeKey kept ASCII only, so "Встреча с Аней" and "Обед с мамой" both reduced to "--" and shared one key on one day. The second event of the day overwrote the first, silently, and his calendar is Russian. Letters and digits in any script now pass. Migration #18 deletes the rows written under the old rule instead of rewriting them: a calendar fact is derived, the next poll writes the day again, and a stale row reads as an extra meeting.
285 lines
10 KiB
Go
285 lines
10 KiB
Go
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))
|
||
}
|
||
}
|