calendar_test.go

  1package calendar
  2
  3import (
  4	"os"
  5	"strings"
  6	"testing"
  7	"time"
  8)
  9
 10func TestParseICS_Simple(t *testing.T) {
 11	data, err := os.ReadFile("../testdata/invites/simple.ics")
 12	if err != nil {
 13		t.Fatalf("Failed to read test fixture: %v", err)
 14	}
 15
 16	event, err := ParseICS(data)
 17	if err != nil {
 18		t.Fatalf("ParseICS failed: %v", err)
 19	}
 20
 21	if event.UID != "test-event-123@example.com" {
 22		t.Errorf("Expected UID test-event-123@example.com, got %s", event.UID)
 23	}
 24
 25	if event.Summary != "Q2 Planning Meeting" {
 26		t.Errorf("Expected summary 'Q2 Planning Meeting', got %s", event.Summary)
 27	}
 28
 29	if event.Location != "Conference Room A" {
 30		t.Errorf("Expected location 'Conference Room A', got %s", event.Location)
 31	}
 32
 33	if event.Organizer != "alice@company.com" {
 34		t.Errorf("Expected organizer alice@company.com, got %s", event.Organizer)
 35	}
 36
 37	if event.Status != "CONFIRMED" {
 38		t.Errorf("Expected status CONFIRMED, got %s", event.Status)
 39	}
 40
 41	if event.Method != "REQUEST" {
 42		t.Errorf("Expected method REQUEST, got %s", event.Method)
 43	}
 44
 45	expectedStart := time.Date(2026, 4, 21, 14, 0, 0, 0, time.UTC)
 46	if !event.Start.Equal(expectedStart) {
 47		t.Errorf("Expected start %v, got %v", expectedStart, event.Start)
 48	}
 49
 50	expectedEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, time.UTC)
 51	if !event.End.Equal(expectedEnd) {
 52		t.Errorf("Expected end %v, got %v", expectedEnd, event.End)
 53	}
 54}
 55
 56func TestParseICS_NoEvent(t *testing.T) {
 57	data := []byte(`BEGIN:VCALENDAR
 58VERSION:2.0
 59PRODID:-//Test//Test//EN
 60END:VCALENDAR`)
 61
 62	_, err := ParseICS(data)
 63	if err == nil {
 64		t.Error("Expected error for calendar with no VEVENT")
 65	}
 66	if !strings.Contains(err.Error(), "no VEVENT") {
 67		t.Errorf("Expected 'no VEVENT' error, got: %v", err)
 68	}
 69}
 70
 71func TestParseICS_Malformed(t *testing.T) {
 72	data := []byte(`INVALID ICAL DATA`)
 73
 74	_, err := ParseICS(data)
 75	if err == nil {
 76		t.Error("Expected error for malformed iCalendar data")
 77	}
 78}
 79
 80func TestGenerateRSVP(t *testing.T) {
 81	data, err := os.ReadFile("../testdata/invites/simple.ics")
 82	if err != nil {
 83		t.Fatalf("Failed to read test fixture: %v", err)
 84	}
 85
 86	responses := []string{"ACCEPTED", "DECLINED", "TENTATIVE"}
 87
 88	for _, response := range responses {
 89		t.Run(response, func(t *testing.T) {
 90			rsvpData, err := GenerateRSVP(data, "bob@company.com", response)
 91			if err != nil {
 92				t.Fatalf("GenerateRSVP failed for %s: %v", response, err)
 93			}
 94
 95			rsvpStr := string(rsvpData)
 96
 97			// Check METHOD:REPLY is set
 98			if !strings.Contains(rsvpStr, "METHOD:REPLY") {
 99				t.Error("Expected METHOD:REPLY in RSVP")
100			}
101
102			// Check PARTSTAT is updated
103			if !strings.Contains(rsvpStr, "PARTSTAT="+response) {
104				t.Errorf("Expected PARTSTAT=%s in RSVP", response)
105			}
106
107			// RFC 6047: only the responding attendee should remain
108			attendeeCount := strings.Count(rsvpStr, "ATTENDEE")
109			if attendeeCount != 1 {
110				t.Errorf("Expected exactly 1 ATTENDEE in RSVP, got %d", attendeeCount)
111			}
112
113			// Should contain responding user's email
114			if !strings.Contains(rsvpStr, "bob@company.com") {
115				t.Error("Expected bob@company.com in RSVP attendee")
116			}
117
118			// Should NOT contain other attendees
119			if strings.Contains(rsvpStr, "carol@company.com") {
120				t.Error("RSVP should not contain other attendees")
121			}
122
123			// Verify it's still valid iCalendar
124			_, err = ParseICS(rsvpData)
125			if err != nil {
126				t.Errorf("Generated RSVP is not valid iCalendar: %v", err)
127			}
128		})
129	}
130}
131
132// buildICS wraps DTSTART/DTEND lines into a minimal VCALENDAR for ParseICS.
133func buildICS(dtstart, dtend string) []byte {
134	return []byte("BEGIN:VCALENDAR\r\n" +
135		"VERSION:2.0\r\n" +
136		"PRODID:-//Test//Test//EN\r\n" +
137		"BEGIN:VEVENT\r\n" +
138		"UID:date-only@example.com\r\n" +
139		"DTSTAMP:20260415T120000Z\r\n" +
140		dtstart + "\r\n" +
141		dtend + "\r\n" +
142		"SUMMARY:Test\r\n" +
143		"END:VEVENT\r\n" +
144		"END:VCALENDAR\r\n")
145}
146
147func TestParseICS_DateOnly(t *testing.T) {
148	wantStart := time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC)
149	wantEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC)
150
151	tests := []struct {
152		name    string
153		dtstart string
154		dtend   string
155	}{
156		{
157			name:    "VALUE=DATE without TZID",
158			dtstart: "DTSTART;VALUE=DATE:20260421",
159			dtend:   "DTEND;VALUE=DATE:20260422",
160		},
161		{
162			// Regression: TZID present on a date-only value must be ignored
163			// (RFC 5545 forbids TZID with VALUE=DATE; some producers emit it anyway).
164			name:    "VALUE=DATE with TZID is ignored",
165			dtstart: "DTSTART;TZID=America/New_York;VALUE=DATE:20260421",
166			dtend:   "DTEND;TZID=America/New_York;VALUE=DATE:20260422",
167		},
168		{
169			// Shape-only detection: no VALUE param, but YYYYMMDD value with TZID.
170			name:    "YYYYMMDD shape with TZID is treated as date-only",
171			dtstart: "DTSTART;TZID=America/Los_Angeles:20260421",
172			dtend:   "DTEND;TZID=America/Los_Angeles:20260422",
173		},
174	}
175
176	for _, tt := range tests {
177		t.Run(tt.name, func(t *testing.T) {
178			event, err := ParseICS(buildICS(tt.dtstart, tt.dtend))
179			if err != nil {
180				t.Fatalf("ParseICS failed: %v", err)
181			}
182			if !event.Start.Equal(wantStart) {
183				t.Errorf("Start = %v, want %v", event.Start.UTC(), wantStart)
184			}
185			if !event.End.Equal(wantEnd) {
186				t.Errorf("End = %v, want %v", event.End.UTC(), wantEnd)
187			}
188		})
189	}
190}
191
192func TestParseICS_TimedWithTZID(t *testing.T) {
193	// Existing behavior: timed values with TZID keep their zone semantics.
194	event, err := ParseICS(buildICS(
195		"DTSTART;TZID=America/New_York:20260421T140000",
196		"DTEND;TZID=America/New_York:20260421T153000",
197	))
198	if err != nil {
199		t.Fatalf("ParseICS failed: %v", err)
200	}
201
202	loc, err := time.LoadLocation("America/New_York")
203	if err != nil {
204		t.Skipf("America/New_York unavailable on this system: %v", err)
205	}
206	wantStart := time.Date(2026, 4, 21, 14, 0, 0, 0, loc)
207	wantEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, loc)
208
209	if !event.Start.Equal(wantStart) {
210		t.Errorf("Start = %v, want %v", event.Start, wantStart)
211	}
212	if !event.End.Equal(wantEnd) {
213		t.Errorf("End = %v, want %v", event.End, wantEnd)
214	}
215}
216
217func TestExtractEmail(t *testing.T) {
218	tests := []struct {
219		input    string
220		expected string
221	}{
222		{"mailto:user@example.com", "user@example.com"},
223		{"MAILTO:user@example.com", "user@example.com"},
224		{"CN=John Doe:user@example.com", "user@example.com"},
225		{"user@example.com", "user@example.com"},
226		{"  user@example.com  ", "user@example.com"},
227	}
228
229	for _, tt := range tests {
230		t.Run(tt.input, func(t *testing.T) {
231			result := extractEmail(tt.input)
232			if result != tt.expected {
233				t.Errorf("extractEmail(%q) = %q, want %q", tt.input, result, tt.expected)
234			}
235		})
236	}
237}