6803d700b4
After confirming attendance (or revisiting an already-attending RSVP),
guests can add the event to Google, Outlook, Apple, or any iCalendar
client with one click.
- internal/calendar package builds an RFC 5545 VCALENDAR plus the three
provider deep-links from a narrow Event projection. Times are emitted
as Z-suffixed UTC; the UID is deterministic (event-uuid@guestguard)
so re-downloads update the same calendar entry instead of duplicating
- GET /access/{token}/calendar.ics returns the .ics with a slugified
filename in Content-Disposition; same rate-limit class as /access
- GET /access/{token} response now embeds google/outlook/yahoo/ics URLs
so the frontend doesn't re-encode per provider
- AddToCalendar.vue renders the four buttons; shown only when the
guest is attending (declined/maybe don't need a calendar entry)
- Unit tests cover ics field presence, RFC 5545 escaping (comma,
semicolon), CRLF line endings, explicit-end handling, omitted
optionals, provider URL shape, filename slugification
- Integration: ics download returns valid VCALENDAR with the event
UID + name; unknown token returns 404; /access embeds the links
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
144 lines
4.4 KiB
Go
144 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|