package calendar import ( "net/url" "strings" "testing" "time" "github.com/google/uuid" ) var sampleID = uuid.MustParse("11111111-2222-3333-4444-555555555555") func sampleEvent() Event { return Event{ ID: sampleID, Name: "Kwaku & Ama's Wedding", Venue: "The Mansfield, 11 W 26th St, New York, NY", Description: "Drinks at 6, ceremony at 7. Wear comfortable shoes; the rooftop has uneven cobbles.", StartsAt: time.Date(2026, 6, 12, 18, 0, 0, 0, time.UTC), } } func TestBuildICS_RequiredFields(t *testing.T) { now := time.Date(2026, 5, 17, 9, 30, 0, 0, time.UTC) got := BuildICS(sampleEvent(), now) wantSubstrings := []string{ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//GuestGuard//RSVP//EN", "BEGIN:VEVENT", "UID:11111111-2222-3333-4444-555555555555@guestguard.k4scloud", "DTSTAMP:20260517T093000Z", "DTSTART:20260612T180000Z", // Default 2h duration when EndsAt is zero. "DTEND:20260612T200000Z", // Comma + apostrophe in name; the comma is escaped, the apostrophe is not. `SUMMARY:Kwaku & Ama's Wedding`, "END:VEVENT", "END:VCALENDAR", } for _, want := range wantSubstrings { if !strings.Contains(got, want) { t.Errorf("ics missing %q in output:\n%s", want, got) } } } func TestBuildICS_EscapesText(t *testing.T) { e := sampleEvent() now := time.Date(2026, 5, 17, 9, 30, 0, 0, time.UTC) got := BuildICS(e, now) // Venue has a comma — must be escaped per RFC 5545. if !strings.Contains(got, `LOCATION:The Mansfield\, 11 W 26th St\, New York\, NY`) { t.Errorf("expected escaped commas in LOCATION; got:\n%s", got) } // Description has a semicolon — also must be escaped. if !strings.Contains(got, `DESCRIPTION:Drinks at 6\, ceremony at 7. Wear comfortable shoes\; the rooftop has uneven cobbles.`) { t.Errorf("expected escaped semicolon in DESCRIPTION; got:\n%s", got) } } func TestBuildICS_LineEndings(t *testing.T) { got := BuildICS(sampleEvent(), time.Now().UTC()) // RFC 5545 requires CRLF line endings. if !strings.Contains(got, "\r\n") { t.Error("expected CRLF line endings") } if strings.Contains(got, "\n\n") { t.Error("found bare LFs — must be CRLF") } } func TestBuildICS_HonoursExplicitEnd(t *testing.T) { e := sampleEvent() e.EndsAt = time.Date(2026, 6, 12, 23, 30, 0, 0, time.UTC) got := BuildICS(e, time.Now().UTC()) if !strings.Contains(got, "DTEND:20260612T233000Z") { t.Errorf("explicit end not honoured: %s", got) } } func TestBuildICS_OmitsEmptyOptionals(t *testing.T) { e := Event{ ID: sampleID, Name: "Quiet event", StartsAt: time.Date(2026, 6, 12, 18, 0, 0, 0, time.UTC), } got := BuildICS(e, time.Now().UTC()) if strings.Contains(got, "LOCATION:") { t.Error("LOCATION should be omitted when venue is empty") } if strings.Contains(got, "DESCRIPTION:") { t.Error("DESCRIPTION should be omitted when description is empty") } } func TestBuildLinks_GoogleEncodesDateRange(t *testing.T) { links := BuildLinks(sampleEvent(), "https://example/access/abc/calendar.ics") u, err := url.Parse(links.Google) if err != nil { t.Fatalf("parse google link: %v", err) } if u.Host != "www.google.com" || u.Path != "/calendar/render" { t.Errorf("unexpected google host/path: %s%s", u.Host, u.Path) } dates := u.Query().Get("dates") if dates != "20260612T180000Z/20260612T200000Z" { t.Errorf("google dates: got %q want 20260612T180000Z/20260612T200000Z", dates) } if u.Query().Get("text") != "Kwaku & Ama's Wedding" { t.Errorf("google text not preserved: %q", u.Query().Get("text")) } } func TestBuildLinks_OutlookYahooShape(t *testing.T) { links := BuildLinks(sampleEvent(), "https://example/ics") if !strings.HasPrefix(links.Outlook, "https://outlook.live.com/calendar/0/deeplink/compose?") { t.Errorf("outlook link wrong prefix: %s", links.Outlook) } if !strings.HasPrefix(links.Yahoo, "https://calendar.yahoo.com/?") { t.Errorf("yahoo link wrong prefix: %s", links.Yahoo) } if links.ICS != "https://example/ics" { t.Errorf("ICS url not echoed back: %s", links.ICS) } } func TestFileName(t *testing.T) { cases := map[string]string{ "Kwaku & Ama's Wedding": "kwaku-amas-wedding.ics", " Birthday Party!! ": "birthday-party.ics", "---": "event.ics", "": "event.ics", "Hack/Day 2026": "hackday-2026.ics", } for in, want := range cases { if got := FileName(in); got != want { t.Errorf("FileName(%q) = %q, want %q", in, got, want) } } }