diff --git a/calendar/test/CalDAVServer.jsm b/calendar/test/CalDAVServer.jsm
new file mode 100644
index 0000000000..08901c0741
--- /dev/null
+++ b/calendar/test/CalDAVServer.jsm
@@ -0,0 +1,438 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalDAVServer"];
+
+Cu.importGlobalProperties(["crypto"]);
+
+const PREFIX_BINDINGS = {
+ c: "urn:ietf:params:xml:ns:caldav",
+ cs: "http://calendarserver.org/ns/",
+ d: "DAV:",
+};
+const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS)
+ .map(([prefix, url]) => `xmlns:${prefix}="${url}"`)
+ .join(" ");
+
+const { Assert } = ChromeUtils.import("resource://testing-common/Assert.jsm");
+const { CommonUtils } = ChromeUtils.import("resource://services-common/utils.js");
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var CalDAVServer = {
+ items: new Map(),
+ deletedItems: new Map(),
+ changeCount: 0,
+ server: null,
+ isOpen: false,
+
+ open(username, password) {
+ this.server = new HttpServer();
+ this.server.start(-1);
+ this.isOpen = true;
+
+ this.username = username;
+ this.password = password;
+ this.server.registerPathHandler("/ping", this.ping);
+
+ this.reset();
+ },
+
+ reset() {
+ this.items.clear();
+ this.deletedItems.clear();
+ this.changeCount = 0;
+ this.resetHandlers();
+ },
+
+ resetHandlers() {
+ this.server.registerPathHandler(this.path, this.directoryHandler.bind(this));
+ this.server.registerPrefixHandler(this.path, this.itemHandler.bind(this));
+ },
+
+ close() {
+ if (!this.isOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve =>
+ this.server.stop({
+ onStopped: () => {
+ this.isOpen = false;
+ resolve();
+ },
+ })
+ );
+ },
+
+ get origin() {
+ return `http://localhost:${this.server.identity.primaryPort}`;
+ },
+
+ get path() {
+ return "/calendars/me/test/";
+ },
+
+ get url() {
+ return `${this.origin}${this.path}`;
+ },
+
+ get altPath() {
+ return "/addressbooks/me/default/";
+ },
+
+ get altURL() {
+ return `${this.origin}${this.altPath}`;
+ },
+
+ checkAuth(request, response) {
+ if (!this.username || !this.password) {
+ return true;
+ }
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let value = request.getHeader("Authorization");
+ if (!value.startsWith("Basic ")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let [username, password] = atob(value.substring(6)).split(":");
+ if (username != this.username || password != this.password) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ return true;
+ },
+
+ ping(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("pong");
+ },
+
+ /** Handle any requests to the calendar itself. */
+
+ directoryHandler(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (request.method == "OPTIONS") {
+ response.setStatusLine("1.1", 204, "No Content");
+ return;
+ }
+
+ let input = new DOMParser().parseFromString(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream),
+ "text/xml"
+ );
+
+ switch (input.documentElement.localName) {
+ case "calendar-query":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
+ this.calendarQuery(input, response);
+ return;
+ case "calendar-multiget":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
+ this.calendarMultiGet(input, response);
+ return;
+ case "propfind":
+ Assert.equal(request.method, "PROPFIND");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
+ this.propFind(input, request.hasHeader("Depth") ? request.getHeader("Depth") : 0, response);
+ return;
+ case "sync-collection":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
+ this.syncCollection(input, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`No handler found for <${input.documentElement.localName}>`);
+ },
+
+ calendarQuery(input, response) {
+ let propNames = this._inputProps(input);
+ let output = ``;
+ for (let [href, item] of this.items) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ output += ``;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+<"));
+ },
+
+ async calendarMultiGet(input, response) {
+ let propNames = this._inputProps(input);
+ let output = ``;
+ for (let href of input.querySelectorAll("href")) {
+ href = href.textContent;
+ let item = this.items.get(href);
+ if (item) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ }
+ output += ``;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+<"));
+ },
+
+ propFind(input, depth, response) {
+ let propNames = this._inputProps(input);
+
+ let propValues = {
+ "d:resourcetype": "",
+ "d:owner": "/principals/me/",
+ "d:current-user-principal": "/principals/me/",
+ "d:supported-report-set":
+ "",
+ "c:supported-calendar-component-set": "",
+ "d:getcontenttype": "text/calendar; charset=utf-8",
+
+ "c:calendar-home-set": `/calendars/me/`,
+ "c:calendar-user-address-set": `mailto:me@invalid`,
+ "c:schedule-inbox-url": `/calendars/me/inbox/`,
+ "c:schedule-outbox-url": `/calendars/me/outbox/`,
+ "cs:getctag": this.changeCount,
+ "d:getetag": this.changeCount,
+ };
+
+ let output = `
+
+ ${this.path}
+ ${this._outputProps(propNames, propValues)}
+ `;
+ if (depth == 1) {
+ for (let [href, item] of this.items) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ }
+ output += ``;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+<"));
+ },
+
+ syncCollection(input, response) {
+ let token = input.querySelector("sync-token").textContent.replace(/\D/g, "");
+ let propNames = this._inputProps(input);
+
+ let output = ``;
+ for (let [href, item] of this.items) {
+ if (item.changed > token) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ }
+ for (let [href, deleted] of this.deletedItems) {
+ if (deleted > token) {
+ output += `
+ HTTP/1.1 404 Not Found
+ ${href}
+
+
+ HTTP/1.1 418 I'm a teapot
+
+ `;
+ }
+ }
+ output += `http://mochi.test/sync/${this.changeCount}
+ `;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+<"));
+ },
+
+ _itemResponse(href, item, propNames) {
+ let propValues = {
+ "c:calendar-data": item.ics,
+ "d:getetag": item.etag,
+ "d:getcontenttype": "text/calendar; charset=utf-8; component=VEVENT",
+ };
+
+ let outString = `
+ ${href}
+ ${this._outputProps(propNames, propValues)}
+ `;
+ return outString;
+ },
+
+ _inputProps(input) {
+ let props = input.querySelectorAll("prop > *");
+ let propNames = [];
+
+ for (let p of props) {
+ Assert.equal(p.childElementCount, 0);
+ switch (p.localName) {
+ case "calendar-home-set":
+ case "calendar-user-address-set":
+ case "schedule-inbox-URL":
+ case "schedule-outbox-URL":
+ case "supported-calendar-component-set":
+ case "calendar-data":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.c);
+ propNames.push(`c:${p.localName}`);
+ break;
+ case "getctag":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.cs);
+ propNames.push(`cs:${p.localName}`);
+ break;
+ case "getetag":
+ case "owner":
+ case "current-user-principal":
+ case "supported-report-set":
+ case "displayname":
+ case "resourcetype":
+ case "sync-token":
+ case "getcontenttype":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.d);
+ propNames.push(`d:${p.localName}`);
+ break;
+ default:
+ Assert.report(true, undefined, undefined, `Unknown property requested: ${p.nodeName}`);
+ break;
+ }
+ }
+
+ return propNames;
+ },
+
+ _outputProps(propNames, propValues) {
+ let output = "";
+
+ let found = [];
+ let notFound = [];
+ for (let p of propNames) {
+ if (p in propValues) {
+ found.push(`<${p}>${propValues[p]}${p}>`);
+ } else {
+ notFound.push(`<${p}/>`);
+ }
+ }
+
+ if (found.length > 0) {
+ output += `
+
+ ${found.join("\n")}
+
+ HTTP/1.1 200 OK
+ `;
+ }
+ if (notFound.length > 0) {
+ output += `
+
+ ${notFound.join("\n")}
+
+ HTTP/1.1 404 Not Found
+ `;
+ }
+
+ return output;
+ },
+
+ /** Handle any requests to calendar items. */
+
+ itemHandler(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (!/\/[\w-]+\.ics$/.test(request.path)) {
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Item not found at ${request.path}`);
+ return;
+ }
+
+ switch (request.method) {
+ case "GET":
+ this.getItem(request, response);
+ return;
+ case "PUT":
+ this.putItem(request, response);
+ return;
+ case "DELETE":
+ this.deleteItem(request, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 405, "Method Not Allowed");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Method not allowed: ${request.method}`);
+ },
+
+ async getItem(request, response) {
+ let item = this.items.get(request.path);
+ if (!item) {
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Item not found at ${request.path}`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/calendar");
+ response.setHeader("ETag", item.etag);
+ response.write(item.ics);
+ },
+
+ async putItem(request, response) {
+ if (request.hasHeader("If-Match")) {
+ let item = this.items.get(request.path);
+ if (!item || item.etag != request.getHeader("If-Match")) {
+ response.setStatusLine("1.1", 412, "Precondition Failed");
+ return;
+ }
+ }
+
+ response.processAsync();
+
+ let ics = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ await this.putItemInternal(request.path, ics);
+ response.setStatusLine("1.1", 204, "No Content");
+
+ response.finish();
+ },
+
+ async putItemInternal(name, ics) {
+ if (!name.startsWith("/")) {
+ name = this.path + name;
+ }
+
+ let hash = await crypto.subtle.digest("sha-1", new TextEncoder().encode(ics));
+ let etag = Array.from(new Uint8Array(hash), c => c.toString(16).padStart(2, "0")).join("");
+ this.items.set(name, { etag, ics, changed: ++this.changeCount });
+ this.deletedItems.delete(name);
+ },
+
+ deleteItem(request, response) {
+ this.deleteItemInternal(request.path);
+ response.setStatusLine("1.1", 204, "No Content");
+ },
+
+ deleteItemInternal(name) {
+ if (!name.startsWith("/")) {
+ name = this.path + name;
+ }
+ this.items.delete(name);
+ this.deletedItems.set(name, ++this.changeCount);
+ },
+};
diff --git a/calendar/test/ICSServer.jsm b/calendar/test/ICSServer.jsm
new file mode 100644
index 0000000000..1893839a12
--- /dev/null
+++ b/calendar/test/ICSServer.jsm
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ICSServer"];
+
+Cu.importGlobalProperties(["crypto"]);
+
+const { Assert } = ChromeUtils.import("resource://testing-common/Assert.jsm");
+const { CommonUtils } = ChromeUtils.import("resource://services-common/utils.js");
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var ICSServer = {
+ server: null,
+ isOpen: false,
+
+ ics: "",
+ etag: "",
+ open(username, password) {
+ this.server = new HttpServer();
+ this.server.start(-1);
+ this.isOpen = true;
+
+ this.username = username;
+ this.password = password;
+ this.server.registerPathHandler("/ping", this.ping);
+ this.server.registerPathHandler(this.path, this.handleICS.bind(this));
+
+ this.reset();
+ },
+
+ reset() {
+ this.ics = "";
+ this.etag = "";
+ },
+
+ close() {
+ if (!this.isOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve =>
+ this.server.stop({
+ onStopped: () => {
+ this.isOpen = false;
+ resolve();
+ },
+ })
+ );
+ },
+
+ get origin() {
+ return `http://localhost:${this.server.identity.primaryPort}`;
+ },
+
+ get path() {
+ return "/test.ics";
+ },
+
+ get url() {
+ return `${this.origin}${this.path}`;
+ },
+
+ get altPath() {
+ return "/addressbooks/me/default/";
+ },
+
+ get altURL() {
+ return `${this.origin}${this.altPath}`;
+ },
+
+ checkAuth(request, response) {
+ if (!this.username || !this.password) {
+ return true;
+ }
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let value = request.getHeader("Authorization");
+ if (!value.startsWith("Basic ")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let [username, password] = atob(value.substring(6)).split(":");
+ if (username != this.username || password != this.password) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ return true;
+ },
+
+ ping(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("pong");
+ },
+
+ handleICS(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ switch (request.method) {
+ case "HEAD":
+ this.headICS(request, response);
+ return;
+ case "GET":
+ this.getICS(request, response);
+ return;
+ case "PUT":
+ this.putICS(request, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 405, "Method Not Allowed");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Method not allowed: ${request.method}`);
+ },
+
+ headICS(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/calendar");
+ response.setHeader("ETag", this.etag);
+ },
+
+ getICS(request, response) {
+ this.headICS(request, response);
+ response.write(this.ics);
+ },
+
+ async putICS(request, response) {
+ response.processAsync();
+
+ await this.putICSInternal(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ response.setStatusLine("1.1", 204, "No Content");
+ response.setHeader("ETag", this.etag);
+
+ response.finish();
+ },
+
+ async putICSInternal(ics) {
+ this.ics = ics;
+
+ let hash = await crypto.subtle.digest("sha-1", new TextEncoder().encode(this.ics));
+ this.etag = Array.from(new Uint8Array(hash), c => c.toString(16).padStart(2, "0")).join("");
+ },
+};
diff --git a/calendar/test/moz.build b/calendar/test/moz.build
index d5273a3ef7..58793547e6 100644
--- a/calendar/test/moz.build
+++ b/calendar/test/moz.build
@@ -15,6 +15,11 @@ BROWSER_CHROME_MANIFESTS += [
"browser/views/browser.ini",
]
+TESTING_JS_MODULES += [
+ "CalDAVServer.jsm",
+ "ICSServer.jsm",
+]
+
TESTING_JS_MODULES.mozmill += [
"modules/CalendarTestUtils.jsm",
"modules/CalendarUtils.jsm",
@@ -22,6 +27,7 @@ TESTING_JS_MODULES.mozmill += [
]
XPCSHELL_TESTS_MANIFESTS += [
+ "unit/providers/xpcshell.ini",
"unit/xpcshell-icaljs.ini",
"unit/xpcshell-libical.ini",
]
diff --git a/calendar/test/unit/providers/head.js b/calendar/test/unit/providers/head.js
new file mode 100644
index 0000000000..3d77769cbe
--- /dev/null
+++ b/calendar/test/unit/providers/head.js
@@ -0,0 +1,167 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CalendarTestUtils.jsm"
+);
+var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+var { PromiseUtils } = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+// The tests in this directory each do the same thing, with slight variations as needed for each
+// calendar provider. The core of the test lives in this file and the tests call it when ready.
+
+let manager = cal.getCalendarManager();
+
+do_get_profile();
+add_task(async () => {
+ await new Promise(resolve => manager.startup({ onResult: resolve }));
+ await new Promise(resolve => cal.getTimezoneService().startup({ onResult: resolve }));
+ manager.addCalendarObserver(calendarObserver);
+});
+
+let calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ /* calIObserver */
+
+ _batchCount: 0,
+ _batchRequired: true,
+ onStartBatch() {
+ info(`onStartBatch ${++this._batchCount}`);
+ Assert.equal(this._batchCount, 1, "onStartBatch must not occur in a batch");
+ },
+ onEndBatch() {
+ info(`onEndBatch ${this._batchCount--}`);
+ Assert.equal(this._batchCount, 0, "onEndBatch must occur in a batch");
+ },
+ onLoad(calendar) {
+ info(`onLoad ${calendar.id}`);
+ Assert.equal(this._batchCount, 0, "onLoad must not occur in a batch");
+ if (this._onLoadPromise) {
+ this._onLoadPromise.resolve();
+ }
+ },
+ onAddItem(item) {
+ info(`onAddItem ${item.calendar.id} ${item.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onAddItem must occur in a batch");
+ }
+ if (this._onAddItemPromise) {
+ this._onAddItemPromise.resolve();
+ }
+ },
+ onModifyItem(newItem, oldItem) {
+ info(`onModifyItem ${newItem.calendar.id} ${newItem.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onModifyItem must occur in a batch");
+ }
+ if (this._onModifyItemPromise) {
+ this._onModifyItemPromise.resolve();
+ }
+ },
+ onDeleteItem(deletedItem) {
+ info(`onDeleteItem ${deletedItem.calendar.id} ${deletedItem.id}`);
+ if (this._onDeleteItemPromise) {
+ this._onDeleteItemPromise.resolve();
+ }
+ },
+ onError(calendar, errNo, message) {},
+ onPropertyChanged(calendar, name, value, oldValue) {},
+ onPropertyDeleting(calendar, name) {},
+};
+
+/**
+ * Create and register a calendar.
+ *
+ * @param {string} type - The calendar provider to use.
+ * @param {string} url - URL of the server.
+ * @param {boolean} useCache - Should this calendar have offline storage?
+ * @returns {calICalendar}
+ */
+function createCalendar(type, url, useCache) {
+ let calendar = manager.createCalendar(type, Services.io.newURI(url));
+ calendar.name = type + (useCache ? " with cache" : " without cache");
+ calendar.id = cal.getUUID();
+ calendar.setProperty("cache.enabled", useCache);
+
+ manager.registerCalendar(calendar);
+ calendar = manager.getCalendarById(calendar.id);
+ return calendar;
+}
+
+/**
+ * Wraps calICalendar's getItem method in a Promise.
+ *
+ * @param {calICalendar} calendar
+ * @param {string} uid
+ * @returns {Promise} - resolves to calIItemBase or null
+ */
+function getItem(calendar, uid) {
+ return new Promise(resolve => {
+ calendar.getItem(uid, {
+ _item: null,
+ onGetResult(c, status, itemType, detail, items) {
+ this._item = items[0];
+ },
+ onOperationComplete() {
+ resolve(this._item);
+ },
+ });
+ });
+}
+
+/**
+ * Creates an event and adds it to the given calendar.
+ *
+ * @param {calICalendar} calendar
+ * @returns {calIEvent}
+ */
+async function runAddItem(calendar) {
+ let event = new CalEvent();
+ event.id = "6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a";
+ event.title = "New event";
+ event.startDate = cal.createDateTime("20200303T205500Z");
+ event.endDate = cal.createDateTime("20200303T210200Z");
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onModifyItemPromise = PromiseUtils.defer();
+ calendar.addItem(event, null);
+ await Promise.any([
+ calendarObserver._onAddItemPromise.promise,
+ calendarObserver._onModifyItemPromise.promise,
+ ]);
+
+ return event;
+}
+
+/**
+ * Modifies the event from runAddItem.
+ *
+ * @param {calICalendar} calendar
+ */
+async function runModifyItem(calendar) {
+ let event = await getItem(calendar, "6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a");
+
+ let clone = event.clone();
+ clone.title = "Modified event";
+
+ calendarObserver._onModifyItemPromise = PromiseUtils.defer();
+ calendar.modifyItem(clone, event, null);
+ await calendarObserver._onModifyItemPromise.promise;
+}
+
+/**
+ * Deletes the event from runAddItem.
+ *
+ * @param {calICalendar} calendar
+ */
+async function runDeleteItem(calendar) {
+ let event = await getItem(calendar, "6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a");
+
+ calendarObserver._onDeleteItemPromise = PromiseUtils.defer();
+ calendar.deleteItem(event, null);
+ await calendarObserver._onDeleteItemPromise.promise;
+}
diff --git a/calendar/test/unit/providers/test_caldavCalendar_cached.js b/calendar/test/unit/providers/test_caldavCalendar_cached.js
new file mode 100644
index 0000000000..bb0f211c47
--- /dev/null
+++ b/calendar/test/unit/providers/test_caldavCalendar_cached.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/CalDAVServer.jsm");
+
+CalDAVServer.open();
+CalDAVServer.putItemInternal(
+ "testfile.ics",
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => CalDAVServer.close());
+
+add_task(async function() {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await getItem(calendar, "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._batchRequired = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+});
diff --git a/calendar/test/unit/providers/test_caldavCalendar_uncached.js b/calendar/test/unit/providers/test_caldavCalendar_uncached.js
new file mode 100644
index 0000000000..927e366dab
--- /dev/null
+++ b/calendar/test/unit/providers/test_caldavCalendar_uncached.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/CalDAVServer.jsm");
+
+CalDAVServer.open();
+CalDAVServer.putItemInternal(
+ "testfile.ics",
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => CalDAVServer.close());
+
+add_task(async function() {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await getItem(calendar, "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._batchRequired = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+});
diff --git a/calendar/test/unit/providers/test_icsCalendar_cached.js b/calendar/test/unit/providers/test_icsCalendar_cached.js
new file mode 100644
index 0000000000..b0d5b02b39
--- /dev/null
+++ b/calendar/test/unit/providers/test_icsCalendar_cached.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/ICSServer.jsm");
+
+ICSServer.open();
+ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => ICSServer.close());
+
+add_task(async function() {
+ // TODO: item notifications from a cached ICS calendar occur outside of batches.
+ // This isn't fatal but it shouldn't happen. Side-effects include alarms firing
+ // twice - once from onAddItem then again at onLoad.
+ //
+ // Remove the next line when this is fixed.
+ calendarObserver._batchRequired = false;
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("ics", ICSServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await getItem(calendar, "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runDeleteItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+});
diff --git a/calendar/test/unit/providers/test_icsCalendar_uncached.js b/calendar/test/unit/providers/test_icsCalendar_uncached.js
new file mode 100644
index 0000000000..06e5cdddcb
--- /dev/null
+++ b/calendar/test/unit/providers/test_icsCalendar_uncached.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/ICSServer.jsm");
+
+ICSServer.open();
+ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => ICSServer.close());
+
+add_task(async function() {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("ics", ICSServer.url, false);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await getItem(calendar, "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runDeleteItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+});
diff --git a/calendar/test/unit/providers/test_storageCalendar.js b/calendar/test/unit/providers/test_storageCalendar.js
new file mode 100644
index 0000000000..dbe85b541f
--- /dev/null
+++ b/calendar/test/unit/providers/test_storageCalendar.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function() {
+ let calendar = createCalendar("storage", "moz-storage-calendar://");
+
+ info("creating the item");
+ calendarObserver._batchRequired = false;
+ await runAddItem(calendar);
+
+ info("modifying the item");
+ await runModifyItem(calendar);
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+});
diff --git a/calendar/test/unit/providers/xpcshell.ini b/calendar/test/unit/providers/xpcshell.ini
new file mode 100644
index 0000000000..2bc88eefe4
--- /dev/null
+++ b/calendar/test/unit/providers/xpcshell.ini
@@ -0,0 +1,10 @@
+[default]
+head = head.js
+prefs =
+ calendar.timezone.local=UTC
+
+[test_caldavCalendar_cached.js]
+[test_caldavCalendar_uncached.js]
+[test_icsCalendar_cached.js]
+[test_icsCalendar_uncached.js]
+[test_storageCalendar.js]