import { describe, expect, it } from "vitest"; import { createDavFixture, davRequest } from "@tests/worker/helpers/dav"; import { fetchWorker } from "@tests/worker/helpers/http"; import { withEnvBinding } from "@tests/worker/helpers/env"; import { event, mkcolCalendarBody, propfindSyncToken, syncCollectionBody, syncToken, uid } from "./helpers"; describe("CalDAV sync collection", () => { it("accepts sync-collection REPORT with Depth 1 as Depth 0 compatibility", async () => { const fixture = await createDavFixture(["caldav.full"]); const response = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml", depth: "1" }, body: syncCollectionBody(""), }), ); expect(response.status).toBe(207); expect(await response.text()).toContain(""); }); it("rejects sync-collection REPORT with malformed Depth", async () => { const fixture = await createDavFixture(["caldav.full"]); const response = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml", depth: "garbage" }, body: syncCollectionBody(""), }), ); expect(response.status).toBe(400); await expect(response.text()).resolves.toContain("REPORT Depth must be 0, 1, or infinity"); }); it("returns requested properties for sync-collection current and changed members without stale replay", async () => { const fixture = await createDavFixture(["caldav.full"]); const eventUid = uid(); expect( await fetchWorker( davRequest(fixture, "/calendars/default/sync-props.ics", { method: "PUT", body: event({ uid: eventUid, summary: "Sync Props" }), }), ), ).toMatchObject({ status: 201 }); const initial = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml" }, body: syncCollectionBody("", ""), }), ); expect(initial.status).toBe(207); const initialXml = await initial.text(); expect(initialXml).toContain("/calendars/default/sync-props.ics"); expect(initialXml).toContain(`UID:${eventUid}`); const token = /([^<]+)<\/D:sync-token>/.exec(initialXml)?.[1]; expect(token).toBeTruthy(); const repeated = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml" }, body: syncCollectionBody(token!), }), ); expect(repeated.status).toBe(207); expect((await repeated.text()).match(//g)?.length ?? 0).toBe(0); expect( await fetchWorker( davRequest(fixture, "/calendars/default/sync-props.ics", { method: "PUT", body: event({ uid: eventUid, summary: "Sync Props Updated" }), }), ), ).toMatchObject({ status: 204 }); const changed = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml" }, body: syncCollectionBody(token!, ""), }), ); expect(changed.status).toBe(207); const changedXml = await changed.text(); expect(changedXml).toContain("/calendars/default/sync-props.ics"); expect(changedXml).toContain("Sync Props Updated"); const changedToken = /([^<]+)<\/D:sync-token>/.exec(changedXml)?.[1]; expect(changedToken).toBeTruthy(); const otherCalendar = `other-${crypto.randomUUID()}`; expect( await fetchWorker( davRequest(fixture, `/calendars/${otherCalendar}/`, { method: "MKCOL", headers: { "content-type": "application/xml" }, body: mkcolCalendarBody("Other"), }), ), ).toMatchObject({ status: 201 }); expect( await fetchWorker( davRequest(fixture, `/calendars/${otherCalendar}/other.ics`, { method: "PUT", body: event({ uid: uid(), summary: "Other Calendar" }), }), ), ).toMatchObject({ status: 201 }); const afterOtherCalendarChange = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml" }, body: syncCollectionBody(changedToken!), }), ); expect(afterOtherCalendarChange.status).toBe(207); expect((await afterOtherCalendarChange.text()).match(//g)?.length ?? 0).toBe(0); const propfindToken = await propfindSyncToken(fixture); const syncFromPropfindToken = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml" }, body: syncCollectionBody(propfindToken), }), ); expect(syncFromPropfindToken.status).toBe(207); expect((await syncFromPropfindToken.text()).match(//g)?.length ?? 0).toBe(0); }); it("reports sync-collection tombstones", async () => { const fixture = await createDavFixture(["caldav.full"]); const baselineToken = await syncToken(fixture); expect( await fetchWorker( davRequest(fixture, "/calendars/default/sync.ics", { method: "PUT", body: event({ uid: uid(), summary: "Sync Me" }), }), ), ).toMatchObject({ status: 201 }); expect(await fetchWorker(davRequest(fixture, "/calendars/default/sync.ics", { method: "DELETE" }))).toMatchObject({ status: 204, }); const sync = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml" }, body: syncCollectionBody(baselineToken), }), ); expect(sync.status).toBe(207); const xml = await sync.text(); expect(xml).not.toContain(`${baselineToken}`); expect(xml).toContain("/calendars/default/sync.ics"); expect(xml).toContain("HTTP/1.1 404 Not Found"); }); it("marks truncated sync-collection responses with a 507 response", async () => { const fixture = await createDavFixture(["caldav.full"]); expect( await fetchWorker( davRequest(fixture, `/calendars/default/truncated-a-${crypto.randomUUID()}.ics`, { method: "PUT", body: event({ uid: uid(), summary: "Truncated A" }), }), ), ).toMatchObject({ status: 201 }); expect( await fetchWorker( davRequest(fixture, `/calendars/default/truncated-b-${crypto.randomUUID()}.ics`, { method: "PUT", body: event({ uid: uid(), summary: "Truncated B" }), }), ), ).toMatchObject({ status: 201 }); await withEnvBinding("MAX_REPORT_RESULTS", "1", async () => { const sync = await fetchWorker( davRequest(fixture, "/calendars/default/", { method: "REPORT", headers: { "content-type": "application/xml" }, body: syncCollectionBody(""), }), ); expect(sync.status).toBe(207); const xml = await sync.text(); expect(xml).toContain("HTTP/1.1 507 Insufficient Storage"); expect(xml).toContain("sync:0"); }); }); });