Skip to content

Commit 08a2f59

Browse files
committed
fix: use event.href for URL in sync-collection responses
Use event.href (the original resource path) when available, falling back to constructing URL from event.eventId for backwards compatibility. This ensures sync-collection responses return the URL that clients expect, fixing sync failures where deleted events couldn't be matched to local cache. Includes 10 new unit tests for event-response URL construction. See: https://www.rfc-editor.org/rfc/rfc6578.html (sync-collection )
1 parent fdc96bd commit 08a2f59

File tree

2 files changed

+320
-1
lines changed

2 files changed

+320
-1
lines changed

routes/calendar/calendar/event-response.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,22 @@ module.exports = function (options) {
3636
// MUST NOT contain any DAV:propstat element.
3737
//
3838
const pRes = await Promise.all(propActions);
39-
const url = path.join(ctx.url, `${event.eventId}.ics`);
39+
40+
//
41+
// Construct the event URL for the response.
42+
//
43+
// The URL must match the original resource path that the client used when
44+
// creating the event. This is critical for sync-collection responses where
45+
// deleted events are reported with a 404 status - the client needs to match
46+
// the URL to its local cache to know which event to remove.
47+
//
48+
// Priority order:
49+
// 1. event.href - the original resource path (if stored by the data layer)
50+
// 2. Constructed from event.eventId - fallback for backwards compatibility
51+
//
52+
// See: https://www.rfc-editor.org/rfc/rfc6578.html (sync-collection)
53+
//
54+
const url = event.href || path.join(ctx.url, `${event.eventId}.ics`);
4055
const resp = event.deleted_at
4156
? response(url, status[404], [], true)
4257
: response(url, status[200], _.compact(pRes));

test/event-response.test.js

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
const test = require('ava');
2+
const eventResponseFactory = require('../routes/calendar/calendar/event-response');
3+
4+
// Mock options with minimal required data functions
5+
function createMockOptions() {
6+
return {
7+
data: {
8+
getCalendarId: () => 'test-calendar-id',
9+
buildICS: () => 'BEGIN:VCALENDAR\nEND:VCALENDAR',
10+
getETag: () => '"test-etag"'
11+
}
12+
};
13+
}
14+
15+
// Mock context
16+
function createMockCtx(url = '/cal/user/calendar') {
17+
return {
18+
url,
19+
state: {
20+
params: {
21+
principalId: 'user',
22+
calendarId: 'calendar'
23+
}
24+
}
25+
};
26+
}
27+
28+
// Mock calendar
29+
function createMockCalendar() {
30+
return {
31+
_id: 'calendar-id',
32+
name: 'Test Calendar',
33+
synctoken: 'http://example.com/sync/1'
34+
};
35+
}
36+
37+
test('event-response exports a function', (t) => {
38+
const options = createMockOptions();
39+
const eventResponse = eventResponseFactory(options);
40+
t.is(typeof eventResponse, 'function');
41+
});
42+
43+
test('event-response uses eventId to construct URL when href is not available', async (t) => {
44+
const options = createMockOptions();
45+
const eventResponse = eventResponseFactory(options);
46+
const ctx = createMockCtx('/cal/user/calendar');
47+
const calendar = createMockCalendar();
48+
49+
const events = [
50+
{
51+
eventId: 'test-event-123',
52+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
53+
}
54+
];
55+
56+
// Empty children array - we just want to test URL construction
57+
const children = [];
58+
59+
const result = await eventResponse(ctx, events, calendar, children);
60+
61+
t.truthy(result.responses);
62+
t.is(result.responses.length, 1);
63+
64+
// Check that the response contains the correct URL
65+
const responseObj = result.responses[0];
66+
t.truthy(responseObj['D:href']);
67+
t.is(responseObj['D:href'], '/cal/user/calendar/test-event-123.ics');
68+
});
69+
70+
test('event-response uses event.href when available instead of constructing from eventId', async (t) => {
71+
const options = createMockOptions();
72+
const eventResponse = eventResponseFactory(options);
73+
const ctx = createMockCtx('/cal/user/calendar');
74+
const calendar = createMockCalendar();
75+
76+
// Event with href property (original resource path)
77+
const events = [
78+
{
79+
eventId: 'modified_event_id',
80+
href: '/cal/user/calendar/original@event.ics',
81+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
82+
}
83+
];
84+
85+
const children = [];
86+
87+
const result = await eventResponse(ctx, events, calendar, children);
88+
89+
t.truthy(result.responses);
90+
t.is(result.responses.length, 1);
91+
92+
// Check that the response uses href, not eventId
93+
const responseObj = result.responses[0];
94+
t.truthy(responseObj['D:href']);
95+
t.is(responseObj['D:href'], '/cal/user/calendar/original@event.ics');
96+
});
97+
98+
test('event-response returns 404 status for deleted events', async (t) => {
99+
const options = createMockOptions();
100+
const eventResponse = eventResponseFactory(options);
101+
const ctx = createMockCtx('/cal/user/calendar');
102+
const calendar = createMockCalendar();
103+
104+
const events = [
105+
{
106+
eventId: 'deleted-event',
107+
deleted_at: new Date(),
108+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
109+
}
110+
];
111+
112+
const children = [];
113+
114+
const result = await eventResponse(ctx, events, calendar, children);
115+
116+
t.truthy(result.responses);
117+
t.is(result.responses.length, 1);
118+
119+
const responseObj = result.responses[0];
120+
t.truthy(responseObj['D:href']);
121+
t.is(responseObj['D:href'], '/cal/user/calendar/deleted-event.ics');
122+
123+
// Check for 404 status
124+
t.truthy(responseObj['D:status']);
125+
t.true(responseObj['D:status'].includes('404'));
126+
});
127+
128+
test('event-response uses href for deleted events when available (critical for sync)', async (t) => {
129+
const options = createMockOptions();
130+
const eventResponse = eventResponseFactory(options);
131+
const ctx = createMockCtx('/cal/user/calendar');
132+
const calendar = createMockCalendar();
133+
134+
// Deleted event with href - simulates the case where eventId was modified
135+
// (e.g., @ replaced with _) but we stored the original href
136+
const events = [
137+
{
138+
eventId: 'event123_example.com',
139+
href: '/cal/user/calendar/event123@example.com.ics',
140+
deleted_at: new Date(),
141+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
142+
}
143+
];
144+
145+
const children = [];
146+
147+
const result = await eventResponse(ctx, events, calendar, children);
148+
149+
t.truthy(result.responses);
150+
t.is(result.responses.length, 1);
151+
152+
const responseObj = result.responses[0];
153+
// Should use href, not eventId
154+
t.is(responseObj['D:href'], '/cal/user/calendar/event123@example.com.ics');
155+
t.truthy(responseObj['D:status']);
156+
t.true(responseObj['D:status'].includes('404'));
157+
});
158+
159+
test('event-response returns 200 status for non-deleted events', async (t) => {
160+
const options = createMockOptions();
161+
const eventResponse = eventResponseFactory(options);
162+
const ctx = createMockCtx('/cal/user/calendar');
163+
const calendar = createMockCalendar();
164+
165+
const events = [
166+
{
167+
eventId: 'active-event',
168+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
169+
}
170+
];
171+
172+
const children = [];
173+
174+
const result = await eventResponse(ctx, events, calendar, children);
175+
176+
t.truthy(result.responses);
177+
t.is(result.responses.length, 1);
178+
179+
const responseObj = result.responses[0];
180+
// Non-deleted events should have propstat, not status
181+
t.truthy(responseObj['D:propstat']);
182+
});
183+
184+
test('event-response handles multiple events correctly', async (t) => {
185+
const options = createMockOptions();
186+
const eventResponse = eventResponseFactory(options);
187+
const ctx = createMockCtx('/cal/user/calendar');
188+
const calendar = createMockCalendar();
189+
190+
const events = [
191+
{
192+
eventId: 'event1',
193+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
194+
},
195+
{
196+
eventId: 'event2',
197+
href: '/cal/user/calendar/custom-path.ics',
198+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
199+
},
200+
{
201+
eventId: 'event3',
202+
deleted_at: new Date(),
203+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
204+
}
205+
];
206+
207+
const children = [];
208+
209+
const result = await eventResponse(ctx, events, calendar, children);
210+
211+
t.truthy(result.responses);
212+
t.is(result.responses.length, 3);
213+
214+
// Event 1: uses eventId
215+
t.is(result.responses[0]['D:href'], '/cal/user/calendar/event1.ics');
216+
217+
// Event 2: uses href
218+
t.is(result.responses[1]['D:href'], '/cal/user/calendar/custom-path.ics');
219+
220+
// Event 3: deleted, uses eventId (no href)
221+
t.is(result.responses[2]['D:href'], '/cal/user/calendar/event3.ics');
222+
t.true(result.responses[2]['D:status'].includes('404'));
223+
});
224+
225+
test('event-response handles email-like eventId with @ symbol', async (t) => {
226+
const options = createMockOptions();
227+
const eventResponse = eventResponseFactory(options);
228+
const ctx = createMockCtx('/cal/user/calendar');
229+
const calendar = createMockCalendar();
230+
231+
// Event with email-like eventId (contains @)
232+
const events = [
233+
{
234+
eventId: 'meeting@company.com',
235+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
236+
}
237+
];
238+
239+
const children = [];
240+
241+
const result = await eventResponse(ctx, events, calendar, children);
242+
243+
t.truthy(result.responses);
244+
t.is(result.responses.length, 1);
245+
246+
// Should preserve the @ in the URL
247+
t.is(
248+
result.responses[0]['D:href'],
249+
'/cal/user/calendar/meeting@company.com.ics'
250+
);
251+
});
252+
253+
test('event-response handles special characters in eventId', async (t) => {
254+
const options = createMockOptions();
255+
const eventResponse = eventResponseFactory(options);
256+
const ctx = createMockCtx('/cal/user/calendar');
257+
const calendar = createMockCalendar();
258+
259+
// Event with special characters in eventId
260+
const events = [
261+
{
262+
eventId: 'event-with-special_chars.123',
263+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
264+
}
265+
];
266+
267+
const children = [];
268+
269+
const result = await eventResponse(ctx, events, calendar, children);
270+
271+
t.truthy(result.responses);
272+
t.is(result.responses.length, 1);
273+
274+
t.is(
275+
result.responses[0]['D:href'],
276+
'/cal/user/calendar/event-with-special_chars.123.ics'
277+
);
278+
});
279+
280+
test('backwards compatibility: events without href use eventId', async (t) => {
281+
const options = createMockOptions();
282+
const eventResponse = eventResponseFactory(options);
283+
const ctx = createMockCtx('/cal/user/calendar');
284+
const calendar = createMockCalendar();
285+
286+
// Simulate existing event without href field (backwards compatibility)
287+
const events = [
288+
{
289+
eventId: 'legacy-event-id',
290+
ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
291+
// No href field - simulates existing events before this fix
292+
}
293+
];
294+
295+
const children = [];
296+
297+
const result = await eventResponse(ctx, events, calendar, children);
298+
299+
t.truthy(result.responses);
300+
t.is(result.responses.length, 1);
301+
302+
// Should fall back to constructing URL from eventId
303+
t.is(result.responses[0]['D:href'], '/cal/user/calendar/legacy-event-id.ics');
304+
});

0 commit comments

Comments
 (0)