feat(tier2): calendar integration — Block B

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>
This commit is contained in:
Kwaku Danso
2026-05-17 19:36:06 +01:00
parent 39533162bb
commit 6803d700b4
7 changed files with 603 additions and 0 deletions
+75
View File
@@ -12,6 +12,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -317,6 +318,74 @@ func TestE2EHappyPath(t *testing.T) {
}, http.StatusNotFound)
assertNoRSVP(t, ctx, db.Pool, guestID)
})
// Tier 2 Block B — calendar integration.
t.Run("access response embeds calendar provider links", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostToken, "Calendar Test", "calendar-test")
guestID := createGuest(t, srv.URL, hostToken, eventID, "Cal Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
access := getAccessFull(t, srv.URL, token)
if access.Calendar.ICS == "" {
t.Fatalf("expected calendar.ics URL in /access response: %+v", access.Calendar)
}
if !strings.HasSuffix(access.Calendar.ICS, "/access/"+token+"/calendar.ics") {
t.Errorf("ics url not token-scoped: %s", access.Calendar.ICS)
}
if !strings.HasPrefix(access.Calendar.Google, "https://www.google.com/calendar/render?") {
t.Errorf("google link wrong: %s", access.Calendar.Google)
}
if !strings.HasPrefix(access.Calendar.Outlook, "https://outlook.live.com/") {
t.Errorf("outlook link wrong: %s", access.Calendar.Outlook)
}
})
t.Run("ics download is a valid VCALENDAR with the event name", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostToken, "Brunch & Beans", "brunch-beans")
guestID := createGuest(t, srv.URL, hostToken, eventID, "ICS Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
resp, err := http.Get(srv.URL + "/access/" + token + "/calendar.ics")
must(t, err, "GET .ics")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("ics status=%d body=%s", resp.StatusCode, body)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") {
t.Errorf("ics content-type: %q", ct)
}
cd := resp.Header.Get("Content-Disposition")
if !strings.Contains(cd, "filename=") || !strings.Contains(cd, "brunch-beans.ics") {
t.Errorf("ics content-disposition: %q", cd)
}
body, err := io.ReadAll(resp.Body)
must(t, err, "read ics body")
text := string(body)
for _, want := range []string{
"BEGIN:VCALENDAR",
"VERSION:2.0",
"BEGIN:VEVENT",
"UID:" + eventID.String() + "@guestguard.k4scloud",
"SUMMARY:Brunch & Beans",
"END:VEVENT",
"END:VCALENDAR",
} {
if !strings.Contains(text, want) {
t.Errorf("ics missing %q in:\n%s", want, text)
}
}
})
t.Run("ics download rejects unknown token", func(t *testing.T) {
resp, err := http.Get(srv.URL + "/access/tk_nonexistenttoken1234567890ab/calendar.ics")
must(t, err, "GET .ics unknown")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 404, got %d body=%s", resp.StatusCode, body)
}
})
}
// --- container helpers ---
@@ -536,6 +605,12 @@ type accessResponseFull struct {
PlusOnes int `json:"plus_ones"`
EditCount int `json:"edit_count"`
} `json:"rsvp"`
Calendar struct {
Google string `json:"google"`
Outlook string `json:"outlook"`
Yahoo string `json:"yahoo"`
ICS string `json:"ics"`
} `json:"calendar"`
}
func getAccessFull(t *testing.T, base, token string) accessResponseFull {