diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index fb0d1187..9cd1b432 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -894,19 +894,115 @@ describe('DayPlanSidebar', () => { // ── ICS export click ───────────────────────────────────────────────── - it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => { + it('FE-PLANNER-DAYPLAN-058: clicking ICS button first asks link or download', async () => { const user = userEvent.setup() - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), - } as any) - // Mock URL.createObjectURL + if (!navigator.clipboard) { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }) + } + const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input) + const method = (init?.method || 'GET').toUpperCase() + + if (url === '/api/trips/1/subscribe.ics' && method === 'GET') { + return { + ok: true, + json: () => Promise.resolve({ token: null }), + } as any + } + + if (url === '/api/trips/1/subscribe.ics' && method === 'POST') { + return { + ok: true, + json: () => Promise.resolve({ + url: 'https://example.com/api/shared/token/calendar.ics', + webcal_url: 'webcal://example.com/api/shared/token/calendar.ics', + }), + } as any + } + + if (url === '/api/trips/1/subscribe.ics' && method === 'DELETE') { + return { ok: true } as any + } + + if (url === '/api/trips/1/export.ics' && method === 'GET') { + return { + ok: true, + blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), + } as any + } + + throw new Error(`Unexpected fetch call: ${method} ${url}`) + }) const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + render() await user.click(screen.getByText('ICS').closest('button')!) + + await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object))) + expect(await screen.findByText('Calendar share')).toBeInTheDocument() + expect(screen.getByText('Create a subscription link for calendar apps, or download the ICS file.')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Create link' })) + await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' }))) + + expect(screen.getByDisplayValue('https://example.com/api/shared/token/calendar.ics')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Copy' })) + await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith('https://example.com/api/shared/token/calendar.ics')) + + await user.click(screen.getByRole('button', { name: 'Delete link' })) + await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'DELETE' }))) + expect(screen.getByRole('button', { name: 'Create link' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Download ICS file' })) await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object))) + expect(createObjURL).toHaveBeenCalled() + expect(revokeObjURL).toHaveBeenCalledWith('blob:mock') + fetchSpy.mockRestore() + clipboardSpy.mockRestore() + createObjURL.mockRestore() + revokeObjURL.mockRestore() + }) + + it('FE-PLANNER-DAYPLAN-097: opening ICS dialog shows existing generated link when present', async () => { + const user = userEvent.setup() + const expectedUrl = `${window.location.origin}/api/shared/existing-token/calendar.ics` + const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined) + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ token: 'existing-token' }), + } as any) + .mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), + } as any) + const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') + const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + + render() + await user.click(screen.getByText('ICS').closest('button')!) + + await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object))) + expect(await screen.findByDisplayValue(expectedUrl)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create link' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Copy' })) + await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith(expectedUrl)) + + await user.click(screen.getByRole('button', { name: 'Download ICS file' })) + await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object))) + expect(createObjURL).toHaveBeenCalled() + expect(revokeObjURL).toHaveBeenCalledWith('blob:mock') + expect(fetchSpy).not.toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' })) + + fetchSpy.mockRestore() + clipboardSpy.mockRestore() createObjURL.mockRestore() revokeObjURL.mockRestore() }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index b1eda2d3..4edcd1d9 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useRef, useMemo } from 'react' import ReactDOM from 'react-dom' -import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon, Link2, Copy } from 'lucide-react' const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } import { assignmentsApi, reservationsApi } from '../../api/client' @@ -252,6 +252,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } const [dragOverDayId, setDragOverDayId] = useState(null) const [transportDetail, setTransportDetail] = useState(null) + const [icsDialog, setIcsDialog] = useState<{ url: string; webcal_url: string; creating: boolean } | null>(null) + const [icsCopied, setIcsCopied] = useState(false) + const icsCopyTimerRef = useRef | null>(null) const [transportPosVersion, setTransportPosVersion] = useState(0) useEffect(() => { @@ -284,6 +287,99 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const currency = trip?.currency || 'EUR' + useEffect(() => { + return () => { + if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current) + } + }, []) + + const closeIcsDialog = () => setIcsDialog(null) + + const handleIcsOpenDialog = async () => { + setIcsCopied(false) + setIcsDialog({ url: '', webcal_url: '', creating: true }) + try { + const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { credentials: 'include' }) + if (!res.ok) throw new Error() + const data = await res.json() as { token?: string | null } + if (data.token) { + const url = `${window.location.origin}/api/shared/${encodeURIComponent(data.token)}/calendar.ics` + const webcal_url = url.replace(/^https?:\/\//, 'webcal://') + setIcsDialog({ url, webcal_url, creating: false }) + } else { + setIcsDialog({ url: '', webcal_url: '', creating: false }) + } + } catch { + setIcsDialog({ url: '', webcal_url: '', creating: false }) + toast.error(t('dayplan.calendarLinkFailed')) + } + } + + const handleIcsCreateLink = async () => { + if (icsDialog?.creating) return + setIcsDialog(prev => prev ? { ...prev, creating: true } : { url: '', webcal_url: '', creating: true }) + try { + const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { method: 'POST', credentials: 'include' }) + if (!res.ok) throw new Error() + const data = await res.json() as { url?: string; webcal_url?: string } + const shareUrl = data.url + const openUrl = data.webcal_url || data.url + if (!shareUrl || !openUrl) throw new Error() + setIcsDialog({ url: shareUrl, webcal_url: openUrl, creating: false }) + } catch { + setIcsDialog(prev => prev ? { ...prev, creating: false } : prev) + toast.error(t('dayplan.calendarLinkFailed')) + } + } + + const handleIcsCopyLink = async () => { + if (!icsDialog?.url) return + try { + await navigator.clipboard.writeText(icsDialog.url) + setIcsCopied(true) + if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current) + icsCopyTimerRef.current = setTimeout(() => setIcsCopied(false), 2000) + } catch { + toast.error(t('dayplan.calendarCopyFailed')) + } + } + + const handleIcsDeleteLink = async () => { + if (!icsDialog || icsDialog.creating) return + setIcsDialog(prev => prev ? { ...prev, creating: true } : prev) + try { + const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { + method: 'DELETE', + credentials: 'include', + }) + if (!res.ok) throw new Error() + setIcsCopied(false) + setIcsDialog(prev => prev ? { ...prev, url: '', webcal_url: '', creating: false } : prev) + toast.success(t('dayplan.calendarLinkDeleted')) + } catch { + setIcsDialog(prev => prev ? { ...prev, creating: false } : prev) + toast.error(t('dayplan.calendarDeleteFailed')) + } + } + + const handleIcsDownload = async () => { + try { + const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' }) + if (!res.ok) throw new Error() + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${trip?.title || 'trip'}.ics` + a.click() + URL.revokeObjectURL(url) + toast.success(t('dayplan.calendarDownloaded')) + closeIcsDialog() + } catch { + toast.error(t('dayplan.calendarExportFailed')) + } + } + // Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren) const getDragData = (e) => { const dt = e?.dataTransfer @@ -993,21 +1089,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ { - try { - const res = await fetch(`/api/trips/${tripId}/export.ics`, { - credentials: 'include', - }) - if (!res.ok) throw new Error() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${trip?.title || 'trip'}.ics` - a.click() - URL.revokeObjectURL(url) - } catch { toast.error(t('planner.icsExportFailed')) } - }} + onClick={handleIcsOpenDialog} onMouseEnter={() => setIcsHover(true)} onMouseLeave={() => setIcsHover(false)} style={{ @@ -2128,6 +2210,101 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ document.body )} + {/* ICS subscription dialog */} + {icsDialog && ReactDOM.createPortal( + + e.stopPropagation()}> + + + + + + + {t('dayplan.calendarShareTitle')} + + + + {t('dayplan.calendarShareDescription')} + + {icsDialog.url ? ( + + + + + {icsCopied ? <> {t('common.copied')}> : <> {t('common.copy')}>} + + + + {t('dayplan.calendarDeleteLink')} + + + ) : ( + + {t('dayplan.calendarCreateLink')} + + )} + + {t('dayplan.calendarDownloadFile')} + + + , + document.body + )} + {/* Transport-Detail-Modal */} {transportDetail && ReactDOM.createPortal( = { 'settings.notificationsActive': 'Active channel', 'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.', 'dayplan.icsTooltip': 'Export calendar (ICS)', + 'dayplan.calendarShareTitle': 'Calendar share', + 'dayplan.calendarShareDescription': 'Create a subscription link for calendar apps, or download the ICS file.', + 'dayplan.calendarCreateLink': 'Create link', + 'dayplan.calendarDeleteLink': 'Delete link', + 'dayplan.calendarDownloadFile': 'Download ICS file', + 'dayplan.calendarLinkFailed': 'Calendar link failed', + 'dayplan.calendarDeleteFailed': 'Delete link failed', + 'dayplan.calendarCopyFailed': 'Copy failed', + 'dayplan.calendarDownloaded': 'ICS downloaded', + 'dayplan.calendarExportFailed': 'ICS export failed', + 'dayplan.calendarLinkDeleted': 'Calendar link deleted', 'share.linkTitle': 'Public Link', 'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.', 'share.createLink': 'Create link', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 29640339..429c4aa7 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2130,6 +2130,22 @@ function runMigrations(db: Database.Database): void { 'ON journey_entries(journey_id, entry_date, sort_order)' ); }, + () => { + // Dedicated calendar subscription tokens for trips + db.exec(` + CREATE TABLE IF NOT EXISTS calendar_share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id), + UNIQUE(trip_id) + ) + `); + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_calendar_share_trip ON calendar_share_tokens(trip_id)'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 5cf49e79..66475c1d 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -202,6 +202,15 @@ function createTables(db: Database.Database): void { UNIQUE(trip_id, user_id) ); + CREATE TABLE IF NOT EXISTS calendar_share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id) + ); + CREATE TABLE IF NOT EXISTS day_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts index 9e8145fd..fe0c37d5 100644 --- a/server/src/routes/share.ts +++ b/server/src/routes/share.ts @@ -58,4 +58,16 @@ router.get('/shared/:token', (req: Request, res: Response) => { res.json(data); }); +// Public calendar subscription payload (no auth required) +router.get('/shared/:token/calendar.ics', (req: Request, res: Response) => { + const { token } = req.params; + const exported = shareService.getSharedTripICS(token); + if (!exported) return res.status(404).json({ error: 'Invalid or expired link' }); + + res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); + // Inline lets calendar clients subscribe/fetch from URL instead of forced download. + res.setHeader('Content-Disposition', `inline; filename="${exported.filename}"`); + res.send(exported.ics); +}); + export default router; diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index 9b2413ba..5397194d 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -36,6 +36,7 @@ import { listItems as listTodoItems } from '../services/todoService'; import { listBudgetItems } from '../services/budgetService'; import { listReservations } from '../services/reservationService'; import { listFiles } from '../services/fileService'; +import { createOrUpdateCalendarShareLink, getCalendarShareLink, deleteCalendarShareLink } from '../services/shareService'; const router = express.Router(); @@ -355,4 +356,57 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { } }); +// ── ICS calendar subscription link ─────────────────────────────────────── + +router.get('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.id, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const existing = getCalendarShareLink(req.params.id); + const token = existing?.token ?? null; + + const host = req.get('host'); + if (!host) return res.status(500).json({ error: 'Host header missing' }); + + const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' + ? req.headers['x-forwarded-proto'].split(',')[0].trim() + : null; + const protocol = forwardedProto || req.protocol; + const url = token ? `${protocol}://${host}/api/shared/${encodeURIComponent(token)}/calendar.ics` : null; + const webcal_url = url ? url.replace(/^https?:\/\//, 'webcal://') : null; + + res.json({ url, webcal_url, token, created: false }); +}); + +router.post('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.id, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const result = createOrUpdateCalendarShareLink(req.params.id, authReq.user.id); + const host = req.get('host'); + if (!host) return res.status(500).json({ error: 'Host header missing' }); + + const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' + ? req.headers['x-forwarded-proto'].split(',')[0].trim() + : null; + const protocol = forwardedProto || req.protocol; + const url = `${protocol}://${host}/api/shared/${encodeURIComponent(result.token)}/calendar.ics`; + const webcal_url = url.replace(/^https?:\/\//, 'webcal://'); + + res.status(result.created ? 201 : 200).json({ url, webcal_url, token: result.token, created: result.created }); +}); + +router.delete('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const access = canAccessTrip(req.params.id, authReq.user.id); + if (!access) return res.status(404).json({ error: 'Trip not found' }); + + deleteCalendarShareLink(req.params.id); + res.json({ success: true }); +}); + export default router; diff --git a/server/src/services/shareService.ts b/server/src/services/shareService.ts index abcf63fb..87f7ac24 100644 --- a/server/src/services/shareService.ts +++ b/server/src/services/shareService.ts @@ -1,6 +1,7 @@ import { db, canAccessTrip } from '../db/database'; import crypto from 'crypto'; import { loadTagsByPlaceIds } from './queryHelpers'; +import { exportICS } from './tripService'; interface SharePermissions { share_map?: boolean; @@ -20,6 +21,11 @@ interface ShareTokenInfo { share_collab: boolean; } +interface CalendarShareTokenInfo { + token: string; + created_at: string; +} + /** * Creates a new share link or updates the permissions on an existing one. * Returns an object with the token string and whether it was newly created. @@ -79,6 +85,57 @@ export function deleteShareLink(tripId: string): void { db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId); } +/** + * Creates or returns a dedicated calendar subscription link for a trip. + */ +export function createOrUpdateCalendarShareLink( + tripId: string, + createdBy: number, +): { token: string; created: boolean } { + const existing = db.prepare('SELECT token FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined; + if (existing) { + return { token: existing.token, created: false }; + } + + const token = crypto.randomBytes(24).toString('base64url'); + db.prepare('INSERT INTO calendar_share_tokens (trip_id, token, created_by) VALUES (?, ?, ?)') + .run(tripId, token, createdBy); + return { token, created: true }; +} + +/** + * Returns the calendar subscription link for a trip, or null if none exists. + */ +export function getCalendarShareLink(tripId: string): CalendarShareTokenInfo | null { + const row = db.prepare('SELECT * FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as any; + if (!row) return null; + return { + token: row.token, + created_at: row.created_at, + }; +} + +/** + * Deletes the calendar subscription link for a trip. + */ +export function deleteCalendarShareLink(tripId: string): void { + db.prepare('DELETE FROM calendar_share_tokens WHERE trip_id = ?').run(tripId); +} + +/** + * Resolves a shared token to ICS calendar content. + * Returns null when token or trip is invalid. + */ +export function getSharedTripICS(token: string): { ics: string; filename: string } | null { + const shareRow = db.prepare('SELECT trip_id FROM calendar_share_tokens WHERE token = ?').get(token) as { trip_id: number } | undefined; + if (!shareRow) return null; + try { + return exportICS(shareRow.trip_id); + } catch { + return null; + } +} + /** * Loads the full public trip data for a share token, filtered by the token's * permission flags. Returns null if the token is invalid or the trip is gone. diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts index 5460ef31..0e5602c1 100644 --- a/server/tests/integration/share.test.ts +++ b/server/tests/integration/share.test.ts @@ -204,6 +204,24 @@ describe('Shared trip access', () => { .send({}); expect(res.status).toBe(404); }); + + it('SHARE-009 — GET /shared/:token/calendar.ics returns public calendar payload', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Rome Calendar' }); + + const create = await request(app) + .post(`/api/trips/${trip.id}/subscribe.ics`) + .set('Host', 'trek.example.com') + .set('Cookie', authCookie(user.id)) + .send({}); + const token = create.body.token; + + const res = await request(app).get(`/api/shared/${token}/calendar.ics`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/calendar/); + expect(res.text).toContain('BEGIN:VCALENDAR'); + expect(res.text).toContain('END:VCALENDAR'); + }); }); describe('Shared trip — day assignments and notes', () => { diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index f24905b1..adce22d4 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -855,6 +855,72 @@ describe('ICS export', () => { const res = await request(app).get(`/api/trips/${trip.id}/export.ics`); expect(res.status).toBe(401); }); + + it('TRIP-025 — GET /api/trips/:id/subscribe.ics returns null before a calendar link exists', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' }); + + const res = await request(app) + .get(`/api/trips/${trip.id}/subscribe.ics`) + .set('Host', 'trek.example.com') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.url).toBeNull(); + expect(res.body.webcal_url).toBeNull(); + expect(res.body.token).toBeNull(); + }); + + it('TRIP-025 — POST /api/trips/:id/subscribe.ics creates shareable http+webcal links', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/subscribe.ics`) + .set('Host', 'trek.example.com') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(201); + expect(res.body.url).toMatch(/^http:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/); + expect(res.body.webcal_url).toMatch(/^webcal:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/); + expect(typeof res.body.token).toBe('string'); + }); + + + it('TRIP-025 — DELETE /api/trips/:id/subscribe.ics removes calendar token', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Delete Calendar Token' }); + await request(app) + .post(`/api/trips/${trip.id}/subscribe.ics`) + .set('Host', 'trek.example.com') + .set('Cookie', authCookie(user.id)); + + const del = await request(app) + .delete(`/api/trips/${trip.id}/subscribe.ics`) + .set('Cookie', authCookie(user.id)); + + expect(del.status).toBe(200); + expect(del.body.success).toBe(true); + + const status = await request(app) + .get(`/api/trips/${trip.id}/subscribe.ics`) + .set('Host', 'trek.example.com') + .set('Cookie', authCookie(user.id)); + expect(status.body.token).toBeNull(); + + }); + + it('TRIP-025 — non-member cannot get subscribe link → 404', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { title: 'Private Trip' }); + + const res = await request(app) + .get(`/api/trips/${trip.id}/subscribe.ics`) + .set('Cookie', authCookie(stranger.id)); + + expect(res.status).toBe(404); + }); }); // ─────────────────────────────────────────────────────────────────────────────
+ {t('dayplan.calendarShareDescription')} +