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}