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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user