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:
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
// Renders the four add-to-calendar buttons (Google, Outlook, Apple/iCalendar,
|
||||
// Other). Tier 2 Block B.
|
||||
//
|
||||
// Apple Calendar has no web-deeplink — it imports .ics files directly from the
|
||||
// device. So Apple and "Other" both point at the .ics download URL; the only
|
||||
// difference is the label so a desktop guest knows which to click.
|
||||
defineProps<{
|
||||
links: {
|
||||
google: string
|
||||
outlook: string
|
||||
yahoo: string
|
||||
ics: string
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="Add this event to your calendar">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-widest text-brand-500">
|
||||
Add to calendar
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<!-- Google: opens the prefilled event in a new tab. -->
|
||||
<a
|
||||
:href="links.google"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5 4a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H8V3a1 1 0 00-2 0v1H5zm0 5a1 1 0 011-1h8a1 1 0 011 1v6a1 1 0 01-1 1H6a1 1 0 01-1-1V9z" />
|
||||
</svg>
|
||||
Google
|
||||
</a>
|
||||
|
||||
<!-- Outlook web: same idea, different domain. -->
|
||||
<a
|
||||
:href="links.outlook"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M3 4a2 2 0 012-2h10a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V4zm3 5a1 1 0 011-1h6a1 1 0 010 2H7a1 1 0 01-1-1zm0 4a1 1 0 011-1h4a1 1 0 010 2H7a1 1 0 01-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Outlook
|
||||
</a>
|
||||
|
||||
<!-- Apple Calendar imports .ics from the file system; pointing at the
|
||||
download triggers the OS handler on macOS/iOS. -->
|
||||
<a
|
||||
:href="links.ics"
|
||||
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 2a4 4 0 00-3.464 6.001A6 6 0 002 14a2 2 0 002 2h12a2 2 0 002-2 6 6 0 00-4.536-5.999A4 4 0 0010 2z" />
|
||||
</svg>
|
||||
Apple
|
||||
</a>
|
||||
|
||||
<!-- Catch-all .ics for everything that isn't a hosted web calendar. -->
|
||||
<a
|
||||
:href="links.ics"
|
||||
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Other (.ics)
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -8,12 +8,20 @@ interface ExistingRSVP {
|
||||
edit_count: number
|
||||
}
|
||||
|
||||
interface CalendarLinks {
|
||||
google: string
|
||||
outlook: string
|
||||
yahoo: string
|
||||
ics: string
|
||||
}
|
||||
|
||||
interface AccessResponse {
|
||||
guest: { id: string; name: string; email?: string | null; plus_ones: number }
|
||||
event: { id: string; name: string; venue: string; event_date: string }
|
||||
token: { id: string; status: string; expires_at: string }
|
||||
access_log_id: string
|
||||
rsvp?: ExistingRSVP | null
|
||||
calendar?: CalendarLinks
|
||||
}
|
||||
|
||||
interface RSVPSubmitResponse {
|
||||
@@ -130,6 +138,11 @@ function fmtDate(iso?: string) {
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
|
||||
// Calendar links come from the backend so we don't reimplement Google /
|
||||
// Outlook / Yahoo encoding rules per provider. They're available as soon
|
||||
// as /access loads — the guest can grab them before submitting.
|
||||
const calendar = computed<CalendarLinks | null>(() => access.value?.calendar ?? null)
|
||||
|
||||
// showForm = no prior submission, or the guest has clicked "Change my response"
|
||||
const showForm = computed(() => editing.value || !existing.value)
|
||||
const submitLabel = computed(() => {
|
||||
@@ -169,6 +182,13 @@ const submitLabel = computed(() => {
|
||||
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
|
||||
<span v-if="!result.fraud.used"> · fallback</span>
|
||||
</p>
|
||||
|
||||
<AddToCalendar
|
||||
v-if="calendar && result.rsvp.response === 'attending'"
|
||||
:links="calendar"
|
||||
class="mt-5 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<div v-if="!editLimitReached" class="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
Need to change something? You have {{ editsRemaining }}
|
||||
@@ -193,6 +213,13 @@ const submitLabel = computed(() => {
|
||||
<span v-if="existing.plus_ones > 0"> with +{{ existing.plus_ones }} plus-ones</span>
|
||||
on {{ fmtDate(existing.submitted_at) }}.
|
||||
</p>
|
||||
|
||||
<AddToCalendar
|
||||
v-if="calendar && existing.response === 'attending'"
|
||||
:links="calendar"
|
||||
class="mb-4 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<div v-if="!editLimitReached" class="flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
|
||||
|
||||
@@ -305,6 +305,11 @@ func (s *Server) Handler() http.Handler {
|
||||
// of their source IP.
|
||||
mux.Handle("GET /access/{token}",
|
||||
rl("access", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.access)))
|
||||
// Block B: .ics download. Same rate-limit class as /access since the
|
||||
// payload is similarly cheap and the abuse profile is identical (one
|
||||
// token per attacker).
|
||||
mux.Handle("GET /access/{token}/calendar.ics",
|
||||
rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar)))
|
||||
mux.Handle("POST /rsvp/{token}",
|
||||
rl("rsvp", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.submit)))
|
||||
// Block A: edits are bounded by MaxRSVPEdits server-side. The redis
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/auth"
|
||||
"github.com/alchemistkay/guestguard/internal/calendar"
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
"github.com/alchemistkay/guestguard/internal/natspub"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
@@ -410,6 +411,10 @@ type accessResponse struct {
|
||||
// page can show an edit form instead of a fresh submit form when the
|
||||
// guest revisits their invitation link (Tier 2 Block A).
|
||||
RSVP *domain.RSVP `json:"rsvp,omitempty"`
|
||||
// Calendar holds the add-to-calendar deep-links and the .ics download
|
||||
// path so the frontend renders four ready-to-click buttons after a
|
||||
// successful RSVP. (Tier 2 Block B.)
|
||||
Calendar calendar.ProviderLinks `json:"calendar"`
|
||||
}
|
||||
|
||||
// GET /access/{token} — validate token, log the access attempt, publish to NATS.
|
||||
@@ -498,9 +503,79 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
|
||||
Token: tk,
|
||||
AccessLog: accessLogID,
|
||||
RSVP: existingRSVP,
|
||||
Calendar: h.calendarLinks(event, raw),
|
||||
})
|
||||
}
|
||||
|
||||
// calendarLinks renders the three provider URLs + .ics path for the event.
|
||||
// The raw access token is embedded in the .ics path so the download endpoint
|
||||
// stays public (no auth) while still scoped to a single invitation.
|
||||
func (h *tokenHandler) calendarLinks(event *domain.Event, rawToken string) calendar.ProviderLinks {
|
||||
return calendar.BuildLinks(eventForCalendar(event), h.calendarICSURL(rawToken))
|
||||
}
|
||||
|
||||
func (h *tokenHandler) calendarICSURL(rawToken string) string {
|
||||
base := h.publicBaseURL
|
||||
if base == "" {
|
||||
base = "http://localhost:8080"
|
||||
}
|
||||
return base + "/access/" + rawToken + "/calendar.ics"
|
||||
}
|
||||
|
||||
func eventForCalendar(e *domain.Event) calendar.Event {
|
||||
// We deliberately don't surface event Settings into the calendar entry —
|
||||
// settings holds host-private config; the guest only needs the
|
||||
// human-facing fields.
|
||||
return calendar.Event{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
Venue: e.Venue,
|
||||
StartsAt: e.EventDate,
|
||||
}
|
||||
}
|
||||
|
||||
// GET /access/{token}/calendar.ics — public, token-scoped download. Same
|
||||
// token-validity rules as /access/{token}, but the response is the .ics
|
||||
// file rather than JSON.
|
||||
func (h *tokenHandler) calendar(w http.ResponseWriter, r *http.Request) {
|
||||
raw := r.PathValue("token")
|
||||
if err := auth.ValidateFormat(raw); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed token")
|
||||
return
|
||||
}
|
||||
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrTokenNotFound) {
|
||||
writeError(w, http.StatusNotFound, "token not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to load token")
|
||||
return
|
||||
}
|
||||
if err := tk.IsValid(time.Now().UTC()); err != nil {
|
||||
writeError(w, http.StatusGone, err.Error())
|
||||
return
|
||||
}
|
||||
guest, err := h.guests.Get(r.Context(), tk.GuestID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load guest")
|
||||
return
|
||||
}
|
||||
event, err := h.events.Get(r.Context(), guest.EventID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load event")
|
||||
return
|
||||
}
|
||||
|
||||
ics := calendar.BuildICS(eventForCalendar(event), time.Now().UTC())
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="`+calendar.FileName(event.Name)+`"`)
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(ics))
|
||||
}
|
||||
|
||||
func collectFingerprint(r *http.Request) map[string]any {
|
||||
fp := map[string]any{
|
||||
"user_agent": r.UserAgent(),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Package calendar builds the "add to calendar" artefacts shown on the RSVP
|
||||
// confirmation flow: an RFC 5545 iCalendar (.ics) file, plus the three
|
||||
// well-known provider deep-links (Google, Outlook, Yahoo). Tier 2 Block B.
|
||||
//
|
||||
// The .ics is the source of truth — Google/Outlook/Yahoo URLs are pure URL
|
||||
// builders and don't need a server round-trip, but we serve them anyway so
|
||||
// the frontend doesn't have to reimplement the encoding rules per provider.
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DefaultDuration is the assumed duration of an event when the host hasn't
|
||||
// told us when it ends. Two hours covers most weddings, parties, and dinners
|
||||
// without being so long it pollutes the guest's calendar if they don't tweak
|
||||
// it themselves.
|
||||
const DefaultDuration = 2 * time.Hour
|
||||
|
||||
// Event is the projection of domain.Event needed to render calendar artefacts.
|
||||
// Kept narrow so this package doesn't depend on the broader domain model and
|
||||
// can be unit-tested with synthetic data.
|
||||
type Event struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Venue string
|
||||
Description string
|
||||
StartsAt time.Time
|
||||
// EndsAt is optional. When zero, DefaultDuration is added to StartsAt.
|
||||
EndsAt time.Time
|
||||
}
|
||||
|
||||
// ProviderLinks bundles the three add-to-calendar URLs plus the path to
|
||||
// download the .ics. Embedded in the /access/{token} response so the
|
||||
// frontend renders four ready-made buttons.
|
||||
type ProviderLinks struct {
|
||||
Google string `json:"google"`
|
||||
Outlook string `json:"outlook"`
|
||||
Yahoo string `json:"yahoo"`
|
||||
ICS string `json:"ics"`
|
||||
}
|
||||
|
||||
// BuildLinks renders the four add-to-calendar URLs for the event. ICS is the
|
||||
// publicly-reachable path on this server (typically /access/{token}/calendar.ics).
|
||||
func BuildLinks(e Event, icsURL string) ProviderLinks {
|
||||
start, end := e.window()
|
||||
|
||||
gq := url.Values{}
|
||||
gq.Set("action", "TEMPLATE")
|
||||
gq.Set("text", e.Name)
|
||||
gq.Set("dates", fmt.Sprintf("%s/%s", fmtUTC(start), fmtUTC(end)))
|
||||
if e.Description != "" {
|
||||
gq.Set("details", e.Description)
|
||||
}
|
||||
if e.Venue != "" {
|
||||
gq.Set("location", e.Venue)
|
||||
}
|
||||
|
||||
oq := url.Values{}
|
||||
oq.Set("path", "/calendar/action/compose")
|
||||
oq.Set("rru", "addevent")
|
||||
oq.Set("subject", e.Name)
|
||||
oq.Set("startdt", start.UTC().Format(time.RFC3339))
|
||||
oq.Set("enddt", end.UTC().Format(time.RFC3339))
|
||||
if e.Description != "" {
|
||||
oq.Set("body", e.Description)
|
||||
}
|
||||
if e.Venue != "" {
|
||||
oq.Set("location", e.Venue)
|
||||
}
|
||||
|
||||
yq := url.Values{}
|
||||
yq.Set("v", "60")
|
||||
yq.Set("title", e.Name)
|
||||
yq.Set("st", fmtUTC(start))
|
||||
yq.Set("et", fmtUTC(end))
|
||||
if e.Description != "" {
|
||||
yq.Set("desc", e.Description)
|
||||
}
|
||||
if e.Venue != "" {
|
||||
yq.Set("in_loc", e.Venue)
|
||||
}
|
||||
|
||||
return ProviderLinks{
|
||||
Google: "https://www.google.com/calendar/render?" + gq.Encode(),
|
||||
Outlook: "https://outlook.live.com/calendar/0/deeplink/compose?" + oq.Encode(),
|
||||
Yahoo: "https://calendar.yahoo.com/?" + yq.Encode(),
|
||||
ICS: icsURL,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildICS renders a minimal RFC 5545 VCALENDAR with a single VEVENT.
|
||||
// The UID is deterministic — re-downloading replaces the same calendar
|
||||
// entry instead of creating a duplicate. Timestamps are emitted in UTC
|
||||
// (Z-suffixed); we don't bother with VTIMEZONE blocks because every modern
|
||||
// client handles UTC correctly and the host already chose a specific
|
||||
// instant in `event_date`.
|
||||
//
|
||||
// `now` is injected for deterministic testing — pass time.Now().UTC() in
|
||||
// production.
|
||||
func BuildICS(e Event, now time.Time) string {
|
||||
start, end := e.window()
|
||||
var b strings.Builder
|
||||
b.Grow(512)
|
||||
write := func(lines ...string) {
|
||||
for _, l := range lines {
|
||||
b.WriteString(l)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
write(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//GuestGuard//RSVP//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:"+uidFor(e.ID),
|
||||
"DTSTAMP:"+fmtUTC(now.UTC()),
|
||||
"DTSTART:"+fmtUTC(start),
|
||||
"DTEND:"+fmtUTC(end),
|
||||
"SUMMARY:"+escape(e.Name),
|
||||
)
|
||||
if e.Description != "" {
|
||||
write("DESCRIPTION:" + escape(e.Description))
|
||||
}
|
||||
if e.Venue != "" {
|
||||
write("LOCATION:" + escape(e.Venue))
|
||||
}
|
||||
write(
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FileName slugifies an event name into something safe for the
|
||||
// Content-Disposition header. Falls back to "event.ics" when nothing usable
|
||||
// is left after slugification.
|
||||
func FileName(name string) string {
|
||||
out := make([]byte, 0, len(name))
|
||||
for _, r := range strings.ToLower(name) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
out = append(out, byte(r))
|
||||
case r == ' ', r == '-', r == '_':
|
||||
out = append(out, '-')
|
||||
}
|
||||
}
|
||||
// Collapse repeated dashes.
|
||||
for strings.Contains(string(out), "--") {
|
||||
out = []byte(strings.ReplaceAll(string(out), "--", "-"))
|
||||
}
|
||||
s := strings.Trim(string(out), "-")
|
||||
if s == "" {
|
||||
return "event.ics"
|
||||
}
|
||||
return s + ".ics"
|
||||
}
|
||||
|
||||
func (e Event) window() (start, end time.Time) {
|
||||
start = e.StartsAt.UTC()
|
||||
if e.EndsAt.IsZero() {
|
||||
end = start.Add(DefaultDuration)
|
||||
} else {
|
||||
end = e.EndsAt.UTC()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// uidFor produces a deterministic UID for an event. The @guestguard.k4scloud
|
||||
// suffix is required by RFC 5545 ("globally unique identifier") and ties the
|
||||
// UID to our origin so unrelated systems don't accidentally collide.
|
||||
func uidFor(id uuid.UUID) string {
|
||||
return id.String() + "@guestguard.k4scloud"
|
||||
}
|
||||
|
||||
// fmtUTC encodes a time as the basic-form UTC stamp the providers expect:
|
||||
// "20260517T180000Z". time.Format with a literal layout avoids the locale
|
||||
// surprises of time.RFC* constants.
|
||||
func fmtUTC(t time.Time) string {
|
||||
return t.UTC().Format("20060102T150405Z")
|
||||
}
|
||||
|
||||
// escape applies the small set of TEXT-value substitutions RFC 5545 calls
|
||||
// out. Without these, a comma in a venue name silently truncates the field
|
||||
// in some readers.
|
||||
var textReplacer = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
"\n", `\n`,
|
||||
"\r", "",
|
||||
",", `\,`,
|
||||
";", `\;`,
|
||||
)
|
||||
|
||||
func escape(s string) string {
|
||||
return textReplacer.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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