diff options
Diffstat (limited to 'comm/calendar/test')
202 files changed, 40615 insertions, 0 deletions
diff --git a/comm/calendar/test/.eslintrc.js b/comm/calendar/test/.eslintrc.js new file mode 100644 index 0000000000..fc89c84bb1 --- /dev/null +++ b/comm/calendar/test/.eslintrc.js @@ -0,0 +1,74 @@ +/* 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/. */ + +"use strict"; + +// Calendar tests run with the pref calendar.timezone.local set to UTC. This +// works fine on the CI, where the system clock is also UTC, but on developers' +// machines the time difference causes some problems. If you have to use the +// Date object, make sure that you use UTC methods. + +module.exports = { + rules: { + "no-restricted-properties": [ + "error", + { + property: "getFullYear", + message: "These tests run in UTC. Use 'getUTCFullYear' instead.", + }, + { + property: "getMonth", + message: "These tests run in UTC. Use 'getUTCMonth' instead.", + }, + { + property: "getDay", + message: "These tests run in UTC. Use 'getUTCDay' instead.", + }, + { + property: "getDate", + message: "These tests run in UTC. Use 'getUTCDate' instead.", + }, + { + property: "getHours", + message: "These tests run in UTC. Use 'getUTCHours' instead.", + }, + { + property: "getMinutes", + message: "These tests run in UTC. Use 'getUTCMinutes' instead.", + }, + { + property: "setFullYear", + message: "These tests run in UTC. Use 'setUTCFullYear' instead.", + }, + { + property: "setMonth", + message: "These tests run in UTC. Use 'setUTCMonth' instead.", + }, + { + property: "setDay", + message: "These tests run in UTC. Use 'setUTCDay' instead.", + }, + { + property: "setDate", + message: "These tests run in UTC. Use 'setUTCDate' instead.", + }, + { + property: "setHours", + message: "These tests run in UTC. Use 'setUTCHours' instead.", + }, + { + property: "setMinutes", + message: "These tests run in UTC. Use 'setUTCMinutes' instead.", + }, + ], + "no-restricted-syntax": [ + "error", + { + selector: "[callee.name='Date'][arguments.length>=2]", + message: + "These tests run in UTC. Use 'new Date(Date.UTC(...))' to construct a Date with arguments.", + }, + ], + }, +}; diff --git a/comm/calendar/test/CalDAVServer.jsm b/comm/calendar/test/CalDAVServer.jsm new file mode 100644 index 0000000000..aff2242f43 --- /dev/null +++ b/comm/calendar/test/CalDAVServer.jsm @@ -0,0 +1,627 @@ +/* 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"]; + +const PREFIX_BINDINGS = { + c: "urn:ietf:params:xml:ns:caldav", + cs: "http://calendarserver.org/ns/", + d: "DAV:", + i: "http://apple.com/ns/ical/", +}; +const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS) + .map(([prefix, url]) => `xmlns:${prefix}="${url}"`) + .join(" "); + +const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs"); +const { CommonUtils } = ChromeUtils.importESModule("resource://services-common/utils.sys.mjs"); +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); + +const logger = console.createInstance({ + prefix: "CalDAVServer", + maxLogLevel: "Log", +}); + +// The response bodies Google sends if you exceed its rate limit. +let MULTIGET_RATELIMIT_ERROR = `<?xml version="1.0" encoding="UTF-8"?> +<D:error xmlns:D="DAV:"/> +`; +let PROPFIND_RATELIMIT_ERROR = `<?xml version="1.0" encoding="UTF-8"?> +<errors xmlns="http://schemas.google.com/g/2005"> + <error> + <domain>GData</domain> + <code>rateLimitExceeded</code> + <internalReason>Some text we're not looking at anyway</internalReason> + </error> +</errors> +`; + +var CalDAVServer = { + items: new Map(), + deletedItems: new Map(), + changeCount: 0, + server: null, + isOpen: false, + + /** + * The "current-user-privilege-set" in responses. Set to null to have no privilege set. + */ + privileges: "<d:privilege><d:all/></d:privilege>", + + 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.privileges = "<d:privilege><d:all/></d:privilege>"; + this.resetHandlers(); + }, + + resetHandlers() { + this.server.registerPathHandler("/.well-known/caldav", this.wellKnown.bind(this)); + this.server.registerPathHandler("/principals/", this.principals.bind(this)); + this.server.registerPathHandler("/principals/me/", this.myPrincipal.bind(this)); + this.server.registerPathHandler("/calendars/me/", this.myCalendars.bind(this)); + + 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"); + }, + + wellKnown(request, response) { + response.setStatusLine("1.1", 301, "Moved Permanently"); + response.setHeader("Location", "/principals/"); + }, + + principals(request, response) { + if (!this.checkAuth(request, response)) { + return; + } + + let input = new DOMParser().parseFromString( + CommonUtils.readBytesFromInputStream(request.bodyInputStream), + "text/xml" + ); + + let propNames = this._inputProps(input); + let propValues = { + "d:current-user-principal": "<href>/principals/me/</href>", + }; + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml"); + response.write( + `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <response> + <href>/principals/</href> + ${this._outputProps(propNames, propValues)} + </response> + </multistatus>`.replace(/>\s+</g, "><") + ); + }, + + myPrincipal(request, response) { + if (!this.checkAuth(request, response)) { + return; + } + + let input = new DOMParser().parseFromString( + CommonUtils.readBytesFromInputStream(request.bodyInputStream), + "text/xml" + ); + + let propNames = this._inputProps(input); + let propValues = { + "d:resourcetype": "<principal/>", + "c:calendar-home-set": "<d:href>/calendars/me/</d:href>", + "c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`, + "c:schedule-inbox-URL": "<d:href>/calendars/me/inbox/</d:href>", + "c:schedule-outbox-URL": "<d:href>/calendars/me/inbox/</d:href>", + }; + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml"); + response.write( + `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <response> + <href>/principals/me/</href> + ${this._outputProps(propNames, propValues)} + </response> + </multistatus>`.replace(/>\s+</g, "><") + ); + }, + + myCalendars(request, response) { + if (!this.checkAuth(request, response)) { + return; + } + + if (request.method == "OPTIONS") { + response.setStatusLine("1.1", 200, "OK"); + response.setHeader("DAV", "1,2,3, calendar-access, calendar-schedule"); + return; + } + + let input = new DOMParser().parseFromString( + CommonUtils.readBytesFromInputStream(request.bodyInputStream), + "text/xml" + ); + + let propNames = this._inputProps(input); + let propValues = { + "d:resourcetype": "<collection/><c:calendar/>", + "d:displayname": "CalDAV Test", + "i:calendar-color": "#ff8000", + "d:current-user-privilege-set": this.privileges, + }; + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml"); + response.write( + `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <response> + <href>/addressbooks/me/</href> + ${this._outputProps(propNames, { + "d:resourcetype": "<collection/>", + "d:displayname": "#calendars", + })} + </response> + <response> + <href>${this.path}</href> + ${this._outputProps(propNames, propValues)} + </response> + </multistatus>`.replace(/>\s+</g, "><") + ); + }, + + /** 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 = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + logger.log("C: " + input); + input = new DOMParser().parseFromString(input, "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 = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`; + for (let [href, item] of this.items) { + output += this._itemResponse(href, item, propNames); + } + output += `</multistatus>`; + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml"); + response.write(output.replace(/>\s+</g, "><")); + logger.log("S: " + output.replace(/>\s+</g, "><")); + }, + + async calendarMultiGet(input, response) { + let propNames = this._inputProps(input); + let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`; + 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 += `</multistatus>`; + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml"); + response.write(output.replace(/>\s+</g, "><")); + logger.log("S: " + output.replace(/>\s+</g, "><")); + }, + + propFind(input, depth, response) { + if (this.throwRateLimitErrors) { + response.setStatusLine("1.1", 403, "Forbidden"); + response.setHeader("Content-Type", "text/xml"); + response.write(PROPFIND_RATELIMIT_ERROR); + logger.log("S: " + PROPFIND_RATELIMIT_ERROR); + return; + } + + let propNames = this._inputProps(input); + let propValues = { + "d:resourcetype": "<d:collection/><c:calendar/>", + "d:owner": "/principals/me/", + "d:current-user-principal": "<href>/principals/me/</href>", + "d:current-user-privilege-set": this.privileges, + "d:supported-report-set": + "<d:supported-report><d:report><c:calendar-multiget/></d:report></d:supported-report>" + + "<d:supported-report><d:report><sync-collection/></d:report></d:supported-report>", + "c:supported-calendar-component-set": "", + "d:getcontenttype": "text/calendar; charset=utf-8", + "c:calendar-home-set": `<d:href>/calendars/me/</d:href>`, + "c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`, + "c:schedule-inbox-url": `<d:href>/calendars/me/inbox/</d:href>`, + "c:schedule-outbox-url": `<d:href>/calendars/me/outbox/</d:href>`, + "cs:getctag": this.changeCount, + "d:getetag": this.changeCount, + }; + + let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <response> + <href>${this.path}</href> + ${this._outputProps(propNames, propValues)} + </response>`; + if (depth == 1) { + for (let [href, item] of this.items) { + output += this._itemResponse(href, item, propNames); + } + } + output += `</multistatus>`; + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml"); + response.write(output.replace(/>\s+</g, "><")); + logger.log("S: " + output.replace(/>\s+</g, "><")); + }, + + syncCollection(input, response) { + if (this.throwRateLimitErrors) { + response.setStatusLine("1.1", 403, "Forbidden"); + response.setHeader("Content-Type", "text/xml"); + response.write(MULTIGET_RATELIMIT_ERROR); + logger.log("S: " + MULTIGET_RATELIMIT_ERROR); + return; + } + + // The maximum number of responses to make at any one request. + let pageSize = 3; + // The last-seen token. Changes before this won't be returned. + let token = 0; + // Which page of responses to return. + let page = 0; + + let tokenStr = input.querySelector("sync-token")?.textContent.replace(/.*\//g, ""); + if (tokenStr?.includes("#")) { + [token, page] = tokenStr.split("#"); + token = parseInt(token, 10); + page = parseInt(page, 10); + } else if (tokenStr) { + token = parseInt(tokenStr, 10); + } + + let nextPage = page + 1; + + // Collect all responses, even if we know some won't be returned. + // This is a test, who cares about performance? + let propNames = this._inputProps(input); + let responses = []; + for (let [href, item] of this.items) { + if (item.changed > token) { + responses.push(this._itemResponse(href, item, propNames)); + } + } + for (let [href, deleted] of this.deletedItems) { + if (deleted > token) { + responses.push(`<response> + <status>HTTP/1.1 404 Not Found</status> + <href>${href}</href> + <propstat> + <prop/> + <status>HTTP/1.1 418 I'm a teapot</status> + </propstat> + </response>`); + } + } + + let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`; + // Use only the responses that match those requested. + output += responses.slice(page * pageSize, nextPage * pageSize).join(""); + if (responses.length > nextPage * pageSize) { + output += `<response> + <status>HTTP/1.1 507 Insufficient Storage</status> + <href>${this.path}</href> + </response>`; + output += `<sync-token>http://mochi.test/sync/${token}#${nextPage}</sync-token>`; + } else { + output += `<sync-token>http://mochi.test/sync/${this.changeCount}</sync-token>`; + } + output += `</multistatus>`; + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml"); + response.write(output.replace(/>\s+</g, "><")); + logger.log("S: " + output.replace(/>\s+</g, "><")); + }, + + _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 = `<response> + <href>${href}</href> + ${this._outputProps(propNames, propValues)} + </response>`; + 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 "current-user-privilege-set": + 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; + case "calendar-color": + Assert.equal(p.namespaceURI, PREFIX_BINDINGS.i); + propNames.push(`i:${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 && propValues[p] != null) { + found.push(`<${p}>${propValues[p]}</${p}>`); + } else { + notFound.push(`<${p}/>`); + } + } + + if (found.length > 0) { + output += `<propstat> + <prop> + ${found.join("\n")} + </prop> + <status>HTTP/1.1 200 OK</status> + </propstat>`; + } + if (notFound.length > 0) { + output += `<propstat> + <prop> + ${notFound.join("\n")} + </prop> + <status>HTTP/1.1 404 Not Found</status> + </propstat>`; + } + + 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/comm/calendar/test/CalendarTestUtils.jsm b/comm/calendar/test/CalendarTestUtils.jsm new file mode 100644 index 0000000000..42e587f540 --- /dev/null +++ b/comm/calendar/test/CalendarTestUtils.jsm @@ -0,0 +1,1203 @@ +/* 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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["CalendarTestUtils"]; + +const EventUtils = ChromeUtils.import("resource://testing-common/mozmill/EventUtils.jsm"); +const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs"); +const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs"); +const { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +async function clickAndWait(win, button) { + EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, win); + await new Promise(resolve => win.setTimeout(resolve)); +} + +/** + * @typedef EditItemAtResult + * @property {Window} dialogWindow - The window of the dialog. + * @property {Document} dialogDocument - The document of the dialog window. + * @property {Window} iframeWindow - The contentWindow property of the embedded + * iframe. + * @property {Document} iframeDocument - The contentDocument of the embedded + * iframe. + */ + +/** + * Helper class for testing the day view of the calendar. + */ +class CalendarDayViewTestUtils { + #helper = new CalendarWeekViewTestUtils("#day-view"); + + /** + * Provides the column container element for the displayed day. + * + * @param {Window} win - The window the calendar is displayed in. + * + * @returns {HTMLElement} - The column container element. + */ + getColumnContainer(win) { + return this.#helper.getColumnContainer(win, 1); + } + + /** + * Provides the element containing the formatted date for the displayed day. + * + * @param {Window} win - The window the calendar is displayed in. + * + * @returns {HTMLElement} - The column heading container. + */ + getColumnHeading(win) { + return this.#helper.getColumnHeading(win, 1); + } + + /** + * Provides the calendar-event-column for the day displayed. + * + * @param {Window} win - The window the calendar is displayed in. + * + * @returns {MozCalendarEventColumn} - The column. + */ + getEventColumn(win) { + return this.#helper.getEventColumn(win, 1); + } + + /** + * Provides the calendar-event-box elements for the day. + * + * @param {Window} win - The window the calendar is displayed in. + * + * @returns {MozCalendarEventBox[]} - The event boxes. + */ + getEventBoxes(win) { + return this.#helper.getEventBoxes(win, 1); + } + + /** + * Provides the calendar-event-box at "index" located in the event column for + * the day displayed. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which event box to select. + * + * @returns {MozCalendarEventBox|undefined} - The event box, if it exists. + */ + getEventBoxAt(win, index) { + return this.#helper.getEventBoxAt(win, 1, index); + } + + /** + * Provides the .multiday-hour-box element for the specified hour. This + * element can be double clicked to create a new event at that hour. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} hour - Must be between 0-23. + * + * @returns {XULElement} - The hour box. + */ + getHourBoxAt(win, hour) { + return this.#helper.getHourBoxAt(win, 1, hour); + } + + /** + * Provides the all-day header, which can be double clicked to create a new + * all-day event. + * + * @param {Window} win - The window the calendar is displayed in. + * + * @returns {CalendarHeaderContainer} - The all-day header. + */ + getAllDayHeader(win) { + return this.#helper.getAllDayHeader(win, 1); + } + + /** + * Provides the all-day calendar-editable-item located at index for the + * current day. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which item to select (1-based). + * + * @returns {MozCalendarEditableItem|undefined} - The all-day item, if it + * exists. + */ + getAllDayItemAt(win, index) { + return this.#helper.getAllDayItemAt(win, 1, index); + } + + /** + * Waits for the calendar-event-box at "index", located in the event + * column for the day displayed to appear. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which item to select (1-based). + * + * @returns {Promise<MozCalendarEventBox>} - The event box. + */ + async waitForEventBoxAt(win, index) { + return this.#helper.waitForEventBoxAt(win, 1, index); + } + + /** + * Waits for the calendar-event-box at "index", located in the event column + * for the current day to disappear. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates the event box (1-based). + */ + async waitForNoEventBoxAt(win, index) { + return this.#helper.waitForNoEventBoxAt(win, 1, index); + } + + /** + * Wait for the all-day calendar-editable-item for the day. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which item to select (1-based). + * + * @returns {Promise<MozCalendarEditableItem>} - The all-day item. + */ + async waitForAllDayItemAt(win, index) { + return this.#helper.waitForAllDayItemAt(win, 1, index); + } + + /** + * Opens the event dialog for viewing for the event box located at the + * specified index. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which event to select. + * + * @returns {Promise<Window>} - The summary event dialog window. + */ + async viewEventAt(win, index) { + return this.#helper.viewEventAt(win, 1, index); + } + + /** + * Opens the event dialog for editing for the event box located at the + * specified index. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which event to select. + * + * @returns {Promise<EditItemAtResult>} + */ + async editEventAt(win, index) { + return this.#helper.editEventAt(win, 1, index); + } + + /** + * Opens the event dialog for editing for a single occurrence of the event + * box located at the specified index. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which event box to select. + * + * @returns {Promise<EditItemAtResult>} + */ + async editEventOccurrenceAt(win, index) { + return this.#helper.editEventOccurrenceAt(win, 1, index); + } + + /** + * Opens the event dialog for editing all occurrences of the event box + * located at the specified index. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} index - Indicates which event box to select. + * + * @returns {Promise<EditItemAtResult>} + */ + async editEventOccurrencesAt(win, index) { + return this.#helper.editEventOccurrencesAt(win, 1, index); + } +} + +/** + * Helper class for testing the week view of the calendar. + */ +class CalendarWeekViewTestUtils { + constructor(rootSelector = "#week-view") { + this.rootSelector = rootSelector; + } + + /** + * Provides the column container element for the day specified. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * + * @throws If the day parameter is out of range. + * @returns {HTMLElement} - The column container element. + */ + getColumnContainer(win, day) { + if (day < 1 || day > 7) { + throw new Error( + `Invalid parameter to #getColumnContainer(): expected day=1-7, got day=${day}.` + ); + } + + let containers = win.document.documentElement.querySelectorAll( + `${this.rootSelector} .day-column-container` + ); + return containers[day - 1]; + } + + /** + * Provides the element containing the formatted date for the day specified. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * + * @throws If the day parameter is out of range. + * @returns {HTMLElement} - The column heading container element. + */ + getColumnHeading(win, day) { + let container = this.getColumnContainer(win, day); + return container.querySelector(".day-column-heading"); + } + + /** + * Provides the calendar-event-column for the day specified. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7 + * + * @throws - If the day parameter is out of range. + * @returns {MozCalendarEventColumn} - The column. + */ + getEventColumn(win, day) { + let container = this.getColumnContainer(win, day); + return container.querySelector("calendar-event-column"); + } + + /** + * Provides the calendar-event-box elements for the day specified. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * + * @returns {MozCalendarEventBox[]} - The event boxes. + */ + getEventBoxes(win, day) { + let container = this.getColumnContainer(win, day); + return container.querySelectorAll(".multiday-events-list calendar-event-box"); + } + + /** + * Provides the calendar-event-box at "index" located in the event column for + * the specified day. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which event box to select. + * + * @returns {MozCalendarEventBox|undefined} - The event box, if it exists. + */ + getEventBoxAt(win, day, index) { + return this.getEventBoxes(win, day)[index - 1]; + } + + /** + * Provides the .multiday-hour-box element for the specified hour. This + * element can be double clicked to create a new event at that hour. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Day of the week, between 1-7. + * @param {number} hour - Must be between 0-23. + * + * @throws If the day or hour are out of range. + * @returns {XULElement} - The hour box. + */ + getHourBoxAt(win, day, hour) { + let container = this.getColumnContainer(win, day); + return container.querySelectorAll(".multiday-hour-box")[hour]; + } + + /** + * Provides the all-day header, which can be double clicked to create a new + * all-day event for the specified day. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Day of the week, between 1-7. + * + * @throws If the day is out of range. + * @returns {CalendarHeaderContainer} - The all-day header. + */ + getAllDayHeader(win, day) { + let container = this.getColumnContainer(win, day); + return container.querySelector("calendar-header-container"); + } + + /** + * Provides the all-day calendar-editable-item located at "index" for the + * specified day. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Day of the week, between 1-7. + * @param {number} index - Indicates which item to select (starting from 1). + * + * @throws If the day or index are out of range. + * @returns {MozCalendarEditableItem|undefined} - The all-day item, if it + * exists. + */ + getAllDayItemAt(win, day, index) { + let allDayHeader = this.getAllDayHeader(win, day); + return allDayHeader.querySelectorAll("calendar-editable-item")[index - 1]; + } + + /** + * Waits for the calendar-event-box at "index", located in the event column + * for the day specified to appear. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Day of the week, between 1-7. + * @param {number} index - Indicates which event box to select. + * + * @returns {MozCalendarEventBox} - The event box. + */ + async waitForEventBoxAt(win, day, index) { + return TestUtils.waitForCondition( + () => this.getEventBoxAt(win, day, index), + `calendar-event-box at day=${day}, index=${index} did not appear in time` + ); + } + + /** + * Waits until the calendar-event-box at "index", located in the event column + * for the day specified disappears. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Day of the week, between 1-7. + * @param {number} index - Indicates which event box to select. + */ + async waitForNoEventBoxAt(win, day, index) { + await TestUtils.waitForCondition( + () => !this.getEventBoxAt(win, day, index), + `calendar-event-box at day=${day}, index=${index} still present` + ); + } + + /** + * Waits for the all-day calendar-editable-item at "index", located in the + * event column for the day specified to appear. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Day of the week, between 1-7. + * @param {number} index - Indicates which item to select (starting from 1). + * + * @returns {Promise<MozCalendarEditableItem>} - The all-day item. + */ + async waitForAllDayItemAt(win, day, index) { + return TestUtils.waitForCondition( + () => this.getAllDayItemAt(win, day, index), + `All-day calendar-editable-item at day=${day}, index=${index} did not appear in time` + ); + } + + /** + * Opens the event dialog for viewing for the event box located at the + * specified parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which event to select. + * + * @returns {Promise<Window>} - The summary event dialog window. + */ + async viewEventAt(win, day, index) { + let item = await this.waitForEventBoxAt(win, day, index); + return CalendarTestUtils.viewItem(win, item); + } + + /** + * Opens the event dialog for editing for the event box located at the + * specified parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which event to select. + * + * @returns {Promise<EditItemAtResult>} + */ + async editEventAt(win, day, index) { + let item = await this.waitForEventBoxAt(win, day, index); + return CalendarTestUtils.editItem(win, item); + } + + /** + * Opens the event dialog for editing for a single occurrence of the event + * box located at the specified parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which event box to select. + * + * @returns {Promise<EditItemAtResult>} + */ + async editEventOccurrenceAt(win, day, index) { + let item = await this.waitForEventBoxAt(win, day, index); + return CalendarTestUtils.editItemOccurrence(win, item); + } + + /** + * Opens the event dialog for editing all occurrences of the event box + * located at the specified parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which event box to select. + * + * @returns {Promise<EditItemAtResult>} + */ + async editEventOccurrencesAt(win, day, index) { + let item = await this.waitForEventBoxAt(win, day, index); + return CalendarTestUtils.editItemOccurrences(win, item); + } +} + +/** + * Helper class for testing the multiweek and month views of the calendar. + */ +class CalendarMonthViewTestUtils { + /** + * @param {string} rootSelector + */ + constructor(rootSelector) { + this.rootSelector = rootSelector; + } + + /** + * Provides the calendar-month-day-box element located at the specified day, + * week combination. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. The cap may be as low as 1 + * depending on the user preference calendar.weeks.inview. + * @param {number} day - Must be between 1-7. + * + * @throws If the day or week parameters are out of range. + * @returns {MozCalendarMonthDayBox} + */ + getDayBox(win, week, day) { + if (!(week >= 1 && week <= 6 && day >= 1 && day <= 7)) { + throw new Error( + `Invalid parameters to getDayBox(): ` + + `expected week=1-6, day=1-7, got week=${week}, day=${day},` + ); + } + + return win.document.documentElement.querySelector( + `${this.rootSelector} .monthbody > tr:nth-of-type(${week}) > + td:nth-of-type(${day}) > calendar-month-day-box` + ); + } + + /** + * Get the calendar-month-day-box-item located in the specified day box, at + * the target index. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which item to select. + * + * @throws If the index, day or week parameters are out of range. + * @returns {MozCalendarMonthDayBoxItem} + */ + getItemAt(win, week, day, index) { + if (!(index >= 1)) { + throw new Error(`Invalid parameters to getItemAt(): expected index>=1, got index=${index}.`); + } + + let dayBox = this.getDayBox(win, week, day); + return dayBox.querySelector(`li:nth-of-type(${index}) calendar-month-day-box-item`); + } + + /** + * Waits for the calendar-month-day-box-item at "index", located in the + * specified week,day combination to appear. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which item to select. + * + * @returns {MozCalendarMonthDayBoxItem} + */ + async waitForItemAt(win, week, day, index) { + return TestUtils.waitForCondition( + () => this.getItemAt(win, week, day, index), + `calendar-month-day-box-item at week=${week}, day=${day}, index=${index} did not appear in time` + ); + } + + /** + * Waits for the calendar-month-day-box-item at "index", located in the + * specified week,day combination to disappear. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates the item that should no longer be present. + */ + async waitForNoItemAt(win, week, day, index) { + await TestUtils.waitForCondition( + () => !this.getItemAt(win, week, day, index), + `calendar-month-day-box-item at week=${week}, day=${day}, index=${index} still present` + ); + } + + /** + * Opens the event dialog for viewing for the item located at the specified + * parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which item to select. + * + * @returns {Window} - The summary event dialog window. + */ + async viewItemAt(win, week, day, index) { + let item = await this.waitForItemAt(win, week, day, index); + return CalendarTestUtils.viewItem(win, item); + } + + /** + * Opens the event dialog for editing for the item located at the specified + * parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which item to select. + * + * @returns {EditItemAtResult} + */ + async editItemAt(win, week, day, index) { + let item = await this.waitForItemAt(win, week, day, index); + return CalendarTestUtils.editItem(win, item); + } + + /** + * Opens the event dialog for editing for a single occurrence of the item + * located at the specified parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which item to select. + * + * @returns {EditItemAtResult} + */ + async editItemOccurrenceAt(win, week, day, index) { + let item = await this.waitForItemAt(win, week, day, index); + return CalendarTestUtils.editItemOccurrence(win, item); + } + + /** + * Opens the event dialog for editing all occurrences of the item + * located at the specified parameters. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} week - Must be between 1-6. + * @param {number} day - Must be between 1-7. + * @param {number} index - Indicates which item to select. + * + * @returns {EditItemAtResult} + */ + async editItemOccurrencesAt(win, week, day, index) { + let item = await this.waitForItemAt(win, week, day, index); + return CalendarTestUtils.editItemOccurrences(win, item); + } +} + +/** + * Non-mozmill calendar helper utility. + */ +const CalendarTestUtils = { + /** + * Helper methods for item editing. + */ + items: { + cancelItemDialog, + saveAndCloseItemDialog, + setData, + }, + + /** + * Helpers specific to the day view. + */ + dayView: new CalendarDayViewTestUtils(), + + /** + * Helpers specific to the week view. + */ + weekView: new CalendarWeekViewTestUtils(), + + /** + * Helpers specific to the multiweek view. + */ + multiweekView: new CalendarMonthViewTestUtils("#multiweek-view"), + + /** + * Helpers specific to the month view. + */ + monthView: new CalendarMonthViewTestUtils("#month-view"), + + /** + * Dedent the template string tagged with this function to make indented data + * easier to read. Usage: + * + * let data = dedent` + * This is indented data it will be unindented so that the first line has + * no leading spaces and the second is indented by two spaces. + * `; + * + * @param strings The string fragments from the template string + * @param ...values The interpolated values + * @returns The interpolated, dedented string + */ + dedent(strings, ...values) { + let parts = []; + // Perform variable interpolation + let minIndent = Infinity; + for (let [i, string] of strings.entries()) { + let innerparts = string.split("\n"); + if (i == 0) { + innerparts.shift(); + } + if (i == strings.length - 1) { + innerparts.pop(); + } + for (let [j, ip] of innerparts.entries()) { + let match = ip.match(/^(\s*)\S*/); + if (j != 0) { + minIndent = Math.min(minIndent, match[1].length); + } + } + parts.push(innerparts); + } + + return parts + .map((part, i) => { + return ( + part + .map((line, j) => { + return j == 0 && i > 0 ? line : line.substr(minIndent); + }) + .join("\n") + (i < values.length ? values[i] : "") + ); + }) + .join(""); + }, + + /** + * Creates and registers a new calendar with the calendar manager. The + * created calendar will be set as the default calendar. + * + * @param {string} - name + * @param {string} - type + * + * @returns {calICalendar} + */ + createCalendar(name = "Test", type = "storage") { + let calendar = cal.manager.createCalendar(type, Services.io.newURI(`moz-${type}-calendar://`)); + calendar.name = name; + calendar.setProperty("calendar-main-default", true); + cal.manager.registerCalendar(calendar); + return calendar; + }, + + /** + * Convenience method for removing a calendar using its proxy. + * + * @param {calICalendar} calendar - A calendar to remove. + */ + removeCalendar(calendar) { + cal.manager.unregisterCalendar(calendar); + }, + + /** + * Ensures the calendar tab is open + * + * @param {Window} win + */ + async openCalendarTab(win) { + let tabmail = win.document.getElementById("tabmail"); + let calendarMode = tabmail.tabModes.calendar; + + if (calendarMode.tabs.length == 1) { + tabmail.selectedTab = calendarMode.tabs[0]; + } else { + let calendarTabButton = win.document.getElementById("calendarButton"); + EventUtils.synthesizeMouseAtCenter(calendarTabButton, { clickCount: 1 }, win); + } + + Assert.equal(calendarMode.tabs.length, 1, "calendar tab is open"); + Assert.equal(tabmail.selectedTab, calendarMode.tabs[0], "calendar tab is selected"); + + await new Promise(resolve => win.setTimeout(resolve)); + }, + + /** + * Make sure the current view has finished loading. + * + * @param {Window} win + */ + async ensureViewLoaded(win) { + await win.currentView().ready; + }, + + /** + * Ensures the calendar view is in the specified mode. + * + * @param {Window} win + * @param {string} viewName + */ + async setCalendarView(win, viewName) { + await CalendarTestUtils.openCalendarTab(win); + await CalendarTestUtils.ensureViewLoaded(win); + + let viewTabButton = win.document.querySelector( + `.calview-toggle-item[aria-controls="${viewName}-view"]` + ); + EventUtils.synthesizeMouseAtCenter(viewTabButton, { clickCount: 1 }, win); + Assert.equal(win.currentView().id, `${viewName}-view`); + + await CalendarTestUtils.ensureViewLoaded(win); + }, + + /** + * Step forward in the calendar view. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} n - Number of times to move the view forward. + */ + async calendarViewForward(win, n) { + let viewForwardButton = win.document.getElementById("nextViewButton"); + for (let i = 0; i < n; i++) { + await clickAndWait(win, viewForwardButton); + await CalendarTestUtils.ensureViewLoaded(win); + } + }, + + /** + * Step backward in the calendar view. + * + * @param {Window} win - The window the calendar is displayed in. + * @param {number} n - Number of times to move the view backward. + */ + async calendarViewBackward(win, n) { + let viewBackwardButton = win.document.getElementById("previousViewButton"); + for (let i = 0; i < n; i++) { + await clickAndWait(win, viewBackwardButton); + await CalendarTestUtils.ensureViewLoaded(win); + } + }, + + /** + * Ensures the calendar tab is not open. + * + * @param {Window} win + */ + async closeCalendarTab(win) { + let tabmail = win.document.getElementById("tabmail"); + let calendarMode = tabmail.tabModes.calendar; + + if (calendarMode.tabs.length == 1) { + tabmail.closeTab(calendarMode.tabs[0]); + } + + Assert.equal(calendarMode.tabs.length, 0, "calendar tab is not open"); + + await new Promise(resolve => win.setTimeout(resolve)); + }, + + /** + * Opens the event dialog for viewing by clicking on the provided event item. + * + * @param {Window} win - The window containing the calendar. + * @param {MozCalendarEditableItem} item - An event box item that can be + * clicked on to open the dialog. + * + * @returns {Promise<Window>} + */ + async viewItem(win, item) { + if (Services.focus.activeWindow != win) { + await BrowserTestUtils.waitForEvent(win, "focus"); + } + + let promise = this.waitForEventDialog("view"); + EventUtils.synthesizeMouseAtCenter(item, { clickCount: 2 }, win); + return promise; + }, + + async _editNewItem(win, target, type) { + let dialogPromise = CalendarTestUtils.waitForEventDialog("edit"); + + if (target) { + this.scrollViewToTarget(target, true); + EventUtils.synthesizeMouse(target, 1, 1, { clickCount: 2 }, win); + } else { + let buttonId = `sidePanelNew${type[0].toUpperCase()}${type.slice(1).toLowerCase()}`; + EventUtils.synthesizeMouseAtCenter(win.document.getElementById(buttonId), {}, win); + } + + let dialogWindow = await dialogPromise; + let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe"); + await new Promise(resolve => iframe.contentWindow.setTimeout(resolve)); + Assert.report(false, undefined, undefined, `New ${type} dialog opened`); + return { + dialogWindow, + dialogDocument: dialogWindow.document, + iframeWindow: iframe.contentWindow, + iframeDocument: iframe.contentDocument, + }; + }, + + /** + * Opens the dialog for editing a new event. An optional day/week view + * hour box or multiweek/month view calendar-month-day-box can be specified + * to simulate creation of the event at that target. + * + * @param {Window} win - The window containing the calendar. + * @param {XULElement?} target - The <spacer> or <calendar-month-day-box> + * to click on, if not specified, the new event + * button is used. + */ + async editNewEvent(win, target) { + return this._editNewItem(win, target, "event"); + }, + + /** + * Opens the dialog for editing a new task. + * + * @param {Promise<Window>} win - The window containing the task tree. + */ + async editNewTask(win) { + return this._editNewItem(win, null, "task"); + }, + + async _editItem(win, item, selector) { + let summaryWin = await this.viewItem(win, item); + let promise = this.waitForEventDialog("edit"); + let button = summaryWin.document.querySelector(selector); + button.click(); + + let dialogWindow = await promise; + let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe"); + return { + dialogWindow, + dialogDocument: dialogWindow.document, + iframeWindow: iframe.contentWindow, + iframeDocument: iframe.contentDocument, + }; + }, + + /** + * Opens the event dialog for editing by clicking on the provided event item. + * + * @param {Window} win - The window containing the calendar. + * @param {MozCalendarEditableItem} item - An event box item that can be + * clicked on to open the dialog. + * + * @returns {Promise<EditItemAtResult>} + */ + async editItem(win, item) { + return this._editItem(win, item, "#calendar-summary-dialog-edit-button"); + }, + + /** + * Opens the event dialog for editing a single occurrence of a repeating event + * by clicking on the provided event item. + * + * @param {Window} win - The window containing the calendar. + * @param {MozCalendarEditableItem} item - An event box item that can be + * clicked on to open the dialog. + * + * @returns {Window} + */ + async editItemOccurrence(win, item) { + return this._editItem(win, item, "#edit-button-context-menu-this-occurrence"); + }, + + /** + * Opens the event dialog for editing all occurrences of a repeating event + * by clicking on the provided event box. + * + * @param {Window} win - The window containing the calendar. + * @param {MozCalendarEditableItem} item - An event box item that can be + * clicked on to open the dialog. + * + * @returns {Window} + */ + async editItemOccurrences(win, item) { + return this._editItem(win, item, "#edit-button-context-menu-all-occurrences"); + }, + + /** + * This produces a Promise for waiting on an event dialog to open. + * The mode parameter can be specified to indicate which of the dialogs to + * wait for. + * + * @param {string} [mode="view"] Determines which dialog we are waiting on, + * can be "view" for the summary or "edit" for the editing one. + * + * @returns {Promise<Window>} + */ + waitForEventDialog(mode = "view") { + let uri = + mode === "edit" + ? "chrome://calendar/content/calendar-event-dialog.xhtml" + : "chrome://calendar/content/calendar-summary-dialog.xhtml"; + + return BrowserTestUtils.domWindowOpened(null, async win => { + await BrowserTestUtils.waitForEvent(win, "load"); + + if (win.document.documentURI != uri) { + return false; + } + + Assert.report(false, undefined, undefined, "Event dialog opened"); + await TestUtils.waitForCondition( + () => Services.focus.activeWindow == win, + "event dialog active" + ); + + if (mode === "edit") { + let iframe = win.document.getElementById("calendar-item-panel-iframe"); + await TestUtils.waitForCondition( + () => iframe.contentWindow?.onLoad?.hasLoaded, + "waiting for iframe to be loaded" + ); + await TestUtils.waitForCondition( + () => Services.focus.focusedWindow == iframe.contentWindow, + "waiting for iframe to be focused" + ); + } + return true; + }); + }, + + /** + * Go to a specific date using the minimonth. + * + * @param {Window} win - Main window + * @param {number} year - Four-digit year + * @param {number} month - 1-based index of a month + * @param {number} day - 1-based index of a day + */ + async goToDate(win, year, month, day) { + let miniMonth = win.document.getElementById("calMinimonth"); + + let activeYear = miniMonth.querySelector(".minimonth-year-name").getAttribute("value"); + + let activeMonth = miniMonth.querySelector(".minimonth-month-name").getAttribute("monthIndex"); + + async function doScroll(name, difference, sleepTime) { + if (difference === 0) { + return; + } + let query = `.${name}s-${difference > 0 ? "back" : "forward"}-button`; + let scrollArrow = await TestUtils.waitForCondition( + () => miniMonth.querySelector(query), + `Query for scroll: ${query}` + ); + + for (let i = 0; i < Math.abs(difference); i++) { + EventUtils.synthesizeMouseAtCenter(scrollArrow, {}, win); + await new Promise(resolve => win.setTimeout(resolve, sleepTime)); + } + } + + await doScroll("year", activeYear - year, 10); + await doScroll("month", activeMonth - (month - 1), 25); + + function getMiniMonthDay(week, day) { + return miniMonth.querySelector( + `.minimonth-cal-box > tr.minimonth-row-body:nth-of-type(${week + 1}) > ` + + `td.minimonth-day:nth-of-type(${day})` + ); + } + + let positionOfFirst = 7 - getMiniMonthDay(1, 7).textContent; + let weekDay = ((positionOfFirst + day - 1) % 7) + 1; + let week = Math.floor((positionOfFirst + day - 1) / 7) + 1; + + // Pick day. + EventUtils.synthesizeMouseAtCenter(getMiniMonthDay(week, weekDay), {}, win); + await CalendarTestUtils.ensureViewLoaded(win); + }, + + /** + * Go to today. + * + * @param {Window} win - Main window + */ + async goToToday(win) { + EventUtils.synthesizeMouseAtCenter(this.getNavBarTodayButton(win), {}, win); + await CalendarTestUtils.ensureViewLoaded(win); + }, + + /** + * Assert whether the given event box's edges are visually draggable (and + * hence, editable) at its edges or not. + * + * @param {MozCalendarEventBox} eventBox - The event box to test. + * @param {boolean} startDraggable - Whether we expect the start edge to be + * draggable. + * @param {boolean} endDraggable - Whether we expect the end edge to be + * draggable. + * @param {string} message - A message for assertions. + */ + async assertEventBoxDraggable(eventBox, startDraggable, endDraggable, message) { + this.scrollViewToTarget(eventBox, true); + // Hover to see if the drag gripbars appear. + let enterPromise = BrowserTestUtils.waitForEvent(eventBox, "mouseenter"); + // Hover over start. + EventUtils.synthesizeMouse(eventBox, 8, 8, { type: "mouseover" }, eventBox.ownerGlobal); + await enterPromise; + Assert.equal( + BrowserTestUtils.is_visible(eventBox.startGripbar), + startDraggable, + `Start gripbar should be ${startDraggable ? "visible" : "hidden"} on hover: ${message}` + ); + Assert.equal( + BrowserTestUtils.is_visible(eventBox.endGripbar), + endDraggable, + `End gripbar should be ${endDraggable ? "visible" : "hidden"} on hover: ${message}` + ); + }, + + /** + * Scroll the calendar view to show the given target. + * + * @param {Element} target - The target to scroll to. A descendent of a + * calendar view. + * @param {boolean} alignStart - Whether to scroll the inline and block start + * edges of the target into view, else scrolls the end edges into view. + */ + scrollViewToTarget(target, alignStart) { + let multidayView = target.closest("calendar-day-view, calendar-week-view"); + if (multidayView) { + // Multiday view has sticky headers, so scrollIntoView doesn't actually + // scroll far enough. + let scrollRect = multidayView.getScrollAreaRect(); + let targetRect = target.getBoundingClientRect(); + // We want to move the view by the difference between the starting/ending + // edge of the view and the starting/ending edge of the target. + let yDiff = alignStart + ? targetRect.top - scrollRect.top + : targetRect.bottom - scrollRect.bottom; + // In left-to-right, starting edge is the left edge. Otherwise, it is the + // right edge. + let xDiff = + alignStart == (target.ownerDocument.dir == "ltr") + ? targetRect.left - scrollRect.left + : targetRect.right - scrollRect.right; + multidayView.grid.scrollBy(xDiff, yDiff); + } else { + target.scrollIntoView(alignStart); + } + }, + + /** + * Save the current calendar views' UI states to be restored later. + * + * This is used with restoreCalendarViewsState to reset the view back to its + * initial loaded state after a test, so that later tests in the same group + * will receive the calendar view as if it was first opened after launching. + * + * @param {Window} win - The window that contains the calendar views. + * + * @returns {object} - An opaque object with data to pass to + * restoreCalendarViewsState. + */ + saveCalendarViewsState(win) { + return { + multidayViewsData: ["day", "week"].map(viewName => { + // Save the scroll state since test utilities may change the scroll + // position, and this is currently not reset on re-opening the tab. + let view = win.document.getElementById(`${viewName}-view`); + return { view, viewName, scrollMinute: view.scrollMinute }; + }), + }; + }, + + /** + * Clean up the calendar views after a test by restoring their UI to the saved + * state, and close the calendar tab. + * + * @param {Window} win - The window that contains the calendar views. + * @param {object} data - The data returned by saveCalendarViewsState. + */ + async restoreCalendarViewsState(win, data) { + for (let { view, viewName, scrollMinute } of data.multidayViewsData) { + await this.setCalendarView(win, viewName); + // The scrollMinute is rounded to the nearest integer. + // As is the scroll pixels. + // When we scrollToMinute, the scroll position is rounded to the nearest + // integer, as is the subsequent scroll minute. So calling + // scrollToMinute(min) + // will set + // scrollMinute = round(round(min * P) / P) + // where P is the pixelsPerMinute of the view. Thus + // scrollMinute = min +- round(0.5 / P) + let roundingError = Math.round(0.5 / view.pixelsPerMinute); + view.scrollToMinute(scrollMinute); + await TestUtils.waitForCondition( + () => Math.abs(view.scrollMinute - scrollMinute) <= roundingError, + "Waiting for scroll minute to restore" + ); + } + await CalendarTestUtils.closeCalendarTab(win); + }, + + /** + * Get the Today button from the navigation bar. + * + * @param {Window} win - The window which contains the calendar. + * + * @returns {HTMLElement} - The today button. + */ + getNavBarTodayButton(win) { + return win.document.getElementById("todayViewButton"); + }, + + /** + * Get the label element containing a human-readable description of the + * displayed interval. + * + * @param {Window} win - The window which contains the calendar. + * + * @returns {HTMLLabelElement} - The interval description label. + */ + getNavBarIntervalDescription(win) { + return win.document.getElementById("intervalDescription"); + }, + + /** + * Get the label element containing an indication of which week or weeks are + * displayed. + * + * @param {Window} win - The window which contains the calendar. + * + * @returns {HTMLLabelElement} - The calendar week label. + */ + getNavBarCalendarWeekBox(win) { + return win.document.getElementById("calendarWeek"); + }, +}; diff --git a/comm/calendar/test/CalendarUtils.jsm b/comm/calendar/test/CalendarUtils.jsm new file mode 100644 index 0000000000..708a65ad4e --- /dev/null +++ b/comm/calendar/test/CalendarUtils.jsm @@ -0,0 +1,87 @@ +/* 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 = [ + "SHORT_SLEEP", + "MID_SLEEP", + "TIMEOUT_MODAL_DIALOG", + "handleDeleteOccurrencePrompt", + "execEventDialogCallback", + "checkMonthAlarmIcon", + "closeAllEventDialogs", +]; + +var { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs"); +var { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); +var EventUtils = ChromeUtils.import("resource://testing-common/mozmill/EventUtils.jsm"); +var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs"); + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "CalendarTestUtils", + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +var SHORT_SLEEP = 100; +var MID_SLEEP = 500; +var TIMEOUT_MODAL_DIALOG = 30000; +var EVENT_DIALOG_NAME = "Calendar:EventDialog"; + +/** + * Delete one or all occurrences using the prompt. + * + * @param {Window} window - Main window. + * @param {Element} element - Element which will open the dialog. + * @param {boolean} selectParent - true if all occurrences should be deleted. + */ +async function handleDeleteOccurrencePrompt(window, element, selectParent) { + let dialogPromise = BrowserTestUtils.promiseAlertDialog( + undefined, + "chrome://calendar/content/calendar-occurrence-prompt.xhtml", + { + callback(dialogWindow) { + let buttonId; + if (selectParent) { + buttonId = "accept-parent-button"; + } else { + buttonId = "accept-occurrence-button"; + } + let acceptButton = dialogWindow.document.getElementById(buttonId); + EventUtils.synthesizeMouseAtCenter(acceptButton, {}, dialogWindow); + }, + } + ); + + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await dialogPromise; +} + +async function execEventDialogCallback(callback) { + let eventWindow = Services.wm.getMostRecentWindow(EVENT_DIALOG_NAME); + + if (!eventWindow) { + eventWindow = await lazy.CalendarTestUtils.waitForEventDialog("edit"); + } + + let iframe = eventWindow.document.getElementById("calendar-item-panel-iframe"); + await TestUtils.waitForCondition(() => iframe.contentWindow.onLoad?.hasLoaded); + + await callback(eventWindow, iframe.contentWindow); +} + +/** + * Checks if Alarm-Icon is shown on a given Event-Box. + * + * @param {Window} window - Main window. + * @param {number} week - Week to check between 1-6. + * @param {number} day - Day to check between 1-7. + */ +function checkMonthAlarmIcon(window, week, day) { + let dayBox = lazy.CalendarTestUtils.monthView.getItemAt(window, week, day, 1); + Assert.ok(dayBox.querySelector(".alarm-icons-box > .reminder-icon")); +} diff --git a/comm/calendar/test/ICSServer.jsm b/comm/calendar/test/ICSServer.jsm new file mode 100644 index 0000000000..c0f120ec2a --- /dev/null +++ b/comm/calendar/test/ICSServer.jsm @@ -0,0 +1,153 @@ +/* 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"]; + +const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs"); +const { CommonUtils } = ChromeUtils.importESModule("resource://services-common/utils.sys.mjs"); +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/comm/calendar/test/ItemEditingHelpers.jsm b/comm/calendar/test/ItemEditingHelpers.jsm new file mode 100644 index 0000000000..812f5008cb --- /dev/null +++ b/comm/calendar/test/ItemEditingHelpers.jsm @@ -0,0 +1,681 @@ +/* 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 = [ + "cancelItemDialog", + "formatDate", + "formatTime", + "menulistSelect", + "saveAndCloseItemDialog", + "setData", +]; + +var { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs"); +var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs"); +var { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); +var { sendString, synthesizeKey, synthesizeMouseAtCenter } = ChromeUtils.import( + "resource://testing-common/mozmill/EventUtils.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalDateTime } = ChromeUtils.import("resource:///modules/CalDateTime.jsm"); +var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); + +function sleep(window, time = 0) { + return new Promise(resolve => window.setTimeout(resolve, time)); +} + +var dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "short" }); +var dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeZone: "UTC", +}); +var timeFormatter = new Services.intl.DateTimeFormat(undefined, { + timeStyle: "short", + timeZone: "UTC", +}); + +/** + * Formats a date for input in a datepicker. Don't use cal.dtz.formatter methods + * for this as they use the application locale but datepicker uses the OS locale. + * + * @param {calIDateTime} date + * @returns {string} + */ +function formatDate(date) { + if (date.isDate) { + return dateFormatter.format(cal.dtz.dateTimeToJsDate(date)); + } + + return dateTimeFormatter.format(cal.dtz.dateTimeToJsDate(date)); +} + +/** + * Formats a time for input in a timepicker. Don't use cal.dtz.formatter methods + * for this as they use the application locale but timepicker uses the OS locale. + * + * @param {calIDateTime} time + * @returns {string} + */ +function formatTime(time) { + return timeFormatter.format(cal.dtz.dateTimeToJsDate(time)); +} + +/** + * @callback dialogCallback + * @param {Window} - The calendar-event-dialog-recurrence.xhtml dialog. + */ + +/** + * Helper function to enter event/task dialog data. + * + * @param {Window} dialogWindow - Item dialog outer window. + * @param {Window} iframeWindow - Item dialog inner iframe. + * @param {object} data + * @param {string} data.title - Item title. + * @param {string} data.location - Item location. + * @param {string} data.description - Item description. + * @param {string[]} data.categories - Category names to set - leave empty to clear. + * @param {string} data.calendar - ID of the calendar the item should be in. + * @param {boolean} data.allday + * @param {calIDateTime} data.startdate + * @param {calIDateTime} data.starttime + * @param {calIDateTime} data.enddate + * @param {calIDateTime} data.endtime + * @param {boolean} data.timezonedisplay - false for hidden, true for shown. + * @param {string} data.timezone - String identifying the timezone. + * @param {string|dialogCallback} data.repeat - Recurrence value, one of + * none/daily/weekly/every.weekday/bi.weekly/monthly/yearly or a callback function to set a + * custom value. + * @param {calIDateTime} data.repeatuntil + * @param {string} data.reminder - + * none/0minutes/5minutes/15minutes/30minutes/1hour/2hours/12hours/1day/2days/1week + * (Custom is not supported.) + * @param {string} data.priority - none/low/normal/high + * @param {string} data.privacy - public/confidential/private + * @param {string} data.status - none/tentative/confirmed/canceled for events + * none/needs-action/in-process/completed/cancelled for tasks + * @param {calIDateTime} data.completed - Completion date (tasks only) + * @param {string} data.percent - Percentage complete (tasks only) + * @param {string} data.freebusy - free/busy + * @param {string} data.attachment.add - URL to add + * @param {string} data.attachment.remove - Label of url to remove. (without http://) + * @param {string} data.attendees.add - Email of attendees to add, comma separated. + * @param {string} data.attendees.remove - Email of attendees to remove, comma separated. + */ +async function setData(dialogWindow, iframeWindow, data) { + function replaceText(input, text) { + synthesizeMouseAtCenter(input, {}, iframeWindow); + synthesizeKey("a", { accelKey: true }, iframeWindow); + sendString(text, iframeWindow); + } + + let dialogDocument = dialogWindow.document; + let iframeDocument = iframeWindow.document; + + let isEvent = iframeWindow.calendarItem.isEvent(); + let startPicker = iframeDocument.getElementById(isEvent ? "event-starttime" : "todo-entrydate"); + let endPicker = iframeDocument.getElementById(isEvent ? "event-endtime" : "todo-duedate"); + + let startdateInput = startPicker._datepicker._inputField; + let enddateInput = endPicker._datepicker._inputField; + let starttimeInput = startPicker._timepicker._inputField; + let endtimeInput = endPicker._timepicker._inputField; + let completeddateInput = iframeDocument.getElementById("completed-date-picker")._inputField; + let untilDateInput = iframeDocument.getElementById("repeat-until-datepicker")._inputField; + + // Wait for input elements' values to be populated. + await sleep(iframeWindow, 500); + + // title + if (data.title !== undefined) { + let titleInput = iframeDocument.getElementById("item-title"); + replaceText(titleInput, data.title); + } + + // location + if (data.location !== undefined) { + let locationInput = iframeDocument.getElementById("item-location"); + replaceText(locationInput, data.location); + } + + // categories + if (data.categories !== undefined) { + await setCategories(iframeWindow, data.categories); + await sleep(iframeWindow); + } + + // calendar + if (data.calendar !== undefined) { + await menulistSelect(iframeDocument.getElementById("item-calendar"), data.calendar); + await sleep(iframeWindow); + } + + // all-day + if (data.allday !== undefined && isEvent) { + let checkbox = iframeDocument.getElementById("event-all-day"); + if (checkbox.checked != data.allday) { + synthesizeMouseAtCenter(checkbox, {}, iframeWindow); + } + } + + // timezonedisplay + if (data.timezonedisplay !== undefined) { + let menuitem = dialogDocument.getElementById("options-timezones-menuitem"); + if (menuitem.getAttribute("checked") != data.timezonedisplay) { + synthesizeMouseAtCenter(menuitem, {}, iframeWindow); + } + } + + // timezone + if (data.timezone !== undefined) { + await setTimezone(dialogWindow, iframeWindow, data.timezone); + } + + // startdate + if ( + data.startdate !== undefined && + (data.startdate instanceof CalDateTime || data.startdate instanceof Ci.calIDateTime) + ) { + let startdate = formatDate(data.startdate); + + if (!isEvent) { + let checkbox = iframeDocument.getElementById("todo-has-entrydate"); + if (!checkbox.checked) { + synthesizeMouseAtCenter(checkbox, {}, iframeWindow); + } + } + replaceText(startdateInput, startdate); + } + + // starttime + if ( + data.starttime !== undefined && + (data.starttime instanceof CalDateTime || data.starttime instanceof Ci.calIDateTime) + ) { + let starttime = formatTime(data.starttime); + replaceText(starttimeInput, starttime); + await sleep(iframeWindow); + } + + // enddate + if ( + data.enddate !== undefined && + (data.enddate instanceof CalDateTime || data.enddate instanceof Ci.calIDateTime) + ) { + let enddate = formatDate(data.enddate); + if (!isEvent) { + let checkbox = iframeDocument.getElementById("todo-has-duedate"); + if (!checkbox.checked) { + synthesizeMouseAtCenter(checkbox, {}, iframeWindow); + } + } + replaceText(enddateInput, enddate); + } + + // endtime + if ( + data.endtime !== undefined && + (data.endtime instanceof CalDateTime || data.endtime instanceof Ci.calIDateTime) + ) { + let endtime = formatTime(data.endtime); + replaceText(endtimeInput, endtime); + } + + // recurrence + if (data.repeat !== undefined) { + if (typeof data.repeat == "function") { + let repeatWindowPromise = BrowserTestUtils.promiseAlertDialog( + undefined, + "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml", + { + async callback(recurrenceWindow) { + Assert.report(false, undefined, undefined, "Recurrence dialog opened"); + await TestUtils.waitForCondition( + () => Services.focus.activeWindow == recurrenceWindow, + "recurrence dialog active" + ); + + await new Promise(resolve => recurrenceWindow.setTimeout(resolve, 500)); + await data.repeat(recurrenceWindow); + }, + } + ); + await Promise.all([ + menulistSelect(iframeDocument.getElementById("item-repeat"), "custom"), + repeatWindowPromise, + ]); + Assert.report(false, undefined, undefined, "Recurrence dialog closed"); + } else { + await menulistSelect(iframeDocument.getElementById("item-repeat"), data.repeat); + } + } + if ( + data.repeatuntil !== undefined && + (data.repeatuntil instanceof CalDateTime || data.repeatuntil instanceof Ci.calIDateTime) + ) { + // Only fill in date, when the Datepicker is visible. + if (!iframeDocument.getElementById("repeat-untilDate").hidden) { + let untildate = formatDate(data.repeatuntil); + replaceText(untilDateInput, untildate); + } + } + + // reminder + if (data.reminder !== undefined) { + await setReminderMenulist(iframeWindow, data.reminder); + } + + // priority + if (data.priority !== undefined) { + dialogDocument.getElementById(`options-priority-${data.priority}-label`).click(); + } + + // privacy + if (data.privacy !== undefined) { + let button = dialogDocument.getElementById("button-privacy"); + let shownPromise = BrowserTestUtils.waitForEvent(button, "popupshown"); + synthesizeMouseAtCenter(button, {}, dialogWindow); + await shownPromise; + let hiddenPromise = BrowserTestUtils.waitForEvent(button, "popuphidden"); + synthesizeMouseAtCenter( + dialogDocument.getElementById(`event-privacy-${data.privacy}-menuitem`), + {}, + dialogWindow + ); + await hiddenPromise; + await sleep(iframeWindow); + } + + // status + if (data.status !== undefined) { + if (isEvent) { + dialogDocument.getElementById(`options-status-${data.status}-menuitem`).click(); + } else { + await menulistSelect(iframeDocument.getElementById("todo-status"), data.status.toUpperCase()); + } + } + + let currentStatus = iframeDocument.getElementById("todo-status").value; + + // completed on + if ( + data.completed !== undefined && + (data.completed instanceof CalDateTime || data.completed instanceof Ci.calIDateTime) && + !isEvent + ) { + let completeddate = formatDate(data.completed); + if (currentStatus == "COMPLETED") { + replaceText(completeddateInput, completeddate); + } + } + + // percent complete + if ( + data.percent !== undefined && + (currentStatus == "NEEDS-ACTION" || + currentStatus == "IN-PROCESS" || + currentStatus == "COMPLETED") + ) { + let percentCompleteInput = iframeDocument.getElementById("percent-complete-textbox"); + replaceText(percentCompleteInput, data.percent); + } + + // free/busy + if (data.freebusy !== undefined) { + dialogDocument.getElementById(`options-freebusy-${data.freebusy}-menuitem`).click(); + } + + // description + if (data.description !== undefined) { + synthesizeMouseAtCenter( + iframeDocument.getElementById("event-grid-tab-description"), + {}, + iframeWindow + ); + let descField = iframeDocument.getElementById("item-description"); + replaceText(descField, data.description); + } + + // attachment + if (data.attachment !== undefined) { + if (data.attachment.add !== undefined) { + await handleAddingAttachment(dialogWindow, data.attachment.add); + } + if (data.attachment.remove !== undefined) { + synthesizeMouseAtCenter( + iframeDocument.getElementById("event-grid-tab-attachments"), + {}, + iframeWindow + ); + let attachmentBox = iframeDocument.getElementById("attachment-link"); + let attachments = attachmentBox.children; + for (let attachment of attachments) { + if (attachment.tooltipText.includes(data.attachment.remove)) { + synthesizeMouseAtCenter(attachment, {}, iframeWindow); + synthesizeKey("VK_DELETE", {}, dialogWindow); + } + } + } + } + + // attendees + if (data.attendees !== undefined) { + // Display attendees Tab. + synthesizeMouseAtCenter( + iframeDocument.getElementById("event-grid-tab-attendees"), + {}, + iframeWindow + ); + // Make sure no notifications are sent, since handling this dialog is + // not working when deleting a parent of a recurring event. + let attendeeCheckbox = iframeDocument.getElementById("notify-attendees-checkbox"); + if (!attendeeCheckbox.disabled && attendeeCheckbox.checked) { + synthesizeMouseAtCenter(attendeeCheckbox, {}, iframeWindow); + } + + // add + if (data.attendees.add !== undefined) { + await addAttendees(dialogWindow, iframeWindow, data.attendees.add); + } + // delete + if (data.attendees.remove !== undefined) { + await deleteAttendees(iframeWindow, data.attendees.remove); + } + } + + await sleep(iframeWindow); +} + +/** + * Closes an event dialog window, saving the event. + * + * @param {Window} dialogWindow + */ +async function saveAndCloseItemDialog(dialogWindow) { + let dialogClosing = BrowserTestUtils.domWindowClosed(dialogWindow); + synthesizeMouseAtCenter( + dialogWindow.document.getElementById("button-saveandclose"), + {}, + dialogWindow + ); + await dialogClosing; + Assert.report(false, undefined, undefined, "Item dialog closed"); + await new Promise(resolve => setTimeout(resolve)); +} + +/** + * Closes an event dialog window, discarding any changes. + * + * @param {Window} dialogWindow + */ +function cancelItemDialog(dialogWindow) { + synthesizeKey("VK_ESCAPE", {}, dialogWindow); +} + +/** + * Select an item in the reminder menulist. + * Custom reminders are not supported. + * + * @param {Window} iframeWindow - The event dialog iframe. + * @param {string} id - Identifying string of menuitem id. + */ +async function setReminderMenulist(iframeWindow, id) { + let iframeDocument = iframeWindow.document; + let menulist = iframeDocument.querySelector(".item-alarm"); + let menuitem = iframeDocument.getElementById(`reminder-${id}-menuitem`); + + Assert.ok(menulist, `<menulist id=${menulist.id}> exists`); + Assert.ok(menuitem, `<menuitem id=${id}> exists`); + + menulist.focus(); + + synthesizeMouseAtCenter(menulist, {}, iframeWindow); + await BrowserTestUtils.waitForEvent(menulist, "popupshown"); + synthesizeMouseAtCenter(menuitem, {}, iframeWindow); + await BrowserTestUtils.waitForEvent(menulist, "popuphidden"); + await sleep(iframeWindow); +} + +/** + * Set the categories in the event-dialog menulist-panel. + * + * @param {Window} iframeWindow - The event dialog iframe. + * @param {string[]} categories - Category names to set - leave empty to clear. + */ +async function setCategories(iframeWindow, categories) { + let iframeDocument = iframeWindow.document; + let menulist = iframeDocument.getElementById("item-categories"); + let menupopup = iframeDocument.getElementById("item-categories-popup"); + + synthesizeMouseAtCenter(menulist, {}, iframeWindow); + await BrowserTestUtils.waitForEvent(menupopup, "popupshown"); + + // Iterate over categories and check if needed. + for (let item of menupopup.children) { + if (categories.includes(item.label)) { + item.setAttribute("checked", "true"); + } else { + item.removeAttribute("checked"); + } + } + + let hiddenPromise = BrowserTestUtils.waitForEvent(menupopup, "popuphidden"); + menupopup.hidePopup(); + await hiddenPromise; +} + +/** + * Add an URL attachment. + * + * @param {Window} dialogWindow - The event dialog. + * @param {string} url - URL to be added. + */ +async function handleAddingAttachment(dialogWindow, url) { + let dialogDocument = dialogWindow.document; + let attachButton = dialogDocument.querySelector("#button-url"); + let menu = dialogDocument.querySelector("#button-attach-menupopup"); + let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown"); + synthesizeMouseAtCenter(attachButton, {}, dialogWindow); + await menuShowing; + + let dialogPromise = BrowserTestUtils.promiseAlertDialog(undefined, undefined, { + async callback(attachmentWindow) { + Assert.report(false, undefined, undefined, "Attachment dialog opened"); + await TestUtils.waitForCondition( + () => Services.focus.activeWindow == attachmentWindow, + "attachment dialog active" + ); + + let attachmentDocument = attachmentWindow.document; + attachmentDocument.getElementById("loginTextbox").value = url; + attachmentDocument.querySelector("dialog").getButton("accept").click(); + }, + }); + synthesizeMouseAtCenter(dialogDocument.querySelector("#button-attach-url"), {}, dialogWindow); + await dialogPromise; + Assert.report(false, undefined, undefined, "Attachment dialog closed"); + await sleep(dialogWindow); +} + +/** + * Add attendees to the event. + * + * @param {Window} dialogWindow - The event dialog. + * @param {Window} iframeWindow - The event dialog iframe. + * @param {string} attendeesString - Comma separated list of email-addresses to add. + */ +async function addAttendees(dialogWindow, iframeWindow, attendeesString) { + let dialogDocument = dialogWindow.document; + + let attendees = attendeesString.split(","); + for (let attendee of attendees) { + let calAttendee = iframeWindow.attendees.find(aAtt => aAtt.id == `mailto:${attendee}`); + // Only add if not already present. + if (!calAttendee) { + let dialogPromise = BrowserTestUtils.promiseAlertDialog( + undefined, + "chrome://calendar/content/calendar-event-dialog-attendees.xhtml", + { + async callback(attendeesWindow) { + Assert.report(false, undefined, undefined, "Attendees dialog opened"); + await TestUtils.waitForCondition( + () => Services.focus.activeWindow == attendeesWindow, + "attendees dialog active" + ); + + let attendeesDocument = attendeesWindow.document; + Assert.equal(attendeesDocument.activeElement.localName, "input"); + Assert.equal( + attendeesDocument.activeElement.value, + "", + "active input value should be empty" + ); + sendString(attendee, attendeesWindow); + Assert.report(false, undefined, undefined, `Sent attendee ${attendee}`); + // Windows needs the focus() call here. + attendeesDocument.querySelector("dialog").getButton("accept").focus(); + synthesizeMouseAtCenter( + attendeesDocument.querySelector("dialog").getButton("accept"), + {}, + attendeesWindow + ); + }, + } + ); + synthesizeMouseAtCenter(dialogDocument.getElementById("button-attendees"), {}, dialogWindow); + await dialogPromise; + Assert.report(false, undefined, undefined, "Attendees dialog closed"); + await sleep(iframeWindow); + } + } +} + +/** + * Delete attendees from the event. + * + * @param {Window} iframeWindow - The event dialog iframe. + * @param {string} attendeesString - Comma separated list of email-addresses to delete. + */ +async function deleteAttendees(iframeWindow, attendeesString) { + let iframeDocument = iframeWindow.document; + let menupopup = iframeDocument.getElementById("attendee-popup"); + + // Now delete the attendees. + let attendees = attendeesString.split(","); + for (let attendee of attendees) { + let attendeeToDelete = iframeDocument.querySelector( + `.attendee-list [attendeeid="mailto:${attendee}"]` + ); + if (attendeeToDelete) { + attendeeToDelete.focus(); + synthesizeMouseAtCenter(attendeeToDelete, { type: "contextmenu" }, iframeWindow); + await BrowserTestUtils.waitForEvent(menupopup, "popupshown"); + menupopup.activateItem( + iframeDocument.getElementById("attendee-popup-removeattendee-menuitem") + ); + await BrowserTestUtils.waitForEvent(menupopup, "popuphidden"); + } + } + await sleep(iframeWindow); +} + +/** + * Set the timezone for the item + * + * @param {Window} dialogWindow - The event dialog. + * @param {Window} iframeWindow - The event dialog iframe. + * @param {string} timezone - String identifying the timezone. + */ +async function setTimezone(dialogWindow, iframeWindow, timezone) { + let dialogDocument = dialogWindow.document; + let iframeDocument = iframeWindow.document; + + let menuitem = dialogDocument.getElementById("options-timezones-menuitem"); + let label = iframeDocument.getElementById("timezone-starttime"); + let menupopup = iframeDocument.getElementById("timezone-popup"); + let customMenuitem = iframeDocument.getElementById("timezone-custom-menuitem"); + + if (!BrowserTestUtils.is_visible(label)) { + menuitem.click(); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(label), + "Timezone label should become visible" + ); + } + + await TestUtils.waitForCondition(() => !label.disabled, "Tiemzone label should become enabled"); + + let shownPromise = BrowserTestUtils.waitForEvent(menupopup, "popupshown"); + let dialogPromise = BrowserTestUtils.promiseAlertDialog( + undefined, + "chrome://calendar/content/calendar-event-dialog-timezone.xhtml", + { + async callback(timezoneWindow) { + Assert.report(false, undefined, undefined, "Timezone dialog opened"); + await TestUtils.waitForCondition( + () => Services.focus.activeWindow == timezoneWindow, + "timezone dialog active" + ); + + let timezoneDocument = timezoneWindow.document; + let timezoneMenulist = timezoneDocument.getElementById("timezone-menulist"); + let timezoneMenuitem = timezoneMenulist.querySelector(`[value="${timezone}"]`); + + let popupshown = BrowserTestUtils.waitForEvent(timezoneMenulist, "popupshown"); + synthesizeMouseAtCenter(timezoneMenulist, {}, timezoneWindow); + await popupshown; + + timezoneMenuitem.scrollIntoView(); + + let popuphidden = BrowserTestUtils.waitForEvent(timezoneMenulist, "popuphidden"); + synthesizeMouseAtCenter(timezoneMenuitem, {}, timezoneWindow); + await popuphidden; + + synthesizeMouseAtCenter( + timezoneDocument.querySelector("dialog").getButton("accept"), + {}, + timezoneWindow + ); + }, + } + ); + + synthesizeMouseAtCenter(label, {}, iframeWindow); + await shownPromise; + + synthesizeMouseAtCenter(customMenuitem, {}, iframeWindow); + await dialogPromise; + Assert.report(false, undefined, undefined, "Timezone dialog closed"); + + await new Promise(resolve => iframeWindow.setTimeout(resolve, 500)); +} + +/** + * Selects an item from a menulist. + * + * @param {Element} menulist + * @param {string} value + */ +async function menulistSelect(menulist, value) { + let win = menulist.ownerGlobal; + Assert.ok(menulist, `<menulist id=${menulist.id}> exists`); + let menuitem = menulist.querySelector(`menupopup > menuitem[value='${value}']`); + Assert.ok(menuitem, `<menuitem value=${value}> exists`); + + menulist.focus(); + + let shownPromise = BrowserTestUtils.waitForEvent(menulist, "popupshown"); + synthesizeMouseAtCenter(menulist, {}, win); + await shownPromise; + + let hiddenPromise = BrowserTestUtils.waitForEvent(menulist, "popuphidden"); + synthesizeMouseAtCenter(menuitem, {}, win); + await hiddenPromise; + + await new Promise(resolve => win.setTimeout(resolve)); + Assert.equal(menulist.value, value); +} diff --git a/comm/calendar/test/browser/browser.ini b/comm/calendar/test/browser/browser.ini new file mode 100644 index 0000000000..5eb780310b --- /dev/null +++ b/comm/calendar/test/browser/browser.ini @@ -0,0 +1,39 @@ +[default] +head = head.js +prefs = + calendar.debug.log=true + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.oauth.loglevel=Debug + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank + signon.rememberSignons=true +subsuite = thunderbird +support-files = data/** + +[browser_basicFunctionality.js] +[browser_calDAV_discovery.js] +[browser_calDAV_oAuth.js] +tags = oauth +[browser_calendarList.js] +[browser_calendarTelemetry.js] +[browser_calendarUnifinder.js] +[browser_dragEventItem.js] +[browser_eventDisplay_dayView.js] +[browser_eventDisplay_multiWeekView.js] +[browser_eventDisplay_weekView.js] +[browser_eventUndoRedo.js] +[browser_import.js] +[browser_localICS.js] +[browser_taskDelete.js] +[browser_taskUndoRedo.js] +[browser_tabs.js] +[browser_taskDisplay.js] +[browser_todayPane.js] +[browser_todayPane_dragAndDrop.js] +[browser_todayPane_visibility.js] diff --git a/comm/calendar/test/browser/browser_basicFunctionality.js b/comm/calendar/test/browser/browser_basicFunctionality.js new file mode 100644 index 0000000000..c8f0fd68b7 --- /dev/null +++ b/comm/calendar/test/browser/browser_basicFunctionality.js @@ -0,0 +1,78 @@ +/* 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/. */ + +/* globals createCalendarUsingDialog */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +add_task(async function testBasicFunctionality() { + const calendarName = "Mochitest"; + + registerCleanupFunction(() => { + for (let calendar of cal.manager.getCalendars()) { + if (calendar.name == calendarName) { + cal.manager.removeCalendar(calendar); + } + } + Services.focus.focusedWindow = window; + }); + + Services.focus.focusedWindow = window; + + // Create test calendar. + await createCalendarUsingDialog(calendarName); + + // Check for minimonth, every month has a day 1. + Assert.ok( + document.querySelector("#calMinimonth .minimonth-cal-box td[aria-label='1']"), + "day 1 exists in the minimonth" + ); + + // Check for calendar list. + Assert.ok(document.querySelector("#calendar-list-pane"), "calendar list pane exists"); + Assert.ok(document.querySelector("#calendar-list"), "calendar list exists"); + + // Check for event search. + Assert.ok(document.querySelector("#bottom-events-box"), "event search box exists"); + + // There should be search field. + Assert.ok(document.querySelector("#unifinder-search-field"), "unifinded search field exists"); + + // Make sure the week view is the default selected view. + Assert.ok( + document + .querySelector(`.calview-toggle-item[aria-selected="true"]`) + .getAttribute("aria-controls") == "week-view", + "week-view toggle is the current default" + ); + + let dayViewButton = document.querySelector("#calTabDay"); + dayViewButton.click(); + Assert.ok(dayViewButton.getAttribute("aria-selected"), "day view button is selected"); + await CalendarTestUtils.ensureViewLoaded(window); + + // Day view should have 09:00 box. + let someTime = cal.createDateTime(); + someTime.resetTo(someTime.year, someTime.month, someTime.day, 9, 0, 0, someTime.timezone); + let label = cal.dtz.formatter.formatTime(someTime); + let labelEl = document.querySelectorAll("#day-view .multiday-timebar .multiday-hour-box")[9]; + Assert.ok(labelEl, "9th hour box should exist"); + Assert.equal(labelEl.textContent, label, "9th hour box should show the correct time"); + Assert.ok(CalendarTestUtils.dayView.getHourBoxAt(window, 9), "09:00 box exists"); + + // Open tasks view. + document.querySelector("#tasksButton").click(); + + // Should be possible to filter today's tasks. + Assert.ok(document.querySelector("#opt_today_filter"), "show today radio button exists"); + + // Check for task add button. + Assert.ok(document.querySelector("#calendar-add-task-button"), "task add button exists"); + + // Check for filtered tasks list. + Assert.ok( + document.querySelector("#calendar-task-tree .calendar-task-treechildren"), + "filtered tasks list exists" + ); +}); diff --git a/comm/calendar/test/browser/browser_calDAV_discovery.js b/comm/calendar/test/browser/browser_calDAV_discovery.js new file mode 100644 index 0000000000..217bf76f55 --- /dev/null +++ b/comm/calendar/test/browser/browser_calDAV_discovery.js @@ -0,0 +1,241 @@ +/* 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/calendar/CalDAVServer.jsm"); +var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); + +async function openWizard(...args) { + await CalendarTestUtils.openCalendarTab(window); + let wizardPromise = BrowserTestUtils.promiseAlertDialog( + undefined, + "chrome://calendar/content/calendar-creation.xhtml", + { + callback: wizardWindow => handleWizard(wizardWindow, ...args), + } + ); + EventUtils.synthesizeMouseAtCenter( + document.querySelector("#newCalendarSidebarButton"), + {}, + window + ); + return wizardPromise; +} + +async function handleWizard(wizardWindow, { username, url, password, expectedCalendars }) { + let wizardDocument = wizardWindow.document; + let acceptButton = wizardDocument.querySelector("dialog").getButton("accept"); + let cancelButton = wizardDocument.querySelector("dialog").getButton("cancel"); + + // Select calendar type. + + EventUtils.synthesizeMouseAtCenter( + wizardDocument.querySelector(`radio[value="network"]`), + {}, + wizardWindow + ); + EventUtils.synthesizeMouseAtCenter(acceptButton, {}, wizardWindow); + + // Network calendar settings. + + Assert.ok(acceptButton.disabled); + Assert.equal(wizardDocument.activeElement.id, "network-username-input"); + if (username) { + EventUtils.sendString(username, wizardWindow); + } + + if (username?.includes("@")) { + Assert.equal( + wizardDocument.getElementById("network-location-input").placeholder, + username.replace(/^.*@/, "") + ); + } + + EventUtils.synthesizeKey("VK_TAB", {}, wizardWindow); + Assert.equal(wizardDocument.activeElement.id, "network-location-input"); + if (url) { + EventUtils.sendString(url, wizardWindow); + } + + Assert.ok(!acceptButton.disabled); + + let promptPromise = handlePasswordPrompt(password); + EventUtils.synthesizeKey("VK_RETURN", {}, wizardWindow); + await promptPromise; + + // Select calendars. + + let list = wizardDocument.getElementById("network-calendar-list"); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(list), + "waiting for calendar list to appear", + 200, + 100 + ); + + Assert.equal(list.childElementCount, expectedCalendars.length); + for (let i = 0; i < expectedCalendars.length; i++) { + let item = list.children[i]; + + Assert.equal(item.calendar.uri.spec, expectedCalendars[i].uri); + Assert.equal( + item.querySelector(".calendar-color").style.backgroundColor, + expectedCalendars[i].color + ); + Assert.equal(item.querySelector(".calendar-name").value, expectedCalendars[i].name); + + if (expectedCalendars[i].hasOwnProperty("readOnly")) { + Assert.equal( + item.calendar.readOnly, + expectedCalendars[i].readOnly, + `calendar read-only property is ${expectedCalendars[i].readOnly}` + ); + } + } + EventUtils.synthesizeMouseAtCenter(cancelButton, {}, wizardWindow); +} + +async function handlePasswordPrompt(password) { + return BrowserTestUtils.promiseAlertDialog(null, undefined, { + async callback(prompt) { + await new Promise(resolve => prompt.setTimeout(resolve)); + + prompt.document.getElementById("password1Textbox").value = password; + + let checkbox = prompt.document.getElementById("checkbox"); + Assert.greater(checkbox.getBoundingClientRect().width, 0); + Assert.ok(checkbox.checked); + + prompt.document.querySelector("dialog").getButton("accept").click(); + }, + }); +} + +/** + * Test that we correctly use DNS discovery. This uses the mochitest server + * (files in the data directory) instead of CalDAVServer because the latter + * can't speak HTTPS, and we only do DNS discovery for HTTPS. + */ +add_task(async function testDNS() { + var _srv = DNS.srv; + var _txt = DNS.txt; + DNS.srv = function (name) { + Assert.equal(name, "_caldavs._tcp.dnstest.invalid"); + return [{ prio: 0, weight: 0, host: "example.org", port: 443 }]; + }; + DNS.txt = function (name) { + Assert.equal(name, "_caldavs._tcp.dnstest.invalid"); + return [{ data: "path=/browser/comm/calendar/test/browser/data/dns.sjs" }]; + }; + + await openWizard({ + username: "carol@dnstest.invalid", + password: "carol", + expectedCalendars: [ + { + uri: "https://example.org/browser/comm/calendar/test/browser/data/calendar.sjs", + name: "You found me!", + color: "rgb(0, 128, 0)", + }, + { + uri: "https://example.org/browser/comm/calendar/test/browser/data/calendar2.sjs", + name: "RΓΆda dagar", + color: "rgb(255, 0, 0)", + }, + ], + }); + + DNS.srv = _srv; + DNS.txt = _txt; +}); + +/** + * Test that the magic URL /.well-known/caldav works. + */ +add_task(async function testWellKnown() { + CalDAVServer.open("alice", "alice"); + + await openWizard({ + username: "alice", + url: CalDAVServer.origin, + password: "alice", + expectedCalendars: [ + { + uri: CalDAVServer.url, + name: "CalDAV Test", + color: "rgb(255, 128, 0)", + }, + ], + }); + + CalDAVServer.close(); +}); + +/** + * Tests calendars with only the "read" "current-user-privilege-set" are + * flagged read-only. + */ +add_task(async function testCalendarWithOnlyReadPriv() { + CalDAVServer.open("alice", "alice"); + CalDAVServer.privileges = "<d:privilege><d:read/></d:privilege>"; + await openWizard({ + username: "alice", + url: CalDAVServer.origin, + password: "alice", + expectedCalendars: [ + { + uri: CalDAVServer.url, + name: "CalDAV Test", + color: "rgb(255, 128, 0)", + readOnly: true, + }, + ], + }); + CalDAVServer.close(); +}); + +/** + * Tests calendars that return none of the expected values for "current-user-privilege-set" + * are flagged read-only. + */ +add_task(async function testCalendarWithoutPrivs() { + CalDAVServer.open("alice", "alice"); + CalDAVServer.privileges = ""; + await openWizard({ + username: "alice", + url: CalDAVServer.origin, + password: "alice", + expectedCalendars: [ + { + uri: CalDAVServer.url, + name: "CalDAV Test", + color: "rgb(255, 128, 0)", + readOnly: true, + }, + ], + }); + CalDAVServer.close(); +}); + +/** + * Tests calendars that return status 404 for "current-user-privilege-set" are + * not flagged read-only. + */ +add_task(async function testCalendarWithNoPrivSupport() { + CalDAVServer.open("alice", "alice"); + CalDAVServer.privileges = null; + await openWizard({ + username: "alice", + url: CalDAVServer.origin, + password: "alice", + expectedCalendars: [ + { + uri: CalDAVServer.url, + name: "CalDAV Test", + color: "rgb(255, 128, 0)", + readOnly: false, + }, + ], + }); + CalDAVServer.close(); +}); diff --git a/comm/calendar/test/browser/browser_calDAV_oAuth.js b/comm/calendar/test/browser/browser_calDAV_oAuth.js new file mode 100644 index 0000000000..4d9c733076 --- /dev/null +++ b/comm/calendar/test/browser/browser_calDAV_oAuth.js @@ -0,0 +1,201 @@ +/* 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/. */ + +// Creates calendars in various configurations (current and legacy) and performs +// requests in each of them to prove that OAuth2 authentication is working as expected. + +var { CalDavCalendar } = ChromeUtils.import("resource:///modules/CalDavCalendar.jsm"); +var { CalDavGenericRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm"); + +var LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +// Ideal login info. This is what would be saved if you created a new calendar. +const ORIGIN = "oauth://mochi.test"; +const SCOPE = "test_scope"; +const USERNAME = "bob@test.invalid"; +const VALID_TOKEN = "bobs_refresh_token"; + +/** + * Set a string pref for the given calendar. + * + * @param {string} calendarId + * @param {string} key + * @param {string} value + */ +function setPref(calendarId, key, value) { + Services.prefs.setStringPref(`calendar.registry.${calendarId}.${key}`, value); +} + +/** + * Clear any existing saved logins and add the given ones. + * + * @param {string[][]} - Zero or more arrays consisting of origin, realm, username, and password. + */ +function setLogins(...logins) { + Services.logins.removeAllLogins(); + for (let [origin, realm, username, password] of logins) { + Services.logins.addLogin(new LoginInfo(origin, null, realm, username, password, "", "")); + } +} + +/** + * Create a calendar with the given id, perform a request, and check that the correct + * authorisation header was used. If the user is required to re-authenticate with the provider, + * check that the new token is stored in the right place. + * + * @param {string} calendarId - ID of the new calendar + * @param {string} [newTokenUsername] - If given, re-authentication must happen and the new token + * stored with this user name. + */ +async function subtest(calendarId, newTokenUsername) { + let calendar = new CalDavCalendar(); + calendar.id = calendarId; + + let request = new CalDavGenericRequest( + calendar.wrappedJSObject.session, + calendar, + "GET", + Services.io.newURI( + "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs" + ) + ); + let response = await request.commit(); + let headers = JSON.parse(response.text); + + if (newTokenUsername) { + Assert.equal(headers.authorization, "Bearer new_access_token"); + + let logins = Services.logins + .findLogins(ORIGIN, null, SCOPE) + .filter(l => l.username == newTokenUsername); + Assert.equal(logins.length, 1); + Assert.equal(logins[0].username, newTokenUsername); + Assert.equal(logins[0].password, "new_refresh_token"); + } else { + Assert.equal(headers.authorization, "Bearer bobs_access_token"); + } + + Services.logins.removeAllLogins(); +} + +// Test making a request when there is no matching token stored. + +/** No token stored, no username or session ID set. */ +add_task(function testCalendarOAuth_id_none() { + let calendarId = "testCalendarOAuth_id_none"; + return subtest(calendarId, calendarId); +}); + +/** No token stored, session ID set. */ +add_task(function testCalendarOAuth_sessionId_none() { + let calendarId = "testCalendarOAuth_sessionId_none"; + setPref(calendarId, "sessionId", "test_session"); + return subtest(calendarId, "test_session"); +}); + +/** No token stored, username set. */ +add_task(function testCalendarOAuth_username_none() { + let calendarId = "testCalendarOAuth_username_none"; + setPref(calendarId, "username", USERNAME); + return subtest(calendarId, USERNAME); +}); + +// Test making a request when there IS a matching token, but the server rejects it. +// Currently a new token is not requested on failure. + +/** Expired token stored with calendar ID. */ +add_task(function testCalendarOAuth_id_expired() { + let calendarId = "testCalendarOAuth_id_expired"; + setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]); + return subtest(calendarId, calendarId); +}).skip(); // Broken. + +/** Expired token stored with session ID. */ +add_task(function testCalendarOAuth_sessionId_expired() { + let calendarId = "testCalendarOAuth_sessionId_expired"; + setPref(calendarId, "sessionId", "test_session"); + setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", "expired_token"]); + return subtest(calendarId, "test_session"); +}).skip(); // Broken. + +/** Expired token stored with calendar ID, username set. */ +add_task(function testCalendarOAuth_username_expired() { + let calendarId = "testCalendarOAuth_username_expired"; + setPref(calendarId, "username", USERNAME); + setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]); + return subtest(calendarId, USERNAME); +}).skip(); // Broken. + +// Test making a request with a valid token, using Lightning's client ID and secret. + +/** Valid token stored with calendar ID. */ +add_task(function testCalendarOAuth_id_valid() { + let calendarId = "testCalendarOAuth_id_valid"; + setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]); + return subtest(calendarId); +}); + +/** Valid token stored with session ID. */ +add_task(function testCalendarOAuth_sessionId_valid() { + let calendarId = "testCalendarOAuth_sessionId_valid"; + setPref(calendarId, "sessionId", "test_session"); + setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", VALID_TOKEN]); + return subtest(calendarId); +}); + +/** Valid token stored with calendar ID, username set. */ +add_task(function testCalendarOAuth_username_valid() { + let calendarId = "testCalendarOAuth_username_valid"; + setPref(calendarId, "username", USERNAME); + setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]); + return subtest(calendarId, USERNAME); +}); + +// Test making a request with a valid token, using Thunderbird's client ID and secret. + +/** Valid token stored with calendar ID. */ +add_task(function testCalendarOAuthTB_id_valid() { + let calendarId = "testCalendarOAuthTB_id_valid"; + setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]); + return subtest(calendarId); +}); + +/** Valid token stored with session ID. */ +add_task(function testCalendarOAuthTB_sessionId_valid() { + let calendarId = "testCalendarOAuthTB_sessionId_valid"; + setPref(calendarId, "sessionId", "test_session"); + setLogins([ORIGIN, SCOPE, "test_session", VALID_TOKEN]); + return subtest(calendarId); +}); + +/** Valid token stored with calendar ID, username set. */ +add_task(function testCalendarOAuthTB_username_valid() { + let calendarId = "testCalendarOAuthTB_username_valid"; + setPref(calendarId, "username", USERNAME); + setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]); + return subtest(calendarId, USERNAME); +}); + +/** Valid token stored with username, exact scope. */ +add_task(function testCalendarOAuthTB_username_validSingle() { + let calendarId = "testCalendarOAuthTB_username_validSingle"; + setPref(calendarId, "username", USERNAME); + setLogins( + [ORIGIN, SCOPE, USERNAME, VALID_TOKEN], + [ORIGIN, "other_scope", USERNAME, "other_refresh_token"] + ); + return subtest(calendarId); +}); + +/** Valid token stored with username, many scopes. */ +add_task(function testCalendarOAuthTB_username_validMultiple() { + let calendarId = "testCalendarOAuthTB_username_validMultiple"; + setPref(calendarId, "username", USERNAME); + setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]); + return subtest(calendarId); +}); diff --git a/comm/calendar/test/browser/browser_calendarList.js b/comm/calendar/test/browser/browser_calendarList.js new file mode 100644 index 0000000000..b85ef5a56e --- /dev/null +++ b/comm/calendar/test/browser/browser_calendarList.js @@ -0,0 +1,341 @@ +/* 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/. */ + +async function calendarListContextMenu(target, menuItem) { + await new Promise(r => setTimeout(r)); + window.focus(); + await TestUtils.waitForCondition( + () => Services.focus.focusedWindow == window, + "waiting for window to be focused" + ); + + // The test frequently times out if we don't wait here. Unknown why. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 250)); + + let contextMenu = document.getElementById("list-calendars-context-menu"); + let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(target, { type: "contextmenu" }); + await shownPromise; + + if (menuItem) { + let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem(document.getElementById(menuItem)); + await hiddenPromise; + } +} + +async function withMockPromptService(response, callback) { + let realPrompt = Services.prompt; + Services.prompt = { + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx: (unused1, unused2, text) => { + info(text); + return response; + }, + }; + await callback(); + Services.prompt = realPrompt; +} + +add_task(async () => { + function checkProperties(index, expected) { + let calendarList = document.getElementById("calendar-list"); + let item = calendarList.rows[index]; + let colorImage = item.querySelector(".calendar-color"); + for (let [key, expectedValue] of Object.entries(expected)) { + switch (key) { + case "id": + Assert.equal(item.getAttribute("calendar-id"), expectedValue); + break; + case "disabled": + Assert.equal(item.querySelector(".calendar-displayed").hidden, expectedValue); + break; + case "displayed": + Assert.equal(item.querySelector(".calendar-displayed").checked, expectedValue); + break; + case "color": + if (item.hasAttribute("calendar-disabled")) { + Assert.equal(getComputedStyle(colorImage).backgroundColor, "rgba(0, 0, 0, 0)"); + } else { + Assert.equal(getComputedStyle(colorImage).backgroundColor, expectedValue); + } + break; + case "name": + Assert.equal(item.querySelector(".calendar-name").textContent, expectedValue); + break; + } + } + } + + function checkDisplayed(...expected) { + let calendarList = document.getElementById("calendar-list"); + Assert.greater(calendarList.rowCount, Math.max(...expected)); + for (let i = 0; i < calendarList.rowCount; i++) { + Assert.equal( + calendarList.rows[i].querySelector(".calendar-displayed").checked, + expected.includes(i) + ); + } + } + + function checkSortOrder(...expected) { + let orderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "wrong"); + Assert.notEqual(orderPref, "wrong", "sort order pref has a value"); + let order = orderPref.split(" "); + Assert.equal(order.length, expected.length, "sort order length"); + for (let i = 0; i < expected.length; i++) { + Assert.equal(order[i], calendars[expected[i]].id, "sort order ids"); + } + } + + let calendarList = document.getElementById("calendar-list"); + let contextMenu = document.getElementById("list-calendars-context-menu"); + let composite = cal.view.getCompositeCalendar(window); + + await CalendarTestUtils.openCalendarTab(window); + + // Check the default calendar. + let calendars = cal.manager.getCalendars(); + Assert.equal(calendars.length, 1); + Assert.equal(calendarList.rowCount, 1); + checkProperties(0, { + color: "rgb(168, 194, 225)", + name: "Home", + }); + checkSortOrder(0); + + // Test adding calendars. + + // Open and then cancel the 'create calendar' dialog, just to prove that the + // context menu works. + let dialogPromise = BrowserTestUtils.promiseAlertDialog( + "cancel", + "chrome://calendar/content/calendar-creation.xhtml" + ); + calendarListContextMenu(calendarList, "list-calendars-context-new"); + await dialogPromise; + + // Add some new calendars, check their properties. + for (let i = 1; i <= 3; i++) { + calendars[i] = CalendarTestUtils.createCalendar(`Mochitest ${i}`, "memory"); + } + + Assert.equal(cal.manager.getCalendars().length, 4); + Assert.equal(calendarList.rowCount, 4); + + for (let i = 1; i <= 3; i++) { + checkProperties(i, { + id: calendars[i].id, + displayed: true, + color: "rgb(168, 194, 225)", + name: `Mochitest ${i}`, + }); + } + checkSortOrder(0, 1, 2, 3); + + // Test the context menu. + + await new Promise(resolve => setTimeout(resolve)); + EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {}); + await new Promise(resolve => setTimeout(resolve)); + await calendarListContextMenu(calendarList.rows[1]); + await new Promise(resolve => setTimeout(resolve)); + Assert.equal( + document.getElementById("list-calendars-context-togglevisible").label, + "Hide Mochitest 1" + ); + Assert.equal( + document.getElementById("list-calendars-context-showonly").label, + "Show Only Mochitest 1" + ); + Assert.ok( + document.getElementById("list-calendar-context-reload").hidden, + "Local calendar should have reload menu showing" + ); + contextMenu.hidePopup(); + + Assert.equal(document.activeElement, calendarList); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]); + + // Test show/hide. + // TODO: Check events on calendars are hidden/shown. + + EventUtils.synthesizeMouseAtCenter(calendarList.rows[2].querySelector(".calendar-displayed"), {}); + Assert.equal(document.activeElement, calendarList); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[2]); + Assert.equal(composite.getCalendarById(calendars[2].id), null); + checkDisplayed(0, 1, 3); + + composite.removeCalendar(calendars[1]); + checkDisplayed(0, 3); + + await calendarListContextMenu(calendarList.rows[3], "list-calendars-context-togglevisible"); + checkDisplayed(0); + + EventUtils.synthesizeMouseAtCenter(calendarList.rows[2].querySelector(".calendar-displayed"), {}); + Assert.equal(composite.getCalendarById(calendars[2].id), calendars[2]); + Assert.equal(document.activeElement, calendarList); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[2]); + checkDisplayed(0, 2); + + composite.addCalendar(calendars[1]); + checkDisplayed(0, 1, 2); + + await calendarListContextMenu(calendarList.rows[3], "list-calendars-context-togglevisible"); + checkDisplayed(0, 1, 2, 3); + + await calendarListContextMenu(calendarList.rows[1], "list-calendars-context-showonly"); + checkDisplayed(1); + + await calendarListContextMenu(calendarList, "list-calendars-context-showall"); + checkDisplayed(0, 1, 2, 3); + + // Test editing calendars. + + dialogPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-properties-dialog.xhtml", + { + callback(win) { + let doc = win.document; + let nameElement = doc.getElementById("calendar-name"); + let colorElement = doc.getElementById("calendar-color"); + Assert.equal(nameElement.value, "Mochitest 1"); + Assert.equal(colorElement.value, "#a8c2e1"); + nameElement.value = "A New Calendar!"; + colorElement.value = "#009900"; + doc.querySelector("dialog").getButton("accept").click(); + }, + } + ); + EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], { clickCount: 2 }); + await dialogPromise; + + Assert.equal(document.activeElement, calendarList); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]); + checkProperties(1, { + color: "rgb(0, 153, 0)", + name: "A New Calendar!", + }); + + dialogPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-properties-dialog.xhtml", + { + callback(win) { + let doc = win.document; + let nameElement = doc.getElementById("calendar-name"); + let colorElement = doc.getElementById("calendar-color"); + Assert.equal(nameElement.value, "A New Calendar!"); + Assert.equal(colorElement.value, "#009900"); + nameElement.value = "Mochitest 1"; + doc.querySelector("dialog").getButton("accept").click(); + }, + } + ); + calendarListContextMenu(calendarList.rows[1], "list-calendars-context-edit"); + await dialogPromise; + + Assert.equal(document.activeElement, calendarList); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]); + checkProperties(1, { + color: "rgb(0, 153, 0)", + name: "Mochitest 1", + }); + + dialogPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-properties-dialog.xhtml", + { + callback(win) { + let doc = win.document; + Assert.equal(doc.getElementById("calendar-name").value, "Mochitest 3"); + let enabledElement = doc.getElementById("calendar-enabled-checkbox"); + Assert.ok(enabledElement.checked); + enabledElement.checked = false; + doc.querySelector("dialog").getButton("accept").click(); + }, + } + ); + // We're clicking on an item that wasn't the selected one. Selection should be updated. + calendarListContextMenu(calendarList.rows[3], "list-calendars-context-edit"); + await dialogPromise; + + Assert.equal(document.activeElement, calendarList); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[3]); + checkProperties(3, { disabled: true }); + + calendars[3].setProperty("disabled", false); + checkProperties(3, { disabled: false }); + + // Test reordering calendars. + + let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService); + dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE); + + await new Promise(resolve => window.setTimeout(resolve)); + + let [result, dataTransfer] = EventUtils.synthesizeDragOver( + calendarList.rows[3], + calendarList.rows[0], + undefined, + undefined, + undefined, + undefined, + { + screenY: calendarList.rows[0].getBoundingClientRect().top + 1, + } + ); + await new Promise(resolve => setTimeout(resolve)); + + EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, calendarList.rows[0]); + EventUtils.sendDragEvent({ type: "dragend" }, calendarList.rows[0]); + dragSession.endDragSession(true); + await new Promise(resolve => setTimeout(resolve)); + + checkSortOrder(3, 0, 1, 2); + + Assert.equal(document.activeElement, calendarList); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[0]); + + // Test deleting calendars. + + // Delete a calendar by unregistering it. + CalendarTestUtils.removeCalendar(calendars[3]); + Assert.equal(cal.manager.getCalendars().length, 3); + Assert.equal(calendarList.rowCount, 3); + checkSortOrder(0, 1, 2); + + // Start to remove a calendar. Cancel the prompt. + EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {}); + await withMockPromptService(1, () => { + EventUtils.synthesizeKey("VK_DELETE"); + }); + Assert.equal(cal.manager.getCalendars().length, 3, "three calendars left in the manager"); + Assert.equal(calendarList.rowCount, 3, "three calendars left in the list"); + checkSortOrder(0, 1, 2); + + // Remove a calendar with the keyboard. + await withMockPromptService(0, () => { + EventUtils.synthesizeKey("VK_DELETE"); + }); + Assert.equal(cal.manager.getCalendars().length, 2, "two calendars left in the manager"); + Assert.equal(calendarList.rowCount, 2, "two calendars left in the list"); + checkSortOrder(0, 2); + + // Remove a calendar with the context menu. + await withMockPromptService(0, async () => { + EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {}); + await calendarListContextMenu(calendarList.rows[1], "list-calendars-context-delete"); + }); + + Assert.equal(cal.manager.getCalendars().length, 1, "one calendar left in the manager"); + Assert.equal(calendarList.rowCount, 1, "one calendar left in the list"); + checkSortOrder(0); + + Assert.equal(composite.defaultCalendar.id, calendars[0].id, "default calendar id check"); + Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[0]); + await CalendarTestUtils.closeCalendarTab(window); +}); diff --git a/comm/calendar/test/browser/browser_calendarTelemetry.js b/comm/calendar/test/browser/browser_calendarTelemetry.js new file mode 100644 index 0000000000..cf2dff7a55 --- /dev/null +++ b/comm/calendar/test/browser/browser_calendarTelemetry.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test telemetry related to calendar. + */ + +let { MailTelemetryForTests } = ChromeUtils.import("resource:///modules/MailGlue.jsm"); +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +/** + * Check that we're counting calendars and read only calendars. + */ +add_task(async function testCalendarCount() { + Services.telemetry.clearScalars(); + + let calendars = cal.manager.getCalendars(); + let homeCal = calendars.find(cal => cal.name == "Home"); + let readOnly = homeCal.readOnly; + homeCal.readOnly = true; + + for (let i = 1; i <= 3; i++) { + calendars[i] = CalendarTestUtils.createCalendar(`Mochitest ${i}`, "memory"); + if (i === 1 || i === 3) { + calendars[i].readOnly = true; + } + } + + await MailTelemetryForTests.reportCalendars(); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + Assert.equal( + scalars["tb.calendar.calendar_count"].memory, + 3, + "Count of calendars must be correct." + ); + Assert.equal( + scalars["tb.calendar.read_only_calendar_count"].memory, + 2, + "Count of readonly calendars must be correct." + ); + + Assert.ok( + !scalars["tb.calendar.calendar_count"].storage, + "'Home' calendar not included in count while disabled" + ); + + Assert.ok( + !scalars["tb.calendar.read_only_calendar_count"].storage, + "'Home' calendar not included in read-only count while disabled" + ); + + for (let i = 1; i <= 3; i++) { + CalendarTestUtils.removeCalendar(calendars[i]); + } + homeCal.readOnly = readOnly; +}); + +/** + * Ensure the "Home" calendar is not ignored if it has been used. + */ +add_task(async function testHomeCalendar() { + let calendar = cal.manager.getCalendars().find(cal => cal.name == "Home"); + let readOnly = calendar.readOnly; + let disabled = calendar.getProperty("disabled"); + + // Test when enabled with no events. + calendar.setProperty("disabled", false); + calendar.readOnly = true; + Services.telemetry.clearScalars(); + await MailTelemetryForTests.reportCalendars(); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + Assert.ok(!scalars["tb.calendar.calendar_count"], "'Home' calendar not counted when unused"); + Assert.ok( + !scalars["tb.calendar.read_only_calendar_count"], + "'Home' calendar not included in readonly count when unused" + ); + + // Now test with an event added to the calendar. + calendar.readOnly = false; + + let event = new CalEvent(); + event.id = "bacd"; + event.title = "Test"; + event.startDate = cal.dtz.now(); + event = await calendar.addItem(event); + + calendar.readOnly = true; + + await TestUtils.waitForCondition(async () => { + let result = await calendar.getItem("bacd"); + return result; + }, "item added to calendar"); + + Services.telemetry.clearScalars(); + await MailTelemetryForTests.reportCalendars(); + + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + Assert.equal( + scalars["tb.calendar.calendar_count"].storage, + 1, + "'Home' calendar counted when there are items" + ); + Assert.equal( + scalars["tb.calendar.read_only_calendar_count"].storage, + 1, + "'Home' calendar included in read-only count when used" + ); + + calendar.readOnly = false; + await calendar.deleteItem(event); + calendar.readOnly = readOnly; + calendar.setProperty("disabled", disabled); +}); diff --git a/comm/calendar/test/browser/browser_calendarUnifinder.js b/comm/calendar/test/browser/browser_calendarUnifinder.js new file mode 100644 index 0000000000..7020b70f71 --- /dev/null +++ b/comm/calendar/test/browser/browser_calendarUnifinder.js @@ -0,0 +1,76 @@ +/* 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 { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +/** + * Tests clicking on events opens in the summary dialog for both + * non-recurring and recurring events. + */ +add_task(async function testOpenEvent() { + let uri = Services.io.newURI("moz-memory-calendar://"); + let calendar = cal.manager.createCalendar("memory", uri); + + calendar.name = "Unifinder Test"; + cal.manager.registerCalendar(calendar); + registerCleanupFunction(() => cal.manager.removeCalendar(calendar)); + + let now = cal.dtz.now(); + + let noRepeatEvent = new CalEvent(); + noRepeatEvent.id = "no repeat event"; + noRepeatEvent.title = "No Repeat Event"; + noRepeatEvent.startDate = now; + noRepeatEvent.endDate = noRepeatEvent.startDate.clone(); + noRepeatEvent.endDate.hour++; + + let repeatEvent = new CalEvent(); + repeatEvent.id = "repeated event"; + repeatEvent.title = "Repeat Event"; + repeatEvent.startDate = now; + repeatEvent.endDate = noRepeatEvent.startDate.clone(); + repeatEvent.endDate.hour++; + repeatEvent.recurrenceInfo = new CalRecurrenceInfo(repeatEvent); + repeatEvent.recurrenceInfo.appendRecurrenceItem( + cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=30") + ); + + await CalendarTestUtils.openCalendarTab(window); + + if (window.isUnifinderHidden()) { + window.toggleUnifinder(); + + await BrowserTestUtils.waitForCondition( + () => window.isUnifinderHidden(), + "calendar unifinder is open" + ); + } + + for (let event of [noRepeatEvent, repeatEvent]) { + await calendar.addItem(event); + + let dialogWindowPromise = CalendarTestUtils.waitForEventDialog(); + let tree = document.querySelector("#unifinder-search-results-tree"); + mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 2 }); + + let dialogWindow = await dialogWindowPromise; + let docUri = dialogWindow.document.documentURI; + Assert.ok( + docUri === "chrome://calendar/content/calendar-summary-dialog.xhtml", + "event summary dialog did show" + ); + + await BrowserTestUtils.closeWindow(dialogWindow); + await calendar.deleteItem(event); + } +}); diff --git a/comm/calendar/test/browser/browser_dragEventItem.js b/comm/calendar/test/browser/browser_dragEventItem.js new file mode 100644 index 0000000000..204e429fdd --- /dev/null +++ b/comm/calendar/test/browser/browser_dragEventItem.js @@ -0,0 +1,414 @@ +/* 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/. */ + +/** + * Test dragging of events in the various calendar views. + */ +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +const calendar = CalendarTestUtils.createCalendar("Drag Test", "memory"); +// Set a low number of hours to reduce pixel -> minute rounding errors. +Services.prefs.setIntPref("calendar.view.visiblehours", 3); + +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + Services.prefs.clearUserPref("calendar.view.visiblehours"); + // Reset the spaces toolbar to its default visible state. + window.gSpacesToolbar.toggleToolbar(false); +}); + +/** + * Ensures that the window is maximised after switching dates. + * + * @param {calIDateTime} date - A date to navigate the view to. + */ +async function resetView(date, view) { + window.goToDate(date); + + if (window.windowState != window.STATE_MAXIMIZED) { + // The multi-day views adjust scrolling dynamically when they detect a + // resize. Hook into the resize event and scroll after the adjustment. + let resizePromise = BrowserTestUtils.waitForEvent(window, "resize"); + window.maximize(); + await resizePromise; + } +} + +/** + * End the dragging of the event at the specified location. + * + * @param {number} day - The day to drop into. + * @param {number} hour - The starting hour to drop to. + * @param {number} topOffset - An offset to apply to the mouse position. + */ +function endDrag(day, hour, topOffset) { + let view = window.currentView(); + let hourElement; + if (view.id == "day-view") { + hourElement = CalendarTestUtils.dayView.getHourBoxAt(window, hour); + } else { + hourElement = CalendarTestUtils.weekView.getHourBoxAt(window, day, hour); + } + // We scroll to align the *end* of the hour element so we can avoid triggering + // the auto-scroll when we synthesize mousemove below. + // FIXME: Use and test auto scroll by holding mouseover at the view edges. + CalendarTestUtils.scrollViewToTarget(hourElement, false); + + let hourRect = hourElement.getBoundingClientRect(); + + // We drop the event with some offset from the starting edge of the desired + // hourElement. + // NOTE: This may mean that the drop point may not be above the hourElement. + // NOTE: We assume that the drop point is however still above the view. + // Currently event "move" events get cancelled if the pointer leaves the view. + let top = Math.round(hourRect.top + topOffset); + let left = Math.round(hourRect.left + hourRect.width / 2); + + EventUtils.synthesizeMouseAtPoint(left, top, { type: "mousemove", shiftKey: true }, window); + EventUtils.synthesizeMouseAtPoint(left, top, { type: "mouseup", shiftKey: true }, window); +} + +/** + * Simulates the dragging of an event box in a multi-day view to another + * column, horizontally. + * + * @param {MozCalendarEventBox} eventBox - The event to start moving. + * @param {number} day - The day to drop into. + * @param {number} hour - The starting hour to drop to. + */ +function simulateDragToColumn(eventBox, day, hour) { + // Scroll to align to the top of the view. + CalendarTestUtils.scrollViewToTarget(eventBox, true); + + let sourceRect = eventBox.getBoundingClientRect(); + // Start dragging from the center of the event box to avoid the gripbars. + // NOTE: We assume that the eventBox's center is in view. + let leftOffset = sourceRect.width / 2; + // We round the mouse position to try and reduce rounding errors when + // scrolling the view. + let sourceTop = Math.round(sourceRect.top + sourceRect.height / 2); + let sourceLeft = sourceRect.left + leftOffset; + // Keep track of the exact offset. + let topOffset = sourceTop - sourceRect.top; + + EventUtils.synthesizeMouseAtPoint( + sourceLeft, + sourceTop, + // Hold shift to avoid snapping. + { type: "mousedown", shiftKey: true }, + window + ); + EventUtils.synthesizeMouseAtPoint( + // We assume the location of the mouseout event does not matter, just as + // long as the event box receives it. + sourceLeft, + sourceTop, + { type: "mouseout", shiftKey: true }, + window + ); + + // End drag with the same offset from the starting edge. + endDrag(day, hour, topOffset); +} + +/** + * Simulates the dragging of an event box via one of the gripbars. + * + * @param {MozCalendarEventBox} eventBox - The event to resize. + * @param {"start"|"end"} - The side to grab. + * @param {number} day - The day to move into. + * @param {number} hour - The hour to move to. + */ +function simulateGripbarDrag(eventBox, side, day, hour) { + // Scroll the edge of the box into view. + CalendarTestUtils.scrollViewToTarget(eventBox, side == "start"); + + let gripbar = side == "start" ? eventBox.startGripbar : eventBox.endGripbar; + + let sourceRect = gripbar.getBoundingClientRect(); + let sourceTop = sourceRect.top + sourceRect.height / 2; + let sourceLeft = sourceRect.left + sourceRect.width / 2; + + // Hover to make the gripbar visible. + EventUtils.synthesizeMouseAtPoint(sourceLeft, sourceTop, { type: "mouseover" }, window); + EventUtils.synthesizeMouseAtPoint( + sourceLeft, + sourceTop, + // Hold shift to avoid snapping. + { type: "mousedown", shiftKey: true }, + window + ); + + // End the drag at the start of the hour. + endDrag(day, hour, 0); +} + +/** + * Tests dragging an event item updates the event in the month view. + */ +add_task(async function testMonthViewDragEventItem() { + let event = new CalEvent(); + event.id = "1"; + event.title = "Month View Event"; + event.startDate = cal.createDateTime("20210316T000000Z"); + event.endDate = cal.createDateTime("20210316T110000Z"); + + await CalendarTestUtils.setCalendarView(window, "month"); + await calendar.addItem(event); + await resetView(event.startDate); + + // Hide the spaces toolbar since it interferes with the calendar + window.gSpacesToolbar.toggleToolbar(true); + + let eventItem = await CalendarTestUtils.monthView.waitForItemAt(window, 3, 3, 1); + let dayBox = await CalendarTestUtils.monthView.getDayBox(window, 3, 2); + let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService); + dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE); + + let [result, dataTransfer] = EventUtils.synthesizeDragOver( + eventItem, + dayBox, + undefined, + undefined, + eventItem.ownerGlobal, + dayBox.ownerGlobal + ); + EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, dayBox); + dragSession.endDragSession(true); + + Assert.ok( + !CalendarTestUtils.monthView.getItemAt(window, 3, 3, 1), + "item removed from initial date" + ); + + eventItem = await CalendarTestUtils.monthView.waitForItemAt(window, 3, 2, 1); + Assert.ok(eventItem, "item moved to new date"); + + let { id, title, startDate, endDate } = eventItem.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210315T000000Z", "startDate is correct"); + Assert.equal(endDate.icalString, "20210315T110000Z", "endDate is correct"); + await calendar.deleteItem(eventItem.occurrence); +}); + +/** + * Tests dragging an event item updates the event in the multiweek view. + */ +add_task(async function testMultiWeekViewDragEventItem() { + let event = new CalEvent(); + event.id = "2"; + event.title = "Multiweek View Event"; + event.startDate = cal.createDateTime("20210316T000000Z"); + event.endDate = cal.createDateTime("20210316T110000Z"); + + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await calendar.addItem(event); + await resetView(event.startDate); + + let eventItem = await CalendarTestUtils.multiweekView.waitForItemAt(window, 1, 3, 1); + let dayBox = await CalendarTestUtils.multiweekView.getDayBox(window, 1, 2); + let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService); + dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE); + + let [result, dataTransfer] = EventUtils.synthesizeDragOver( + eventItem, + dayBox, + undefined, + undefined, + eventItem.ownerGlobal, + dayBox.ownerGlobal + ); + EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, dayBox); + dragSession.endDragSession(true); + + Assert.ok( + !CalendarTestUtils.multiweekView.getItemAt(window, 1, 3, 1), + "item removed from initial date" + ); + + eventItem = await CalendarTestUtils.multiweekView.waitForItemAt(window, 1, 2, 1); + Assert.ok(eventItem, "item moved to new date"); + + let { id, title, startDate, endDate } = eventItem.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210315T000000Z", "startDate is correct"); + Assert.equal(endDate.icalString, "20210315T110000Z", "endDate is correct"); + await calendar.deleteItem(eventItem.occurrence); +}); + +/** + * Tests dragging an event box to the previous day updates the event in the + * week view. + */ +add_task(async function testWeekViewDragEventBoxToPreviousDay() { + let event = new CalEvent(); + event.id = "3"; + event.title = "Week View Previous Day"; + event.startDate = cal.createDateTime("20210316T020000Z"); + event.endDate = cal.createDateTime("20210316T030000Z"); + + await CalendarTestUtils.setCalendarView(window, "week"); + await calendar.addItem(event); + await resetView(event.startDate); + + let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1); + simulateDragToColumn(eventBox, 2, 2); + + eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 2, 1); + await TestUtils.waitForCondition( + () => !CalendarTestUtils.weekView.getEventBoxAt(window, 3, 1), + "Old position is empty" + ); + + let { id, title, startDate, endDate } = eventBox.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210315T020000Z", "startDate is correct"); + Assert.equal(endDate.icalString, "20210315T030000Z", "endDate is correct"); + await calendar.deleteItem(eventBox.occurrence); +}); + +/** + * Tests dragging an event box to the following day updates the event in the + * week view. + */ +add_task(async function testWeekViewDragEventBoxToFollowingDay() { + let event = new CalEvent(); + event.id = "4"; + event.title = "Week View Following Day"; + event.startDate = cal.createDateTime("20210316T020000Z"); + event.endDate = cal.createDateTime("20210316T030000Z"); + + await CalendarTestUtils.setCalendarView(window, "week"); + await calendar.addItem(event); + await resetView(event.startDate); + + let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1); + simulateDragToColumn(eventBox, 4, 2); + + eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 4, 1); + await TestUtils.waitForCondition( + () => !CalendarTestUtils.weekView.getEventBoxAt(window, 3, 1), + "Old position is empty" + ); + + let { id, title, startDate, endDate } = eventBox.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210317T020000Z", "startDate is correct"); + Assert.equal(endDate.icalString, "20210317T030000Z", "endDate is correct"); + await calendar.deleteItem(eventBox.occurrence); +}); + +/** + * Tests dragging the top of an event box updates the start time in the week + * view. + */ +add_task(async function testWeekViewDragEventBoxStartTime() { + let event = new CalEvent(); + event.id = "5"; + event.title = "Week View Start"; + event.startDate = cal.createDateTime("20210316T020000Z"); + event.endDate = cal.createDateTime("20210316T030000Z"); + + await CalendarTestUtils.setCalendarView(window, "week"); + await calendar.addItem(event); + await resetView(event.startDate); + + let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1); + simulateGripbarDrag(eventBox, "start", 3, 1); + eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1); + + let { id, title, startDate, endDate } = eventBox.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210316T010000Z", "startDate was changed"); + Assert.equal(endDate.icalString, "20210316T030000Z", "endDate did not change"); + await calendar.deleteItem(eventBox.occurrence); +}); + +/** + * Tests dragging the end of an event box changes the time in the week view. + */ +add_task(async function testWeekViewDragEventBoxEndTime() { + let event = new CalEvent(); + event.id = "6"; + event.title = "Week View End"; + event.startDate = cal.createDateTime("20210316T020000Z"); + event.endDate = cal.createDateTime("20210316T030000Z"); + + await CalendarTestUtils.setCalendarView(window, "week"); + await calendar.addItem(event); + await resetView(event.startDate); + + let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1); + simulateGripbarDrag(eventBox, "end", 3, 6); + eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1); + + let { id, title, startDate, endDate } = eventBox.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210316T020000Z", "startDate did not change"); + Assert.equal(endDate.icalString, "20210316T060000Z", "endDate was changed"); + await calendar.deleteItem(eventBox.occurrence); +}); + +/** + * Tests dragging the top of an event box changes the start time in the day view. + */ +add_task(async function testDayViewDragEventBoxStartTime() { + let event = new CalEvent(); + event.id = "7"; + event.title = "Day View Start"; + event.startDate = cal.createDateTime("20210316T020000Z"); + event.endDate = cal.createDateTime("20210316T030000Z"); + + await CalendarTestUtils.setCalendarView(window, "day"); + await calendar.addItem(event); + await resetView(event.startDate); + + let eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1); + simulateGripbarDrag(eventBox, "start", 1, 1); + eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1); + + let { id, title, startDate, endDate } = eventBox.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210316T010000Z", "startDate was changed"); + Assert.equal(endDate.icalString, "20210316T030000Z", "endDate did not change"); + await calendar.deleteItem(eventBox.occurrence); +}); + +/** + * Tests dragging the bottom of an event box changes the end time in the day + * view. + */ +add_task(async function testDayViewDragEventBoxEndTime() { + let event = new CalEvent(); + event.id = "8"; + event.title = "Day View End"; + event.startDate = cal.createDateTime("20210316T020000Z"); + event.endDate = cal.createDateTime("20210316T030000Z"); + + await CalendarTestUtils.setCalendarView(window, "day"); + await calendar.addItem(event); + await resetView(event.startDate); + + let eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1); + simulateGripbarDrag(eventBox, "end", 1, 4); + eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1); + + let { id, title, startDate, endDate } = eventBox.occurrence; + Assert.equal(id, event.id, "id is correct"); + Assert.equal(title, event.title, "title is correct"); + Assert.equal(startDate.icalString, "20210316T020000Z", "startDate did not change"); + Assert.equal(endDate.icalString, "20210316T040000Z", "endDate was changed"); + await calendar.deleteItem(eventBox.occurrence); +}); diff --git a/comm/calendar/test/browser/browser_eventDisplay_dayView.js b/comm/calendar/test/browser/browser_eventDisplay_dayView.js new file mode 100644 index 0000000000..5f0941cac4 --- /dev/null +++ b/comm/calendar/test/browser/browser_eventDisplay_dayView.js @@ -0,0 +1,133 @@ +/* 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/. */ + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +var calendar = CalendarTestUtils.createCalendar(); +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +/** + * Create an event item in the calendar. + * + * @param {string} name - The name of the event. + * @param {string} start - The date time string for the start of the event. + * @param {string} end - The date time string for the end of the event. + * + * @returns {CalEvent} - The created event. + */ +async function createEvent(name, start, end) { + let event = new CalEvent(); + event.title = name; + event.startDate = cal.createDateTime(start); + event.endDate = cal.createDateTime(end); + return calendar.addItem(event); +} + +/** + * Assert that there is an event shown on the given date in the day-view. + * + * @param {object} date - The date to move to. + * @param {number} date.day - The day. + * @param {number} date.week - The week. + * @param {number} date.year - The year. + * @param {object} expect - Details about the expected event. + * @param {string} expect.name - The event name. + * @param {boolean} expect.startInView - Whether the event starts within the + * view on the given date. + * @param {boolean} expect.endInView - Whether the event ends within the view + * on the given date. + * @param {string} message - A message to use in assertions. + */ +async function assertDayEvent(date, expect, message) { + await CalendarTestUtils.goToDate(window, date.year, date.month, date.day); + let element = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1); + Assert.equal( + element.querySelector(".event-name-label").textContent, + expect.name, + `Event name should match: ${message}` + ); + await CalendarTestUtils.assertEventBoxDraggable( + element, + expect.startInView, + expect.endInView, + message + ); +} + +/** + * Test an event that occurs within one day, in the day view. + */ +add_task(async function testInsideDayView() { + let event = await createEvent("Test Event", "20190403T123400", "20190403T234500"); + await CalendarTestUtils.setCalendarView(window, "day"); + Assert.equal( + document.querySelectorAll("#day-view calendar-event-column").length, + 1, + "1 day column in the day view" + ); + + // This event is fully within this view. + await assertDayEvent( + { day: 3, month: 4, year: 2019 }, + { name: "Test Event", startInView: true, endInView: true }, + "Single day event" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that starts and ends at midnight, in the day view. + */ +add_task(async function testMidnightDayView() { + let event = await createEvent("Test Event", "20190403T000000", "20190404T000000"); + await CalendarTestUtils.setCalendarView(window, "day"); + + // This event is fully within this view. + await assertDayEvent( + { day: 3, month: 4, year: 2019 }, + { name: "Test Event", startInView: true, endInView: true }, + "Single midnight event" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that spans multiple days, in the day view. + */ +add_task(async function testOutsideDayView() { + let event = await createEvent("Test Event", "20190402T123400", "20190404T234500"); + await CalendarTestUtils.setCalendarView(window, "day"); + + // Go to the start of the event. The end of the event is beyond the current view. + await assertDayEvent( + { day: 2, month: 4, year: 2019 }, + { name: "Test Event", startInView: true, endInView: false }, + "First day" + ); + + // Go to the middle of the event. Both ends of the event are beyond the current view. + await assertDayEvent( + { day: 3, month: 4, year: 2019 }, + { name: "Test Event", startInView: false, endInView: false }, + "Middle day" + ); + + // Go to the end of the event. The start of the event is beyond the current view. + await assertDayEvent( + { day: 4, month: 4, year: 2019 }, + { name: "Test Event", startInView: false, endInView: true }, + "Last day" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); diff --git a/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js b/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js new file mode 100644 index 0000000000..91ddeec6ac --- /dev/null +++ b/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js @@ -0,0 +1,275 @@ +/* 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/. */ + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +var calendar = CalendarTestUtils.createCalendar(); +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +/** + * Create an event item in the calendar. + * + * @param {string} name - The name of the event. + * @param {string} start - The date time string for the start of the event. + * @param {string} end - The date time string for the end of the event. + * + * @returns {Promise<CalEvent>} - The created event. + */ +function createEvent(name, start, end) { + let event = new CalEvent(); + event.title = name; + event.startDate = cal.createDateTime(start); + event.endDate = cal.createDateTime(end); + return calendar.addItem(event); +} + +/** + * Assert that there is a an event in the multiweek or month view between the + * expected range, and no events on the other days. + * + * @param {"multiweek"|"month"} viewName - The view to test. + * @param {number} numWeeks - The number of weeks shown in the view. + * @param {object} date - The date to move to. + * @param {number} date.day - The day. + * @param {number} date.week - The week. + * @param {number} date.year - The year. + * @param {object} expect - Details about the expected event. + * @param {string} expect.name - The event name. + * @param {number} expect.start - The day that the event should start in the + * week. Between 1 and 7. + * @param {number} expect.end - The day that the event should end in the week. + * @param {boolean} expect.startInView - Whether the event starts within the + * view on the given date. + * @param {boolean} expect.endInView - Whether the event ends within the view + * on the given date. + * @param {string} message - A message to use in assertions. + */ +async function assertMultiweekEvents(viewName, numWeeks, date, expect, message) { + await CalendarTestUtils.goToDate(window, date.year, date.month, date.day); + let view = + viewName == "multiweek" ? CalendarTestUtils.multiweekView : CalendarTestUtils.monthView; + + // start = (startWeek - 1) * 7 + startDay + let startWeek = Math.floor((expect.start - 1) / 7) + 1; + let startDay = ((expect.start - 1) % 7) + 1; + let endWeek = Math.floor((expect.end - 1) / 7) + 1; + let endDay = ((expect.end - 1) % 7) + 1; + for (let week = startWeek; week <= endWeek; week++) { + let start = week == startWeek ? startDay : 1; + let end = week == endWeek ? endDay : 7; + for (let day = start; day <= end; day++) { + let element = await view.waitForItemAt(window, week, day, 1); + Assert.equal( + element.querySelector(".event-name-label").textContent, + expect.name, + `Week ${week}, day ${day} event name should match: ${message}` + ); + let multidayIcon = element.querySelector(".item-type-icon"); + if (startDay == endDay && week == startWeek && day == startDay) { + Assert.equal(multidayIcon.src, "", `Week ${week}, day ${day} icon has no source`); + } else if (expect.startInView && week == startWeek && day == startDay) { + Assert.equal( + multidayIcon.src, + "chrome://calendar/skin/shared/event-start.svg", + `Week ${week}, day ${day} icon src shows event start: ${message}` + ); + } else if (expect.endInView && week == endWeek && day == endDay) { + Assert.equal( + multidayIcon.src, + "chrome://calendar/skin/shared/event-end.svg", + `Week ${week}, day ${day} icon src shows event end: ${message}` + ); + } else { + Assert.equal( + multidayIcon.src, + "chrome://calendar/skin/shared/event-continue.svg", + `Week ${week}, day ${day} icon src shows event continue: ${message}` + ); + } + } + } + Assert.equal( + numWeeks, + document.querySelectorAll(`#${viewName}-view .monthbody tr:not([hidden])`).length, + `Should show ${numWeeks} weeks in the view: ${message}` + ); + // Test no events loaded on the other days. + for (let week = 1; week <= numWeeks; week++) { + for (let day = 1; day <= 7; day++) { + if ( + (week > startWeek && week < endWeek) || + (week == startWeek && day >= startDay) || + (week == endWeek && day <= endDay) + ) { + continue; + } + Assert.ok( + !view.getItemAt(window, week, day, 1), + `Should be no events on day ${day}: ${message}` + ); + } + } +} + +/** + * Test an event that occurs fully within the multi-week view. + */ +add_task(async function testInsideMultiweekView() { + let event = await createEvent("Test Event", "20190402T123400", "20190419T234500"); + await CalendarTestUtils.setCalendarView(window, "multiweek"); + Assert.equal( + document.querySelectorAll("#multiweek-view tr:not([hidden]) calendar-month-day-box").length, + 28, + "28 days in the multiweek view" + ); + + await assertMultiweekEvents( + "multiweek", + 4, + { day: 1, month: 4, year: 2019 }, + { name: "Test Event", start: 3, end: 20, startInView: true, endInView: true }, + "3 week event in multiweek view" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that starts and ends at midnight, in the multi-week view. + */ +add_task(async function testMidnightMultiweekView() { + // Event spans one day. + let event = await createEvent("Test Event", "20190402T000000", "20190403T000000"); + await CalendarTestUtils.setCalendarView(window, "multiweek"); + + await assertMultiweekEvents( + "multiweek", + 4, + { day: 1, month: 4, year: 2019 }, + { name: "Test Event", start: 3, end: 3, startInView: true, endInView: true }, + "one day midnight event in multiweek" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that starts or ends outside the multi-week view. + */ +add_task(async function testOutsideMultiweekView() { + let event = await createEvent("Test Event", "20190402T123400", "20190507T234500"); + await CalendarTestUtils.setCalendarView(window, "multiweek"); + + await assertMultiweekEvents( + "multiweek", + 4, + { day: 11, month: 3, year: 2019 }, + { name: "Test Event", start: 24, end: 28, startInView: true, endInView: false }, + "First block in multiweek" + ); + + await assertMultiweekEvents( + "multiweek", + 4, + { day: 8, month: 4, year: 2019 }, + { name: "Test Event", start: 1, end: 28, startInView: false, endInView: false }, + "Middle block in multiweek" + ); + + await assertMultiweekEvents( + "multiweek", + 4, + { day: 29, month: 4, year: 2019 }, + { name: "Test Event", start: 1, end: 10, startInView: false, endInView: true }, + "End block in multiweek" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that occurs within one month, in the month view. + */ +add_task(async function testInsideMonthView() { + let event = await createEvent("Test Event", "20190702T123400", "20190719T234500"); + await CalendarTestUtils.setCalendarView(window, "month"); + Assert.equal( + document.querySelectorAll("#month-view tr:not([hidden]) calendar-month-day-box").length, + 35, + "35 days in the month view" + ); + + await assertMultiweekEvents( + "month", + 5, + { day: 1, month: 7, year: 2019 }, + { name: "Test Event", start: 3, end: 20, startInView: true, endInView: true }, + "Event in single month" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that starts and ends at midnight, in the month view. + */ +add_task(async function testMidnightMonthView() { + // Event spans three days. + let event = await createEvent("Test Event", "20190702T000000", "20190705T000000"); + await CalendarTestUtils.setCalendarView(window, "month"); + + await assertMultiweekEvents( + "month", + 5, + { day: 1, month: 7, year: 2019 }, + { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true }, + "3 day midnight event in single month" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that spans multiple months, in the month view. + */ +add_task(async function testOutsideMonthView() { + let event = await createEvent("Test Event", "20190320T123400", "20190507T234500"); + await CalendarTestUtils.setCalendarView(window, "month"); + + await assertMultiweekEvents( + "month", + 6, + { day: 1, month: 3, year: 2019 }, + { name: "Test Event", start: 25, end: 42, startInView: true, endInView: false }, + "First month" + ); + + await assertMultiweekEvents( + "month", + 5, + { day: 1, month: 4, year: 2019 }, + { name: "Test Event", start: 1, end: 35, startInView: false, endInView: false }, + "Middle month" + ); + + await assertMultiweekEvents( + "month", + 5, + { day: 1, month: 5, year: 2019 }, + { name: "Test Event", start: 1, end: 10, startInView: false, endInView: true }, + "End month" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); diff --git a/comm/calendar/test/browser/browser_eventDisplay_weekView.js b/comm/calendar/test/browser/browser_eventDisplay_weekView.js new file mode 100644 index 0000000000..806105c29a --- /dev/null +++ b/comm/calendar/test/browser/browser_eventDisplay_weekView.js @@ -0,0 +1,151 @@ +/* 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/. */ + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +var calendar = CalendarTestUtils.createCalendar(); +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +/** + * Create an event item in the calendar. + * + * @param {string} name - The name of the event. + * @param {string} start - The date time string for the start of the event. + * @param {string} end - The date time string for the end of the event. + * + * @returns {CalEvent} - The created event. + */ +async function createEvent(name, start, end) { + let event = new CalEvent(); + event.title = name; + event.startDate = cal.createDateTime(start); + event.endDate = cal.createDateTime(end); + return calendar.addItem(event); +} + +/** + * Assert that there is a an event in the week-view between the expected range, + * and no events on the other days. + * + * @param {object} date - The date to move to. + * @param {number} date.day - The day. + * @param {number} date.week - The week. + * @param {number} date.year - The year. + * @param {object} expect - Details about the expected event. + * @param {string} expect.name - The event name. + * @param {number} expect.start - The day that the event should start in the + * week. Between 1 and 7. + * @param {number} expect.end - The day that the event should end in the week. + * @param {boolean} expect.startInView - Whether the event starts within the + * view on the given date. + * @param {boolean} expect.endInView - Whether the event ends within the view + * on the given date. + * @param {string} message - A message to use in assertions. + */ +async function assertWeekEvents(date, expect, message) { + await CalendarTestUtils.goToDate(window, date.year, date.month, date.day); + // First test for expected events since these can take a short while to load, + // and we don't want to test for the absence of an event before they show. + for (let day = expect.start; day <= expect.end; day++) { + let element = await CalendarTestUtils.weekView.waitForEventBoxAt(window, day, 1); + Assert.equal( + element.querySelector(".event-name-label").textContent, + expect.name, + `Day ${day} event name should match: ${message}` + ); + let icon = element.querySelector(".item-recurrence-icon"); + Assert.equal(icon.src, ""); + Assert.ok(icon.hidden); + await CalendarTestUtils.assertEventBoxDraggable( + element, + expect.startInView && day == expect.start, + expect.endInView && day == expect.end, + `Day ${day}: ${message}` + ); + } + // Test no events loaded on the other days. + for (let day = 1; day <= 7; day++) { + if (day >= expect.start && day <= expect.end) { + continue; + } + Assert.equal( + CalendarTestUtils.weekView.getEventBoxes(window, day).length, + 0, + `Should be no events on day ${day}: ${message}` + ); + } +} + +/** + * Test an event that occurs within one week, in the week view. + */ +add_task(async function testInsideWeekView() { + let event = await createEvent("Test Event", "20190101T123400", "20190103T234500"); + await CalendarTestUtils.setCalendarView(window, "week"); + Assert.equal( + document.querySelectorAll("#week-view calendar-event-column").length, + 7, + "7 day columns in the week view" + ); + + await assertWeekEvents( + { day: 1, month: 1, year: 2019 }, + { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true }, + "Single week event" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that starts and ends at midnight, in the week view. + */ +add_task(async function testMidnightWeekView() { + // Spans three days. + let event = await createEvent("Test Event", "20190101T000000", "20190104T000000"); + await CalendarTestUtils.setCalendarView(window, "week"); + + // Midnight-to-midnight event only spans one day even though the end time + // matches the starting time of the next day (midnight). + await assertWeekEvents( + { day: 1, month: 1, year: 2019 }, + { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true }, + "Midnight week event" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); + +/** + * Test an event that spans multiple weeks, in the week view. + */ +add_task(async function testOutsideWeekView() { + let event = await createEvent("Test Event", "20190402T123400", "20190418T234500"); + await CalendarTestUtils.setCalendarView(window, "week"); + + await assertWeekEvents( + { day: 3, month: 4, year: 2019 }, + { name: "Test Event", start: 3, end: 7, startInView: true, endInView: false }, + "First week" + ); + await assertWeekEvents( + { day: 10, month: 4, year: 2019 }, + { name: "Test Event", start: 1, end: 7, startInView: false, endInView: false }, + "Middle week" + ); + await assertWeekEvents( + { day: 17, month: 4, year: 2019 }, + { name: "Test Event", start: 1, end: 5, startInView: false, endInView: true }, + "Last week" + ); + + await CalendarTestUtils.closeCalendarTab(window); + await calendar.deleteItem(event); +}); diff --git a/comm/calendar/test/browser/browser_eventUndoRedo.js b/comm/calendar/test/browser/browser_eventUndoRedo.js new file mode 100644 index 0000000000..34ea8d0523 --- /dev/null +++ b/comm/calendar/test/browser/browser_eventUndoRedo.js @@ -0,0 +1,260 @@ +/* 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/. */ + +"use strict"; + +/** + * Tests for ensuring the undo/redo options are enabled properly when + * manipulating events. + */ + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalTransactionManager: "resource:///modules/CalTransactionManager.jsm", +}); + +const calendar = CalendarTestUtils.createCalendar("Undo Redo Test"); +const calTransManager = CalTransactionManager.getInstance(); + +/** + * Checks the value of the "disabled" property for items in either the "Edit" + * menu bar or the app menu. Display of the relevant menu is triggered first so + * the UI code can update the respective items. + * + * @param {XULElement} element - The menu item we want to check, if its id begins + * with "menu" then we assume it is in the menu + * bar, if "appmenu" then the app menu. + */ +async function isDisabled(element) { + let targetMenu = document.getElementById("menu_EditPopup"); + + let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {}); + await shownPromise; + + let hiddenPromise = BrowserTestUtils.waitForEvent(targetMenu, "popuphidden"); + let status = element.disabled; + targetMenu.hidePopup(); + await hiddenPromise; + return status; +} + +async function clickItem(element) { + let targetMenu = document.getElementById("menu_EditPopup"); + + let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {}); + await shownPromise; + + targetMenu.activateItem(element); +} + +/** + * Removes CalTransaction items from the CalTransactionManager stacks so other + * tests are unhindered. + */ +function clearTransactions() { + calTransManager.undoStack = []; + calTransManager.redoStack = []; +} + +/** + * Test the undo/redo functionality for event creation. + * + * @param {string} undoId - The id of the "undo" menu item. + * @param {string} redoId - The id of the "redo" menu item. + */ +async function testAddUndoRedoEvent(undoId, redoId) { + let undo = document.getElementById(undoId); + let redo = document.getElementById(redoId); + Assert.ok(await isDisabled(undo), `#${undoId} is disabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + let newBtn = document.getElementById("sidePanelNewEvent"); + let windowOpened = CalendarTestUtils.waitForEventDialog("edit"); + EventUtils.synthesizeMouseAtCenter(newBtn, {}); + + let win = await windowOpened; + let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow; + await CalendarTestUtils.items.setData(win, iframeWin, { title: "A New Event" }); + await CalendarTestUtils.items.saveAndCloseItemDialog(win); + + let eventItem; + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem; + }, "event not created in time"); + + Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + // Test undo. + await clickItem(undo); + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return !eventItem; + }, "undo did not remove item in time"); + + Assert.ok(!eventItem, `#${undoId} reverses item creation`); + + // Test redo. + await clickItem(redo); + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem; + }, `${redoId} did not re-create item in time`); + Assert.ok(eventItem, `#${redoId} redos item creation`); + + await calendar.deleteItem(eventItem.item); + clearTransactions(); +} + +/** + * Test the undo/redo functionality for event modification. + * + * @param {string} undoId - The id of the "undo" menu item. + * @param {string} redoId - The id of the "redo" menu item. + */ +async function testModifyUndoRedoEvent(undoId, redoId) { + let undo = document.getElementById(undoId); + let redo = document.getElementById(redoId); + Assert.ok(await isDisabled(undo), `#${undoId} is disabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + let event = new CalEvent(); + event.title = "Modifiable Event"; + event.startDate = cal.dtz.now(); + await calendar.addItem(event); + window.goToDate(event.startDate); + + let eventItem; + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem; + }, "event not created in time"); + + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventItem); + await CalendarTestUtils.items.setData(dialogWindow, iframeWindow, { + title: "Modified Event", + }); + await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow); + + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem && eventItem.item.title == "Modified Event"; + }, "event not modified in time"); + + Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + // Test undo. + await clickItem(undo); + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem && eventItem.item.title == "Modifiable Event"; + }, `#${undoId} did not un-modify event in time`); + + Assert.equal(eventItem.item.title, "Modifiable Event", `#${undoId} reverses item modification`); + + // Test redo. + await clickItem(redo); + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem && eventItem.item.title == "Modified Event"; + }, `${redoId} did not re-modify item in time`); + + Assert.equal(eventItem.item.title, "Modified Event", `#${redoId} redos item modification`); + + clearTransactions(); + await calendar.deleteItem(eventItem.item); +} + +/** + * Test the undo/redo functionality for event deletion. + * + * @param {string} undoId - The id of the "undo" menu item. + * @param {string} redoId - The id of the "redo" menu item. + */ +async function testDeleteUndoRedo(undoId, redoId) { + let undo = document.getElementById(undoId); + let redo = document.getElementById(redoId); + Assert.ok(await isDisabled(undo), `#${undoId} is disabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + let event = new CalEvent(); + event.title = "Deletable Event"; + event.startDate = cal.dtz.now(); + await calendar.addItem(event); + window.goToDate(event.startDate); + + let eventItem; + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem; + }, "event not created in time"); + + EventUtils.synthesizeMouseAtCenter(eventItem, {}); + EventUtils.synthesizeKey("VK_DELETE"); + + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return !eventItem; + }, "event not deleted in time"); + + Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + // Test undo. + await clickItem(undo); + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return eventItem; + }, `#${undoId} did not add event in time`); + Assert.ok(eventItem, `#${undoId} reverses item deletion`); + + // Test redo. + await clickItem(redo); + await TestUtils.waitForCondition(() => { + eventItem = document.querySelector("calendar-month-day-box-item"); + return !eventItem; + }, "redo did not delete item in time"); + + Assert.ok(!eventItem, `#${redoId} redos item deletion`); + clearTransactions(); +} + +/** + * Ensure the menu bar is visible and navigate the calendar view to today. + */ +add_setup(async function () { + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + clearTransactions(); + document.getElementById("toolbar-menubar").setAttribute("autohide", null); + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.dtz.now()); +}); + +/** + * Tests the menu bar's undo/redo after adding an event. + */ +add_task(async function testMenuBarAddEventUndoRedo() { + return testAddUndoRedoEvent("menu_undo", "menu_redo"); +}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac. + +/** + * Tests the menu bar's undo/redo after modifying an event. + */ +add_task(async function testMenuBarModifyEventUndoRedo() { + return testModifyUndoRedoEvent("menu_undo", "menu_redo"); +}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac. + +/** + * Tests the menu bar's undo/redo after deleting an event. + */ +add_task(async function testMenuBarDeleteEventUndoRedo() { + return testDeleteUndoRedo("menu_undo", "menu_redo"); +}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac. diff --git a/comm/calendar/test/browser/browser_import.js b/comm/calendar/test/browser/browser_import.js new file mode 100644 index 0000000000..86e605802b --- /dev/null +++ b/comm/calendar/test/browser/browser_import.js @@ -0,0 +1,285 @@ +/* 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/. */ + +// This tests importing an ICS file. Rather than using the UI to trigger the +// import, loadEventsFromFile is called directly. + +/* globals loadEventsFromFile */ + +const { MockFilePicker } = ChromeUtils.importESModule( + "resource://testing-common/MockFilePicker.sys.mjs" +); +const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry); + +add_task(async () => { + await CalendarTestUtils.setCalendarView(window, "month"); + await CalendarTestUtils.goToDate(window, 2019, 1, 1); + + let chromeUrl = Services.io.newURI(getRootDirectory(gTestPath) + "data/import.ics"); + let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl); + let file = fileUrl.QueryInterface(Ci.nsIFileURL).file; + + MockFilePicker.init(window); + MockFilePicker.setFiles([file]); + MockFilePicker.returnValue = MockFilePicker.returnCancel; + + let calendar = CalendarTestUtils.createCalendar(); + + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + MockFilePicker.cleanup(); + }); + + let cancelReturn = await loadEventsFromFile(); + ok(!cancelReturn, "loadEventsFromFile returns false on cancel"); + + // Prepare to test the import dialog. + MockFilePicker.returnValue = MockFilePicker.returnOK; + + let dialogWindowPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-ics-file-dialog.xhtml", + { + async callback(dialogWindow) { + let doc = dialogWindow.document; + let dialogElement = doc.querySelector("dialog"); + + let optionsPane = doc.getElementById("calendar-ics-file-dialog-options-pane"); + let progressPane = doc.getElementById("calendar-ics-file-dialog-progress-pane"); + let resultPane = doc.getElementById("calendar-ics-file-dialog-result-pane"); + + ok(!optionsPane.hidden); + ok(progressPane.hidden); + ok(resultPane.hidden); + + // Check the initial import dialog state. + let displayedPath = doc.querySelector("#calendar-ics-file-dialog-file-path").value; + let pathFragment = "browser/comm/calendar/test/browser/data/import.ics"; + if (Services.appinfo.OS == "WINNT") { + pathFragment = pathFragment.replace(/\//g, "\\"); + } + is( + displayedPath.substring(displayedPath.length - pathFragment.length), + pathFragment, + "the displayed ics file path is correct" + ); + + let calendarMenu = doc.querySelector("#calendar-ics-file-dialog-calendar-menu"); + // 0 is the Home calendar. + calendarMenu.selectedIndex = 1; + let calendarMenuItems = calendarMenu.querySelectorAll("menuitem"); + is(calendarMenu.value, "Test", "correct calendar name is selected"); + Assert.equal(calendarMenuItems.length, 1, "exactly one calendar is in the calendars menu"); + is(calendarMenuItems[0].selected, true, "calendar menu item is selected"); + + let items; + await TestUtils.waitForCondition(() => { + items = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame"); + return items.length == 4; + }, "four calendar items are displayed"); + is( + items[0].querySelector(".item-title").textContent, + "Event One", + "event 1 title should be correct" + ); + is( + items[1].querySelector(".item-title").textContent, + "Event Two", + "event 2 title should be correct" + ); + is( + items[2].querySelector(".item-title").textContent, + "Event Three", + "event 3 title should be correct" + ); + is( + items[3].querySelector(".item-title").textContent, + "Event Four", + "event 4 title should be correct" + ); + is( + items[0].querySelector(".item-date-row-start-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T150000")), + "event 1 start date should be correct" + ); + is( + items[0].querySelector(".item-date-row-end-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T160000")), + "event 1 end date should be correct" + ); + is( + items[1].querySelector(".item-date-row-start-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T160000")), + "event 2 start date should be correct" + ); + is( + items[1].querySelector(".item-date-row-end-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T170000")), + "event 2 end date should be correct" + ); + is( + items[2].querySelector(".item-date-row-start-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T170000")), + "event 3 start date should be correct" + ); + is( + items[2].querySelector(".item-date-row-end-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T180000")), + "event 3 end date should be correct" + ); + is( + items[3].querySelector(".item-date-row-start-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T180000")), + "event 4 start date should be correct" + ); + is( + items[3].querySelector(".item-date-row-end-date").textContent, + cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T190000")), + "event 4 end date should be correct" + ); + + function check_displayed_titles(expectedTitles) { + let items = doc.querySelectorAll( + ".calendar-ics-file-dialog-item-frame:not([hidden]) > calendar-item-summary" + ); + Assert.deepEqual( + [...items].map(summary => summary.item.title), + expectedTitles + ); + } + + let filterInput = doc.getElementById("calendar-ics-file-dialog-search-input"); + async function check_filter(filterText, expectedTitles) { + let commandPromise = BrowserTestUtils.waitForEvent(filterInput, "command"); + + EventUtils.synthesizeMouseAtCenter(filterInput, {}, dialogWindow); + if (filterText) { + EventUtils.synthesizeKey("a", { accelKey: true }, dialogWindow); + EventUtils.sendString(filterText, dialogWindow); + } else { + EventUtils.synthesizeKey("VK_ESCAPE", {}, dialogWindow); + } + + await commandPromise; + + check_displayed_titles(expectedTitles); + } + + await check_filter("event", ["Event One", "Event Two", "Event Three", "Event Four"]); + await check_filter("four", ["Event Four"]); + await check_filter("ONE", ["Event One"]); + await check_filter(`"event t"`, ["Event Two", "Event Three"]); + await check_filter("", ["Event One", "Event Two", "Event Three", "Event Four"]); + + async function check_sort(order, expectedTitles) { + let sortButton = doc.getElementById("calendar-ics-file-dialog-sort-button"); + let shownPromise = BrowserTestUtils.waitForEvent(sortButton, "popupshown"); + EventUtils.synthesizeMouseAtCenter(sortButton, {}, dialogWindow); + await shownPromise; + let hiddenPromise = BrowserTestUtils.waitForEvent(sortButton, "popuphidden"); + EventUtils.synthesizeMouseAtCenter( + doc.getElementById(`calendar-ics-file-dialog-sort-${order}`), + {}, + dialogWindow + ); + await hiddenPromise; + + let items = doc.querySelectorAll("calendar-item-summary"); + is(items.length, 4, "four calendar items are displayed"); + Assert.deepEqual( + [...items].map(summary => summary.item.title), + expectedTitles + ); + } + + await check_sort("title-ascending", [ + "Event Four", + "Event One", + "Event Three", + "Event Two", + ]); + await check_sort("start-descending", [ + "Event Four", + "Event Three", + "Event Two", + "Event One", + ]); + await check_sort("title-descending", [ + "Event Two", + "Event Three", + "Event One", + "Event Four", + ]); + await check_sort("start-ascending", [ + "Event One", + "Event Two", + "Event Three", + "Event Four", + ]); + + items = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame"); + + // Import just the first item, and check that the correct number of items remains. + let firstItemImportButton = items[0].querySelector( + ".calendar-ics-file-dialog-item-import-button" + ); + EventUtils.synthesizeMouseAtCenter(firstItemImportButton, { clickCount: 1 }, dialogWindow); + + await TestUtils.waitForCondition(() => { + let remainingItems = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame"); + return remainingItems.length == 3; + }, "three items remain after importing the first item"); + check_displayed_titles(["Event Two", "Event Three", "Event Four"]); + + // Filter and import the shown items. + await check_filter("four", ["Event Four"]); + + dialogElement.getButton("accept").click(); + ok(optionsPane.hidden); + ok(!progressPane.hidden); + ok(resultPane.hidden); + + await TestUtils.waitForCondition(() => !optionsPane.hidden); + ok(progressPane.hidden); + ok(resultPane.hidden); + + is(filterInput.value, ""); + check_displayed_titles(["Event Two", "Event Three"]); + + // Click the accept button to import the remaining items. + dialogElement.getButton("accept").click(); + ok(optionsPane.hidden); + ok(!progressPane.hidden); + ok(resultPane.hidden); + + await TestUtils.waitForCondition(() => !resultPane.hidden); + ok(optionsPane.hidden); + ok(progressPane.hidden); + + let messageElement = doc.querySelector("#calendar-ics-file-dialog-result-message"); + is(messageElement.textContent, "Import complete.", "import success message appeared"); + + dialogElement.getButton("accept").click(); + }, + } + ); + + await loadEventsFromFile(); + await dialogWindowPromise; + + // Check that the items were actually successfully imported. + let result = await calendar.getItemsAsArray( + Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, + cal.createDateTime("20190101T000000"), + cal.createDateTime("20190102T000000") + ); + is(result.length, 4, "all items that were imported were in fact imported"); + + await CalendarTestUtils.monthView.waitForItemAt(window, 1, 3, 4); + + for (let item of result) { + await calendar.deleteItem(item); + } +}); diff --git a/comm/calendar/test/browser/browser_localICS.js b/comm/calendar/test/browser/browser_localICS.js new file mode 100644 index 0000000000..43e0299937 --- /dev/null +++ b/comm/calendar/test/browser/browser_localICS.js @@ -0,0 +1,63 @@ +/* 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/. */ + +/* globals createCalendarUsingDialog */ + +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const HOUR = 8; + +// Unique name needed as deleting a calendar only unsubscribes from it and +// if same file were used on next testrun then previously created event +// would show up. +var calendarName = String(Date.now()); +var calendarFile = Services.dirsvc.get("TmpD", Ci.nsIFile); +calendarFile.append(calendarName + ".ics"); + +add_task(async function testLocalICS() { + await CalendarTestUtils.setCalendarView(window, "day"); + await createCalendarUsingDialog(calendarName, { network: {} }); + + // Create new event. + let box = CalendarTestUtils.dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, box); + await setData(dialogWindow, iframeWindow, { title: calendarName, calendar: calendarName }); + await saveAndCloseItemDialog(dialogWindow); + + // Assert presence in view. + await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1); + + // Verify in file. + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + let cstream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance( + Ci.nsIConverterInputStream + ); + + // Wait a moment until file is written. + await TestUtils.waitForCondition(() => calendarFile.exists()); + + // Read the calendar file and check for the summary. + fstream.init(calendarFile, -1, 0, 0); + cstream.init(fstream, "UTF-8", 0, 0); + + let str = {}; + cstream.readString(-1, str); + cstream.close(); + + Assert.ok(str.value.includes("SUMMARY:" + calendarName)); +}); + +registerCleanupFunction(() => { + for (let calendar of cal.manager.getCalendars()) { + if (calendar.name == calendarName) { + cal.manager.removeCalendar(calendar); + } + } +}); diff --git a/comm/calendar/test/browser/browser_tabs.js b/comm/calendar/test/browser/browser_tabs.js new file mode 100644 index 0000000000..950b98a68c --- /dev/null +++ b/comm/calendar/test/browser/browser_tabs.js @@ -0,0 +1,26 @@ +/* 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 () => { + // Test the calendar tab opens and closes. + await CalendarTestUtils.openCalendarTab(window); + await CalendarTestUtils.closeCalendarTab(window); + + // Test the tasks tab opens and closes. + await openTasksTab(); + await closeTasksTab(); + + // Test the calendar and tasks tabs at the same time. + await CalendarTestUtils.openCalendarTab(window); + await openTasksTab(); + await CalendarTestUtils.closeCalendarTab(window); + await closeTasksTab(); + + // Test calendar view selection. + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.setCalendarView(window, "week"); + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await CalendarTestUtils.setCalendarView(window, "month"); + await CalendarTestUtils.closeCalendarTab(window); +}); diff --git a/comm/calendar/test/browser/browser_taskDelete.js b/comm/calendar/test/browser/browser_taskDelete.js new file mode 100644 index 0000000000..dcbf7ab057 --- /dev/null +++ b/comm/calendar/test/browser/browser_taskDelete.js @@ -0,0 +1,185 @@ +/* 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/. */ + +/** + * Tests for deleting tasks in the task view. + */ +const { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +let calendar = CalendarTestUtils.createCalendar("Task Delete Test", "memory"); +registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar)); + +/** + * Test ensures its possible to delete a task in the task view. Creates two task + * and deletes one. + */ +add_task(async function testTaskDeletion() { + let task1 = new CalTodo(); + task1.id = "1"; + task1.title = "Task 1"; + task1.entryDate = cal.createDateTime("20210126T000001Z"); + + let task2 = new CalTodo(); + task2.id = "2"; + task2.title = "Task 2"; + task2.entryDate = cal.createDateTime("20210127T000001Z"); + + await calendar.addItem(task1); + await calendar.addItem(task2); + await openTasksTab(); + + let tree = document.querySelector("#calendar-task-tree"); + let radio = document.querySelector("#opt_next7days_filter"); + let waitForRefresh = BrowserTestUtils.waitForEvent(tree, "refresh"); + EventUtils.synthesizeMouseAtCenter(radio, {}); + tree.refresh(); + + await waitForRefresh; + Assert.equal(tree.view.rowCount, 2, "2 tasks are displayed"); + + mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 1 }); + EventUtils.synthesizeKey("VK_DELETE"); + + // Try and trigger a reflow + tree.getBoundingClientRect(); + tree.invalidate(); + await new Promise(r => setTimeout(r)); + + await TestUtils.waitForCondition(() => { + tree = document.querySelector("#calendar-task-tree"); + return tree.view.rowCount == 1; + }, `task view displays ${tree.view.rowCount} tasks instead of 1`); + + let result = await calendar.getItem(task1.id); + Assert.ok(!result, "first task was deleted successfully"); + + result = await calendar.getItem(task2.id); + Assert.ok(result, "second task was not deleted"); + await calendar.deleteItem(task2); + await closeTasksTab(); +}); + +/** + * Test ensures it is possible to delete a recurring task from the task view. + * See bug 1688708. + */ +add_task(async function testRecurringTaskDeletion() { + let repeatTask = new CalTodo(); + repeatTask.id = "1"; + repeatTask.title = "Repeating Task"; + repeatTask.entryDate = cal.createDateTime("20210125T000001Z"); + repeatTask.recurrenceInfo = new CalRecurrenceInfo(repeatTask); + repeatTask.recurrenceInfo.appendRecurrenceItem( + cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3") + ); + + let nonRepeatTask = new CalTodo(); + nonRepeatTask.id = "2"; + nonRepeatTask.title = "Non-Repeating Task"; + nonRepeatTask.entryDate = cal.createDateTime("20210126T000001Z"); + + repeatTask = await calendar.addItem(repeatTask); + nonRepeatTask = await calendar.addItem(nonRepeatTask); + + await openTasksTab(); + + let tree = document.querySelector("#calendar-task-tree"); + let radio = document.querySelector("#opt_next7days_filter"); + let waitForRefresh = BrowserTestUtils.waitForEvent(tree, "refresh"); + EventUtils.synthesizeMouseAtCenter(radio, {}); + tree.refresh(); + + await waitForRefresh; + Assert.equal(tree.view.rowCount, 4, "4 tasks are displayed"); + + // Delete a single occurrence. + let handleSingleDelete = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-occurrence-prompt.xhtml", + { + async callback(win) { + let dialog = win.document.querySelector("dialog"); + let button = dialog.querySelector("#accept-occurrence-button"); + EventUtils.synthesizeMouseAtCenter(button, {}, win); + }, + } + ); + mailTestUtils.treeClick(EventUtils, window, tree, 1, 1, { clickCount: 1 }); + EventUtils.synthesizeKey("VK_DELETE"); + await handleSingleDelete; + + // Try and trigger a reflow + tree.getBoundingClientRect(); + tree.invalidate(); + await new Promise(r => setTimeout(r)); + + await TestUtils.waitForCondition(() => { + tree = document.querySelector("#calendar-task-tree"); + return tree.view.rowCount == 3; + }, `task view displays ${tree.view.rowCount} tasks instead of 3`); + + repeatTask = await calendar.getItem(repeatTask.id); + + Assert.equal( + repeatTask.recurrenceInfo.getOccurrences( + cal.createDateTime("20210126T000001Z"), + cal.createDateTime("20210126T000001Z"), + 10 + ).length, + 0, + "a single occurrence was deleted successfully" + ); + + Assert.equal( + repeatTask.recurrenceInfo.getOccurrences( + repeatTask.entryDate, + cal.createDateTime("20210131T000001Z"), + 10 + ).length, + 2, + "other occurrences were not removed" + ); + + // Delete all occurrences + let handleAllDelete = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-occurrence-prompt.xhtml", + { + async callback(win) { + let dialog = win.document.querySelector("dialog"); + let button = dialog.querySelector("#accept-parent-button"); + EventUtils.synthesizeMouseAtCenter(button, {}, win); + }, + } + ); + + mailTestUtils.treeClick(EventUtils, window, tree, 1, 1, { clickCount: 1 }); + EventUtils.synthesizeKey("VK_DELETE"); + await handleAllDelete; + + // Try and trigger a reflow + tree.getBoundingClientRect(); + tree.invalidate(); + await new Promise(r => setTimeout(r)); + + await TestUtils.waitForCondition(() => { + tree = document.querySelector("#calendar-task-tree"); + return tree.view.rowCount == 1; + }, `task view displays ${tree.view.rowCount} tasks instead of 1`); + + repeatTask = await calendar.getItem(repeatTask.id); + Assert.ok(!repeatTask, "all occurrences were removed"); + + let result = await calendar.getItem(nonRepeatTask.id); + Assert.ok(result, "non-recurring task was not deleted"); + await closeTasksTab(); +}); diff --git a/comm/calendar/test/browser/browser_taskDisplay.js b/comm/calendar/test/browser/browser_taskDisplay.js new file mode 100644 index 0000000000..1ccd0dac3f --- /dev/null +++ b/comm/calendar/test/browser/browser_taskDisplay.js @@ -0,0 +1,274 @@ +/* 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/. */ + +XPCOMUtils.defineLazyModuleGetters(this, { + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +var calendar = CalendarTestUtils.createCalendar(); +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +let tree = document.getElementById("calendar-task-tree"); + +add_task(async () => { + async function createTask(title, attributes = {}) { + let task = new CalTodo(); + task.title = title; + for (let [key, value] of Object.entries(attributes)) { + task[key] = value; + } + return calendar.addItem(task); + } + + function treeRefresh() { + return BrowserTestUtils.waitForEvent(tree, "refresh"); + } + + async function setFilterGroup(name) { + info(`Setting filter to ${name}`); + let radio = document.getElementById(`opt_${name}_filter`); + EventUtils.synthesizeMouseAtCenter(radio, {}); + await treeRefresh(); + Assert.equal( + document.getElementById("calendar-task-tree").getAttribute("filterValue"), + radio.value, + "Filter group changed" + ); + } + + async function setFilterText(text) { + EventUtils.synthesizeMouseAtCenter(document.getElementById("task-text-filter-field"), {}); + EventUtils.sendString(text); + Assert.equal(document.getElementById("task-text-filter-field").value, text, "Filter text set"); + await treeRefresh(); + } + + async function clearFilterText() { + EventUtils.synthesizeMouseAtCenter(document.getElementById("task-text-filter-field"), {}); + EventUtils.synthesizeKey("VK_ESCAPE"); + Assert.equal( + document.getElementById("task-text-filter-field").value, + "", + "Filter text cleared" + ); + await treeRefresh(); + } + + async function checkVisibleTasks(...expectedTasks) { + function toPrettyString(task) { + if (task.recurrenceId) { + return `${task.title}#${task.recurrenceId}`; + } + return task.title; + } + tree.getBoundingClientRect(); // Try and trigger a reflow... + tree.invalidate(); + + // It seems that under certain conditions notifyOperationComplete() is + // called in CalStorageCalender.getItems before all the results have been + // retrieved. This results in the "refresh" event being fired prematurely in + // calendar-task-tree. After some investigation, the cause of this seems to + // be related to multiple calls of executeAsync() in CalStorageItemModel. + // getAdditionalDataForItemMap() not finishing before notifyOperationComplete() + // is called despite being awaited on. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 500)); + + let actualTasks = []; + for (let i = 0; i < tree.view.rowCount; i++) { + actualTasks.push(tree.getTaskAtRow(i)); + } + info("Expected: " + expectedTasks.map(toPrettyString).join(", ")); + info("Actual: " + actualTasks.map(toPrettyString).join(", ")); + + Assert.equal(tree.view.rowCount, expectedTasks.length, "Correct number of tasks"); + await new Promise(r => setTimeout(r)); + + // Although the order of expectedTasks matches the observed behaviour when + // this test was written, order is NOT checked here. The order of the list + // is not well defined (particularly when changing the filter text). + for (let aTask of actualTasks) { + Assert.ok( + expectedTasks.some(eTask => eTask.hasSameIds(aTask)), + toPrettyString(aTask) + ); + } + } + + let today = cal.dtz.now(); + today.hour = today.minute = today.second = 0; + let yesterday = today.clone(); + yesterday.addDuration(cal.createDuration("-P1D")); + let tomorrow = today.clone(); + tomorrow.addDuration(cal.createDuration("P1D")); + let later = today.clone(); + later.addDuration(cal.createDuration("P2W")); + + let tasks = { + incomplete: await createTask("Incomplete"), + started30: await createTask("30% started", { percentComplete: 30 }), + started60: await createTask("60% started", { percentComplete: 60 }), + complete: await createTask("Complete", { isCompleted: true }), + overdue: await createTask("Overdue", { dueDate: yesterday }), + startsToday: await createTask("Starts today", { entryDate: today }), + startsTomorrow: await createTask("Starts tomorrow", { entryDate: tomorrow }), + startsLater: await createTask("Starts later", { entryDate: later }), + }; + + let repeatingTask = new CalTodo(); + repeatingTask.title = "Repeating"; + repeatingTask.entryDate = yesterday; + repeatingTask.recurrenceInfo = new CalRecurrenceInfo(repeatingTask); + repeatingTask.recurrenceInfo.appendRecurrenceItem( + cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3") + ); + + let firstOccurrence = repeatingTask.recurrenceInfo.getOccurrenceFor(yesterday); + firstOccurrence.isCompleted = true; + firstOccurrence.completedDate = yesterday; + repeatingTask.recurrenceInfo.modifyException(firstOccurrence, true); + + repeatingTask = await calendar.addItem(repeatingTask); + + let occurrences = repeatingTask.recurrenceInfo.getOccurrences(yesterday, later, 10); + Assert.equal(occurrences.length, 3); + + await openTasksTab(); + + await setFilterGroup("all"); + await checkVisibleTasks( + tasks.incomplete, + tasks.started30, + tasks.started60, + tasks.complete, + tasks.overdue, + tasks.startsToday, + tasks.startsTomorrow, + tasks.startsLater, + repeatingTask + ); + + await setFilterGroup("open"); + await checkVisibleTasks( + tasks.incomplete, + tasks.started30, + tasks.started60, + tasks.overdue, + tasks.startsToday, + tasks.startsTomorrow, + tasks.startsLater, + occurrences[1], + occurrences[2] + ); + + await setFilterGroup("completed"); + await checkVisibleTasks(tasks.complete, occurrences[0]); + + await setFilterGroup("overdue"); + await checkVisibleTasks(tasks.overdue); + + await setFilterGroup("notstarted"); + await checkVisibleTasks(tasks.overdue, tasks.incomplete, tasks.startsToday, occurrences[1]); + + await setFilterGroup("next7days"); + await checkVisibleTasks( + tasks.overdue, + tasks.incomplete, + tasks.startsToday, + tasks.started30, + tasks.started60, + tasks.complete, + tasks.startsTomorrow, + occurrences[1], + occurrences[2] + ); + + await setFilterGroup("today"); + await checkVisibleTasks( + tasks.overdue, + tasks.incomplete, + tasks.startsToday, + tasks.started30, + tasks.started60, + tasks.complete, + occurrences[1] + ); + + await setFilterGroup("throughcurrent"); + await checkVisibleTasks( + tasks.overdue, + tasks.incomplete, + tasks.startsToday, + tasks.started30, + tasks.started60, + tasks.complete, + occurrences[1] + ); + + await setFilterText("No matches"); + await checkVisibleTasks(); + + await clearFilterText(); + await checkVisibleTasks( + tasks.incomplete, + tasks.started30, + tasks.started60, + tasks.complete, + tasks.overdue, + tasks.startsToday, + occurrences[1] + ); + + await setFilterText("StArTeD"); + await checkVisibleTasks(tasks.started30, tasks.started60); + + await setFilterGroup("today"); + Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD"); + await checkVisibleTasks(tasks.started30, tasks.started60); + + await setFilterGroup("next7days"); + Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD"); + await checkVisibleTasks(tasks.started30, tasks.started60); + + await setFilterGroup("notstarted"); + Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD"); + await checkVisibleTasks(); + + await setFilterGroup("overdue"); + Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD"); + await checkVisibleTasks(); + + await setFilterGroup("completed"); + Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD"); + await checkVisibleTasks(); + + await setFilterGroup("open"); + Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD"); + await checkVisibleTasks(tasks.started30, tasks.started60); + + await setFilterGroup("all"); + Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD"); + await checkVisibleTasks(tasks.started30, tasks.started60); + + await clearFilterText(); + await checkVisibleTasks( + tasks.started30, + tasks.started60, + tasks.incomplete, + tasks.complete, + tasks.overdue, + tasks.startsToday, + tasks.startsTomorrow, + tasks.startsLater, + repeatingTask + ); + + for (let task of Object.values(tasks)) { + await calendar.deleteItem(task); + } + await setFilterGroup("throughcurrent"); +}); diff --git a/comm/calendar/test/browser/browser_taskUndoRedo.js b/comm/calendar/test/browser/browser_taskUndoRedo.js new file mode 100644 index 0000000000..09cf9a8de2 --- /dev/null +++ b/comm/calendar/test/browser/browser_taskUndoRedo.js @@ -0,0 +1,244 @@ +/* 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/. */ + +"use strict"; + +/** + * Tests for ensuring the undo/redo options are enabled properly when + * manipulating tasks. + */ + +var { mailTestUtils } = ChromeUtils.import("resource://testing-common/mailnews/MailTestUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalTodo: "resource:///modules/CalTodo.jsm", + CalTransactionManager: "resource:///modules/CalTransactionManager.jsm", +}); + +const calendar = CalendarTestUtils.createCalendar("Undo Redo Test", "memory"); +const calTransManager = CalTransactionManager.getInstance(); + +/** + * Checks the value of the "disabled" property for items in either the "Edit" + * menu bar or the app menu. Display of the relevant menu is triggered first so + * the UI code can update the respective items. + * + * @param {XULElement} element - The menu item we want to check, if its id begins + * with "menu" then we assume it is in the menu + * bar, if "appmenu" then the app menu. + */ +async function isDisabled(element) { + let targetMenu = document.getElementById("menu_EditPopup"); + + let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {}); + await shownPromise; + + let hiddenPromise = BrowserTestUtils.waitForEvent(targetMenu, "popuphidden"); + let status = element.disabled; + EventUtils.synthesizeKey("VK_ESCAPE"); + await hiddenPromise; + return status; +} + +/** + * Removes CalTransaction items from the CalTransactionManager stacks so other + * tests are unhindered. + */ +function clearTransactions() { + calTransManager.undoStack = []; + calTransManager.redoStack = []; +} + +/** + * Test the undo/redo functionality for task creation. + * + * @param {string} undoId - The id of the "undo" menu item. + * @param {string} redoId - The id of the "redo" menu item. + */ +async function taskAddUndoRedoTask(undoId, redoId) { + let undo = document.getElementById(undoId); + let redo = document.getElementById(redoId); + Assert.ok(await isDisabled(undo), `#${undoId} is disabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + let newBtn = document.getElementById("sidePanelNewTask"); + let windowPromise = CalendarTestUtils.waitForEventDialog("edit"); + EventUtils.synthesizeMouseAtCenter(newBtn, {}); + + let win = await windowPromise; + let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow; + await CalendarTestUtils.items.setData(win, iframeWin, { title: "New Task" }); + await CalendarTestUtils.items.saveAndCloseItemDialog(win); + + let tree = document.querySelector("#calendar-task-tree"); + let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh"); + tree.refresh(); + await refreshPromise; + + Assert.equal(tree.view.rowCount, 1); + Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + // Test undo. + undo.doCommand(); + await TestUtils.waitForCondition( + () => tree.view.rowCount == 0, + `${undoId} did not remove task in time` + ); + Assert.equal(tree.view.rowCount, 0, `#${undoId} reverses task creation`); + + // Test redo. + redo.doCommand(); + await TestUtils.waitForCondition( + () => tree.view.rowCount == 1, + `${redoId} did not re-create task in time` + ); + + let task = tree.getTaskAtRow(0); + Assert.equal(task.title, "New Task", `#${redoId} redos task creation`); + await calendar.deleteItem(task); + clearTransactions(); +} + +/** + * Test the undo/redo functionality for task modification. + * + * @param {string} undoId - The id of the "undo" menu item. + * @param {string} redoId - The id of the "redo" menu item. + */ +async function testModifyUndoRedoTask(undoId, redoId) { + let undo = document.getElementById(undoId); + let redo = document.getElementById(redoId); + Assert.ok(await isDisabled(undo), `#${undoId} is disabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + let task = new CalTodo(); + task.title = "Modifiable Task"; + task.entryDate = cal.dtz.now(); + await calendar.addItem(task); + + let tree = document.querySelector("#calendar-task-tree"); + let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh"); + tree.refresh(); + await refreshPromise; + + let windowPromise = CalendarTestUtils.waitForEventDialog("edit"); + mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 2 }); + + let win = await windowPromise; + let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow; + await CalendarTestUtils.items.setData(win, iframeWin, { title: "Modified Task" }); + await CalendarTestUtils.items.saveAndCloseItemDialog(win); + + Assert.equal(tree.getTaskAtRow(0).title, "Modified Task"); + Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + // Test undo. + undo.doCommand(); + refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh"); + tree.refresh(); + await refreshPromise; + Assert.equal( + tree.getTaskAtRow(0).title, + "Modifiable Task", + `#${undoId} reverses task modification` + ); + + // Test redo. + redo.doCommand(); + refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh"); + tree.refresh(); + await refreshPromise; + Assert.equal(tree.getTaskAtRow(0).title, "Modified Task", `#${redoId} redos task modification`); + + clearTransactions(); + await calendar.deleteItem(tree.getTaskAtRow(0)); +} + +/** + * Test the undo/redo functionality for task deletion. + * + * @param {string} undoId - The id of the "undo" menu item. + * @param {string} redoId - The id of the "redo" menu item. + */ +async function testDeleteUndoRedoTask(undoId, redoId) { + let undo = document.getElementById(undoId); + let redo = document.getElementById(redoId); + Assert.ok(await isDisabled(undo), `#${undoId} is disabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + let task = new CalTodo(); + task.title = "Deletable Task"; + task.startDate = cal.dtz.now(); + task.entryDate = cal.dtz.now(); + await calendar.addItem(task); + + let tree = document.querySelector("#calendar-task-tree"); + let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh"); + tree.refresh(); + await refreshPromise; + Assert.equal(tree.view.rowCount, 1); + + mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 1 }); + EventUtils.synthesizeKey("VK_DELETE"); + await TestUtils.waitForCondition(() => tree.view.rowCount == 0, "task was not removed in time"); + + Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`); + Assert.ok(await isDisabled(redo), `#${redoId} is disabled`); + + // Test undo. + undo.doCommand(); + tree.refresh(); + await TestUtils.waitForCondition( + () => tree.view.rowCount == 1, + "undo did not restore task in time" + ); + Assert.equal(tree.getTaskAtRow(0).title, "Deletable Task", `#${undoId} reverses item deletion`); + + // Test redo. + redo.doCommand(); + await TestUtils.waitForCondition( + () => tree.view.rowCount == 0, + `#${redoId} redo did not delete item in time` + ); + Assert.ok(!tree.getTaskAtRow(0), `#${redoId} redos item deletion`); + + clearTransactions(); +} + +/** + * Ensure the menu bar is visible and navigate to the task view. + */ +add_setup(async function () { + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + clearTransactions(); + document.getElementById("toolbar-menubar").setAttribute("autohide", null); + await openTasksTab(); +}); + +/** + * Tests the menu bar's undo/redo after adding an event. + */ +add_task(async function testMenuBarAddTaskUndoRedo() { + return taskAddUndoRedoTask("menu_undo", "menu_redo"); +}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac. + +/** + * Tests the menu bar's undo/redo after modifying an event. + */ +add_task(async function testMenuBarModifyTaskUndoRedo() { + return testModifyUndoRedoTask("menu_undo", "menu_redo"); +}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac. + +/** + * Tests the menu bar's undo/redo after deleting an event. + */ +add_task(async function testMenuBarDeleteTaskUndoRedo() { + return testDeleteUndoRedoTask("menu_undo", "menu_redo"); +}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac. diff --git a/comm/calendar/test/browser/browser_todayPane.js b/comm/calendar/test/browser/browser_todayPane.js new file mode 100644 index 0000000000..8ad9141815 --- /dev/null +++ b/comm/calendar/test/browser/browser_todayPane.js @@ -0,0 +1,820 @@ +/* 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/. */ + +/* globals TodayPane */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { formatDate, formatTime } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalDateTime: "resource:///modules/CalDateTime.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +let calendar = CalendarTestUtils.createCalendar(); +Services.prefs.setIntPref("calendar.agenda.days", 7); +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + Services.prefs.clearUserPref("calendar.agenda.days"); +}); + +let today = cal.dtz.now(); +let startHour = today.hour; +today.hour = today.minute = today.second = 0; + +let todayPanePanel = document.getElementById("today-pane-panel"); +let todayPaneStatusButton = document.getElementById("calendar-status-todaypane-button"); + +// Go to mail tab. +selectFolderTab(); + +// Verify today pane open. +if (todayPanePanel.hasAttribute("collapsed")) { + EventUtils.synthesizeMouseAtCenter(todayPaneStatusButton, {}); +} +Assert.ok(!todayPanePanel.hasAttribute("collapsed"), "Today Pane is open"); + +// Verify today pane's date. +Assert.equal(document.getElementById("datevalue-label").value, today.day, "Today Pane shows today"); + +async function addEvent(title, relativeStart, relativeEnd, isAllDay) { + let event = new CalEvent(); + event.id = cal.getUUID(); + event.title = title; + event.startDate = today.clone(); + event.startDate.addDuration(cal.createDuration(relativeStart)); + event.startDate.isDate = isAllDay; + event.endDate = today.clone(); + event.endDate.addDuration(cal.createDuration(relativeEnd)); + event.endDate.isDate = isAllDay; + return calendar.addItem(event); +} + +function checkEvent(row, { dateHeader, time, title, relative, overlap, classes = [] }) { + let dateHeaderElement = row.querySelector(".agenda-date-header"); + if (dateHeader) { + Assert.ok(BrowserTestUtils.is_visible(dateHeaderElement), "date header is visible"); + if (dateHeader instanceof CalDateTime || dateHeader instanceof Ci.calIDateTime) { + dateHeader = cal.dtz.formatter.formatDateLongWithoutYear(dateHeader); + } + Assert.equal(dateHeaderElement.textContent, dateHeader, "date header has correct value"); + } else { + Assert.ok(BrowserTestUtils.is_hidden(dateHeaderElement), "date header is hidden"); + } + + let calendarElement = row.querySelector(".agenda-listitem-calendar"); + let timeElement = row.querySelector(".agenda-listitem-time"); + if (time) { + Assert.ok(BrowserTestUtils.is_visible(calendarElement), "calendar is visible"); + Assert.ok(BrowserTestUtils.is_visible(timeElement), "time is visible"); + if (time instanceof CalDateTime || time instanceof Ci.calIDateTime) { + time = cal.dtz.formatter.formatTime(time); + } + Assert.equal(timeElement.textContent, time, "time has correct value"); + } else if (time === "") { + Assert.ok(BrowserTestUtils.is_visible(calendarElement), "calendar is visible"); + Assert.ok(BrowserTestUtils.is_hidden(timeElement), "time is hidden"); + } else { + Assert.ok(BrowserTestUtils.is_hidden(calendarElement), "calendar is hidden"); + Assert.ok(BrowserTestUtils.is_hidden(timeElement), "time is hidden"); + } + + let titleElement = row.querySelector(".agenda-listitem-title"); + Assert.ok(BrowserTestUtils.is_visible(titleElement), "title is visible"); + Assert.equal(titleElement.textContent, title, "title has correct value"); + + let relativeElement = row.querySelector(".agenda-listitem-relative"); + if (Array.isArray(relative)) { + Assert.ok(BrowserTestUtils.is_visible(relativeElement), "relative time is visible"); + Assert.report( + !relative.includes(relativeElement.textContent), + relative, + relativeElement.textContent, + "relative time is correct", + "includes" + ); + } else if (relative !== undefined) { + Assert.ok(BrowserTestUtils.is_hidden(relativeElement), "relative time is hidden"); + } + + let overlapElement = row.querySelector(".agenda-listitem-overlap"); + if (overlap) { + Assert.ok(BrowserTestUtils.is_visible(overlapElement), "overlap is visible"); + Assert.equal( + overlapElement.src, + `chrome://messenger/skin/icons/new/event-${overlap}.svg`, + "overlap has correct image" + ); + Assert.equal( + overlapElement.dataset.l10nId, + `calendar-editable-item-multiday-event-icon-${overlap}`, + "overlap has correct alt text" + ); + } else { + Assert.ok(BrowserTestUtils.is_hidden(overlapElement), "overlap is hidden"); + } + + for (let className of classes) { + Assert.ok(row.classList.contains(className), `row has ${className} class`); + } +} + +function checkEvents(...expectedEvents) { + Assert.equal(TodayPane.agenda.rowCount, expectedEvents.length, "expected number of rows shown"); + for (let i = 0; i < expectedEvents.length; i++) { + Assert.ok(TodayPane.agenda.rows[i].getAttribute("is"), "agenda-listitem"); + checkEvent(TodayPane.agenda.rows[i], expectedEvents[i]); + } +} + +add_task(async function testBasicAllDay() { + let todaysEvent = await addEvent("Today's Event", "P0D", "P1D", true); + checkEvents({ dateHeader: "Today", title: "Today's Event" }); + + let tomorrowsEvent = await addEvent("Tomorrow's Event", "P1D", "P2D", true); + checkEvents( + { dateHeader: "Today", title: "Today's Event" }, + { dateHeader: "Tomorrow", title: "Tomorrow's Event" } + ); + + let events = []; + for (let i = 2; i < 7; i++) { + events.push(await addEvent(`Event ${i + 1}`, `P${i}D`, `P${i + 1}D`, true)); + checkEvents( + { dateHeader: "Today", title: "Today's Event" }, + { dateHeader: "Tomorrow", title: "Tomorrow's Event" }, + ...events.map(e => { + return { dateHeader: e.startDate, title: e.title }; + }) + ); + } + + await calendar.deleteItem(todaysEvent); + checkEvents( + { dateHeader: "Tomorrow", title: "Tomorrow's Event" }, + ...events.map(e => { + return { dateHeader: e.startDate, title: e.title }; + }) + ); + await calendar.deleteItem(tomorrowsEvent); + checkEvents( + ...events.map(e => { + return { dateHeader: e.startDate, title: e.title }; + }) + ); + + while (events.length) { + await calendar.deleteItem(events.shift()); + checkEvents( + ...events.map(e => { + return { dateHeader: e.startDate, title: e.title }; + }) + ); + } +}); + +add_task(async function testBasic() { + let time = today.clone(); + time.hour = 23; + + let todaysEvent = await addEvent("Today's Event", "P0DT23H", "P1D"); + checkEvents({ dateHeader: "Today", time, title: "Today's Event" }); + + let tomorrowsEvent = await addEvent("Tomorrow's Event", "P1DT23H", "P2D"); + checkEvents( + { dateHeader: "Today", time, title: "Today's Event" }, + { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" } + ); + + let events = []; + for (let i = 2; i < 7; i++) { + events.push(await addEvent(`Event ${i + 1}`, `P${i}DT23H`, `P${i + 1}D`)); + checkEvents( + { dateHeader: "Today", time, title: "Today's Event" }, + { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" }, + ...events.map(e => { + return { dateHeader: e.startDate, time, title: e.title }; + }) + ); + } + + await calendar.deleteItem(todaysEvent); + checkEvents( + { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" }, + ...events.map(e => { + return { dateHeader: e.startDate, time, title: e.title }; + }) + ); + await calendar.deleteItem(tomorrowsEvent); + checkEvents( + ...events.map(e => { + return { dateHeader: e.startDate, time, title: e.title }; + }) + ); + + while (events.length) { + await calendar.deleteItem(events.shift()); + checkEvents( + ...events.map(e => { + return { dateHeader: e.startDate, time, title: e.title }; + }) + ); + } +}); + +/** + * Adds and removes events in a different order from which they occur. + * This checks that the events are inserted in the right place, and that the + * date header is shown/hidden appropriately. + */ +add_task(async function testSortOrder() { + let afternoonEvent = await addEvent("Afternoon Event", "P1DT13H", "P1DT17H"); + checkEvents({ + dateHeader: "Tomorrow", + time: afternoonEvent.startDate, + title: "Afternoon Event", + }); + + let morningEvent = await addEvent("Morning Event", "P1DT8H", "P1DT12H"); + checkEvents( + { dateHeader: "Tomorrow", time: morningEvent.startDate, title: "Morning Event" }, + { time: afternoonEvent.startDate, title: "Afternoon Event" } + ); + + let allDayEvent = await addEvent("All Day Event", "P1D", "P2D", true); + checkEvents( + { dateHeader: "Tomorrow", title: "All Day Event" }, + { time: morningEvent.startDate, title: "Morning Event" }, + { time: afternoonEvent.startDate, title: "Afternoon Event" } + ); + + let eveningEvent = await addEvent("Evening Event", "P1DT18H", "P1DT22H"); + checkEvents( + { dateHeader: "Tomorrow", title: "All Day Event" }, + { time: morningEvent.startDate, title: "Morning Event" }, + { time: afternoonEvent.startDate, title: "Afternoon Event" }, + { time: eveningEvent.startDate, title: "Evening Event" } + ); + + await calendar.deleteItem(afternoonEvent); + checkEvents( + { dateHeader: "Tomorrow", title: "All Day Event" }, + { time: morningEvent.startDate, title: "Morning Event" }, + { time: eveningEvent.startDate, title: "Evening Event" } + ); + + await calendar.deleteItem(morningEvent); + checkEvents( + { dateHeader: "Tomorrow", title: "All Day Event" }, + { time: eveningEvent.startDate, title: "Evening Event" } + ); + + await calendar.deleteItem(allDayEvent); + checkEvents({ + dateHeader: "Tomorrow", + time: eveningEvent.startDate, + title: "Evening Event", + }); + + await calendar.deleteItem(eveningEvent); + checkEvents(); +}); + +/** + * Check events that begin and end on different days inside the date range. + * All-day events are still sorted ahead of non-all-day events. + */ +add_task(async function testOverlapInside() { + let allDayEvent = await addEvent("All Day Event", "P0D", "P2D", true); + checkEvents( + { dateHeader: "Today", title: "All Day Event", overlap: "start" }, + { dateHeader: "Tomorrow", title: "All Day Event", overlap: "end" } + ); + + let timedEvent = await addEvent("Timed Event", "P1H", "P1D23H"); + checkEvents( + { dateHeader: "Today", title: "All Day Event", overlap: "start" }, + { time: timedEvent.startDate, title: "Timed Event", overlap: "start" }, + { dateHeader: "Tomorrow", title: "All Day Event", overlap: "end" }, + { time: timedEvent.endDate, title: "Timed Event", overlap: "end" } + ); + + await calendar.deleteItem(allDayEvent); + await calendar.deleteItem(timedEvent); +}); + +/** + * Check events that begin and end on different days and that end at midnight. + * The list item for the end of the event should be the last one on the day + * before the end midnight, and its time label should display "24:00". + */ +add_task(async function testOverlapEndAtMidnight() { + // Start with an event that begins outside the displayed dates. + + let timedEvent = await addEvent("Timed Event", "-P1D", "P1D"); + // Ends an hour before `timedEvent` to prove the ordering is correct. + let duringEvent = await addEvent("During Event", "P22H", "P23H"); + // Starts at the same time as `timedEvent` ends to prove the ordering is correct. + let nextEvent = await addEvent("Next Event", "P1D", "P2D", true); + + checkEvents( + { dateHeader: "Today", time: duringEvent.startDate, title: "During Event" }, + { + // Should show "24:00" as the time and end today. + time: cal.dtz.formatter.formatTime(timedEvent.endDate, true), + title: "Timed Event", + overlap: "end", + }, + { dateHeader: "Tomorrow", title: "Next Event" } + ); + + // Move the event fully into the displayed range. + + let timedClone = timedEvent.clone(); + timedClone.startDate.day += 2; + timedClone.endDate.day += 2; + await calendar.modifyItem(timedClone, timedEvent); + + let duringClone = duringEvent.clone(); + duringClone.startDate.day += 2; + duringClone.endDate.day += 2; + await calendar.modifyItem(duringClone, duringEvent); + + let nextClone = nextEvent.clone(); + nextClone.startDate.day += 2; + nextClone.endDate.day += 2; + await calendar.modifyItem(nextClone, nextEvent); + + let realEndDate = today.clone(); + realEndDate.day += 2; + checkEvents( + { + dateHeader: "Tomorrow", + time: timedClone.startDate, + title: "Timed Event", + overlap: "start", + }, + { dateHeader: realEndDate, time: duringClone.startDate, title: "During Event" }, + { + // Should show "24:00" as the time and end on the day after tomorrow. + time: cal.dtz.formatter.formatTime(timedClone.endDate, true), + title: "Timed Event", + overlap: "end", + }, + { dateHeader: nextClone.startDate, title: "Next Event" } + ); + + await calendar.deleteItem(timedClone); + await calendar.deleteItem(duringClone); + await calendar.deleteItem(nextClone); +}); + +/** + * Check events that begin and/or end outside the date range. Events that have + * already started are listed as "Today", but still sorted by start time. + * All-day events are still sorted ahead of non-all-day events. + */ +add_task(async function testOverlapOutside() { + let before = await addEvent("Starts Before", "-P1D", "P1D", true); + checkEvents({ dateHeader: "Today", title: "Starts Before", overlap: "end" }); + + let after = await addEvent("Ends After", "P0D", "P9D", true); + checkEvents( + { dateHeader: "Today", title: "Starts Before", overlap: "end" }, + { title: "Ends After", overlap: "start" } + ); + + let both = await addEvent("Beyond Start and End", "-P2D", "P9D", true); + checkEvents( + { dateHeader: "Today", title: "Beyond Start and End", overlap: "continue" }, + { title: "Starts Before", overlap: "end" }, + { title: "Ends After", overlap: "start" } + ); + + // Change `before` to begin earlier than `both`. They should swap places. + + let startClone = before.clone(); + startClone.startDate.day -= 2; + await calendar.modifyItem(startClone, before); + checkEvents( + { dateHeader: "Today", title: "Starts Before", overlap: "end" }, + { title: "Beyond Start and End", overlap: "continue" }, + { title: "Ends After", overlap: "start" } + ); + + let beforeWithTime = await addEvent("Starts Before with time", "-PT5H", "PT15H"); + checkEvents( + { dateHeader: "Today", title: "Starts Before", overlap: "end" }, + { title: "Beyond Start and End", overlap: "continue" }, + { title: "Ends After", overlap: "start" }, + // This is the end of the event so the end time is used. + { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" } + ); + + let afterWithTime = await addEvent("Ends After with time", "PT6H", "P8DT12H"); + checkEvents( + { dateHeader: "Today", title: "Starts Before", overlap: "end" }, + { title: "Beyond Start and End", overlap: "continue" }, + { title: "Ends After", overlap: "start" }, + { time: afterWithTime.startDate, title: "Ends After with time", overlap: "start" }, + // This is the end of the event so the end time is used. + { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" } + ); + + let bothWithTime = await addEvent("Beyond Start and End with time", "-P2DT10H", "P9DT1H"); + checkEvents( + { dateHeader: "Today", title: "Starts Before", overlap: "end" }, + { title: "Beyond Start and End", overlap: "continue" }, + { title: "Ends After", overlap: "start" }, + { time: "", title: "Beyond Start and End with time", overlap: "continue" }, + { time: afterWithTime.startDate, title: "Ends After with time", overlap: "start" }, + // This is the end of the event so the end time is used. + { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" } + ); + + await calendar.deleteItem(before); + await calendar.deleteItem(after); + await calendar.deleteItem(both); + await calendar.deleteItem(beforeWithTime); + await calendar.deleteItem(afterWithTime); + await calendar.deleteItem(bothWithTime); +}); + +/** + * Checks that events that happened earlier today are marked as in the past, + * and events happening now are marked as such. + * + * This test may fail if run within a minute either side of midnight. + * + * It would be nice to test that as time passes events are changed + * appropriately, but that means waiting around for minutes and probably won't + * be very reliable, so we don't do that. + */ +add_task(async function testActive() { + let now = cal.dtz.now(); + + let pastEvent = await addEvent("Past Event", "PT0M", "PT1M"); + let presentEvent = await addEvent("Present Event", `PT${now.hour}H`, `PT${now.hour + 1}H`); + let futureEvent = await addEvent("Future Event", "PT23H59M", "PT24H"); + checkEvents( + { dateHeader: "Today", time: pastEvent.startDate, title: "Past Event" }, + { time: presentEvent.startDate, title: "Present Event" }, + { time: futureEvent.startDate, title: "Future Event" } + ); + + let [pastRow, presentRow, futureRow] = TodayPane.agenda.rows; + Assert.ok(pastRow.classList.contains("agenda-listitem-past"), "past event is marked past"); + Assert.ok(!pastRow.classList.contains("agenda-listitem-now"), "past event is not marked now"); + Assert.ok( + !presentRow.classList.contains("agenda-listitem-past"), + "present event is not marked past" + ); + Assert.ok(presentRow.classList.contains("agenda-listitem-now"), "present event is marked now"); + Assert.ok( + !futureRow.classList.contains("agenda-listitem-past"), + "future event is not marked past" + ); + Assert.ok(!futureRow.classList.contains("agenda-listitem-now"), "future event is not marked now"); + + await calendar.deleteItem(pastEvent); + await calendar.deleteItem(presentEvent); + await calendar.deleteItem(futureEvent); +}); + +/** + * Checks events in different time zones are displayed correctly. + */ +add_task(async function testOtherTimeZones() { + // Johannesburg is UTC+2. + let johannesburg = cal.timezoneService.getTimezone("Africa/Johannesburg"); + // Panama is UTC-5. + let panama = cal.timezoneService.getTimezone("America/Panama"); + + // All-day events are displayed on the day of the event, the time zone is ignored. + + let allDayEvent = new CalEvent(); + allDayEvent.id = cal.getUUID(); + allDayEvent.title = "All-day event in Johannesburg"; + allDayEvent.startDate = cal.createDateTime(); + allDayEvent.startDate.resetTo(today.year, today.month, today.day + 1, 0, 0, 0, johannesburg); + allDayEvent.startDate.isDate = true; + allDayEvent.endDate = cal.createDateTime(); + allDayEvent.endDate.resetTo(today.year, today.month, today.day + 2, 0, 0, 0, johannesburg); + allDayEvent.endDate.isDate = true; + allDayEvent = await calendar.addItem(allDayEvent); + + checkEvents({ + dateHeader: "Tomorrow", + title: "All-day event in Johannesburg", + }); + + await calendar.deleteItem(allDayEvent); + + // The event time must be displayed in the local time zone, and the event must be sorted correctly. + + let beforeEvent = await addEvent("Before", "P1DT5H", "P1DT6H"); + let afterEvent = await addEvent("After", "P1DT7H", "P1DT8H"); + + let timedEvent = new CalEvent(); + timedEvent.id = cal.getUUID(); + timedEvent.title = "Morning in Johannesburg"; + timedEvent.startDate = cal.createDateTime(); + timedEvent.startDate.resetTo(today.year, today.month, today.day + 1, 8, 0, 0, johannesburg); + timedEvent.endDate = cal.createDateTime(); + timedEvent.endDate.resetTo(today.year, today.month, today.day + 1, 12, 0, 0, johannesburg); + timedEvent = await calendar.addItem(timedEvent); + + checkEvents( + { + dateHeader: "Tomorrow", + time: beforeEvent.startDate, + title: "Before", + }, + { + time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T060000Z")), // The date used here is irrelevant. + title: "Morning in Johannesburg", + }, + { + time: afterEvent.startDate, + title: "After", + } + ); + Assert.stringContains( + TodayPane.agenda.rows[1].querySelector(".agenda-listitem-time").getAttribute("datetime"), + "T08:00:00+02:00" + ); + + await calendar.deleteItem(beforeEvent); + await calendar.deleteItem(afterEvent); + await calendar.deleteItem(timedEvent); + + // Events that cross midnight in the local time zone (but not in the event time zone) + // must have a start row and an end row. + + let overnightEvent = new CalEvent(); + overnightEvent.id = cal.getUUID(); + overnightEvent.title = "Evening in Panama"; + overnightEvent.startDate = cal.createDateTime(); + overnightEvent.startDate.resetTo(today.year, today.month, today.day, 17, 0, 0, panama); + overnightEvent.endDate = cal.createDateTime(); + overnightEvent.endDate.resetTo(today.year, today.month, today.day, 23, 0, 0, panama); + overnightEvent = await calendar.addItem(overnightEvent); + + checkEvents( + { + dateHeader: "Today", + time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T220000Z")), // The date used here is irrelevant. + title: "Evening in Panama", + overlap: "start", + }, + { + dateHeader: "Tomorrow", + time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T040000Z")), // The date used here is irrelevant. + title: "Evening in Panama", + overlap: "end", + } + ); + Assert.stringContains( + TodayPane.agenda.rows[0].querySelector(".agenda-listitem-time").getAttribute("datetime"), + "T17:00:00-05:00" + ); + Assert.stringContains( + TodayPane.agenda.rows[1].querySelector(".agenda-listitem-time").getAttribute("datetime"), + "T23:00:00-05:00" + ); + + await calendar.deleteItem(overnightEvent); +}); + +/** + * Checks events in different time zones are displayed correctly. + */ +add_task(async function testRelativeTime() { + let formatter = new Intl.RelativeTimeFormat(undefined, { style: "short" }); + let now = cal.dtz.now(); + now.second = 0; + info(`The time is now ${now}`); + + let testData = [ + { + name: "two hours ago", + start: "-PT1H55M", + expected: { + classes: ["agenda-listitem-past"], + }, + minHour: 2, + }, + { + name: "one hour ago", + start: "-PT1H5M", + expected: { + classes: ["agenda-listitem-past"], + }, + minHour: 2, + }, + { + name: "23 minutes ago", + start: "-PT23M", + expected: { + classes: ["agenda-listitem-past"], + }, + minHour: 1, + }, + { + name: "now", + start: "-PT5M", + expected: { + relative: ["now"], + classes: ["agenda-listitem-now"], + }, + minHour: 1, + maxHour: 22, + }, + { + name: "19 minutes ahead", + start: "PT19M", + expected: { + relative: [formatter.format(19, "minute"), formatter.format(18, "minute")], + }, + maxHour: 22, + }, + { + name: "one hour ahead", + start: "PT1H25M", + expected: { + relative: [formatter.format(85, "minute"), formatter.format(84, "minute")], + }, + maxHour: 21, + }, + { + name: "one and half hours ahead", + start: "PT1H35M", + expected: { + relative: [formatter.format(2, "hour")], + }, + maxHour: 21, + }, + { + name: "two hours ahead", + start: "PT1H49M", + expected: { + relative: [formatter.format(2, "hour")], + }, + maxHour: 21, + }, + ]; + + let events = []; + let expectedEvents = []; + for (let { name, start, expected, minHour, maxHour } of testData) { + if (minHour && now.hour < minHour) { + info(`Skipping ${name} because it's too early.`); + continue; + } + if (maxHour && now.hour > maxHour) { + info(`Skipping ${name} because it's too late.`); + continue; + } + + let event = new CalEvent(); + event.id = cal.getUUID(); + event.title = name; + event.startDate = now.clone(); + event.startDate.addDuration(cal.createDuration(start)); + event.endDate = event.startDate.clone(); + event.endDate.addDuration(cal.createDuration("PT10M")); + events.push(await calendar.addItem(event)); + + expectedEvents.push({ ...expected, title: name, time: event.startDate }); + } + + expectedEvents[0].dateHeader = "Today"; + checkEvents(...expectedEvents); + + for (let event of events) { + await calendar.deleteItem(event); + } +}); + +/** + * Tests the today pane opens events in the summary dialog for both + * non-recurring and recurring events. + */ +add_task(async function testOpenEvent() { + let noRepeatEvent = new CalEvent(); + noRepeatEvent.id = "no repeat event"; + noRepeatEvent.title = "No Repeat Event"; + noRepeatEvent.startDate = today.clone(); + noRepeatEvent.startDate.hour = startHour; + noRepeatEvent.endDate = noRepeatEvent.startDate.clone(); + noRepeatEvent.endDate.hour++; + + let repeatEvent = new CalEvent(); + repeatEvent.id = "repeated event"; + repeatEvent.title = "Repeated Event"; + repeatEvent.startDate = today.clone(); + repeatEvent.startDate.hour = startHour; + repeatEvent.endDate = noRepeatEvent.startDate.clone(); + repeatEvent.endDate.hour++; + repeatEvent.recurrenceInfo = new CalRecurrenceInfo(repeatEvent); + repeatEvent.recurrenceInfo.appendRecurrenceItem( + cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=5") + ); + + for (let event of [noRepeatEvent, repeatEvent]) { + let addedEvent = await calendar.addItem(event); + + if (event == noRepeatEvent) { + Assert.equal(TodayPane.agenda.rowCount, 1); + } else { + Assert.equal(TodayPane.agenda.rowCount, 5); + } + Assert.equal( + TodayPane.agenda.rows[0].querySelector(".agenda-listitem-title").textContent, + event.title, + "event title is correct" + ); + + let dialogWindowPromise = CalendarTestUtils.waitForEventDialog(); + EventUtils.synthesizeMouseAtCenter(TodayPane.agenda.rows[0], { clickCount: 2 }); + + let dialogWindow = await dialogWindowPromise; + let docUri = dialogWindow.document.documentURI; + Assert.ok( + docUri === "chrome://calendar/content/calendar-summary-dialog.xhtml", + "event summary dialog shown" + ); + + await BrowserTestUtils.closeWindow(dialogWindow); + await calendar.deleteItem(addedEvent); + } +}); + +/** + * Tests that the "New Event" button begins creating an event on the date + * selected in the Today Pane. + */ +add_task(async function testNewEvent() { + async function checkEventDialogDate() { + let dialogWindowPromise = CalendarTestUtils.waitForEventDialog("edit"); + EventUtils.synthesizeMouseAtCenter(newEventButton, {}, window); + await dialogWindowPromise.then(async function (dialogWindow) { + let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe"); + let iframeDocument = iframe.contentDocument; + + let startDate = iframeDocument.getElementById("event-starttime"); + Assert.equal( + startDate._datepicker._inputField.value, + formatDate(expectedDate), + "date should match the expected date" + ); + Assert.equal( + startDate._timepicker._inputField.value, + formatTime(expectedDate), + "time should be the next hour after now" + ); + + await BrowserTestUtils.closeWindow(dialogWindow); + }); + } + + let newEventButton = document.getElementById("todaypane-new-event-button"); + + // Check today with the "day" view. + + TodayPane.displayMiniSection("miniday"); + EventUtils.synthesizeMouseAtCenter(document.getElementById("today-button"), {}, window); + + let expectedDate = cal.dtz.now(); + expectedDate.hour++; + expectedDate.minute = 0; + + await checkEventDialogDate(); + + // Check tomorrow with the "day" view. + + EventUtils.synthesizeMouseAtCenter(document.getElementById("next-day-button"), {}, window); + expectedDate.day++; + + await checkEventDialogDate(); + + // Check today with the "month" view; + + TodayPane.displayMiniSection("minimonth"); + let minimonth = document.getElementById("today-minimonth"); + minimonth.value = new Date(); + expectedDate.day--; + + await checkEventDialogDate(); + + // Check a date in the past with the "month" view; + + minimonth.value = new Date(Date.UTC(2018, 8, 1)); + expectedDate.resetTo(2018, 8, 1, expectedDate.hour, 0, 0, cal.dtz.UTC); + + await checkEventDialogDate(); +}).__skipMe = new Date().getUTCHours() == 23; diff --git a/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js b/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js new file mode 100644 index 0000000000..bac91ed60d --- /dev/null +++ b/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js @@ -0,0 +1,262 @@ +/* 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/. */ + +/** + * Tests for drag and drop on the today pane. + */ +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +const { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); +const { + add_message_to_folder, + be_in_folder, + create_folder, + create_message, + inboxFolder, + select_click_row, +} = ChromeUtils.import("resource://testing-common/mozmill/FolderDisplayHelpers.jsm"); +const { SyntheticPartLeaf } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const calendar = CalendarTestUtils.createCalendar("Mochitest", "memory"); +registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar)); + +/** + * Ensures the today pane is visible for each test. + */ +async function ensureTodayPane() { + let todayPane = document.querySelector("#today-pane-panel"); + if (!todayPane.isVisible()) { + todayPane.setVisible(true, true, true); + } + + await TestUtils.waitForCondition(() => todayPane.isVisible(), "today pane not visible in time"); +} + +/** + * Tests dropping a message from the message pane on to the today pane brings + * up the new event dialog. + */ +add_task(async function testDropMozMessage() { + let folder = await create_folder("Mochitest"); + let subject = "The Grand Event"; + let body = "Parking is available."; + await be_in_folder(folder); + await add_message_to_folder([folder], create_message({ subject, body: { body } })); + select_click_row(0); + + let about3PaneTab = document.getElementById("tabmail").currentTabInfo; + let msg = about3PaneTab.message; + let msgStr = about3PaneTab.folder.getUriForMsg(msg); + let msgUrl = MailServices.messageServiceFromURI(msgStr).getUrlForUri(msgStr); + + // Setup a DataTransfer to mimic what ThreadPaneOnDragStart sends. + let dataTransfer = new DataTransfer(); + dataTransfer.mozSetDataAt("text/x-moz-message", msgStr, 0); + dataTransfer.mozSetDataAt("text/x-moz-url", msgUrl.spec, 0); + dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-url", + msgUrl.spec + "?fileName=" + encodeURIComponent("message.eml"), + 0 + ); + dataTransfer.mozSetDataAt( + "application/x-moz-file-promise", + new window.messageFlavorDataProvider(), + 0 + ); + + let promise = CalendarTestUtils.waitForEventDialog("edit"); + await ensureTodayPane(); + document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer })); + + let eventWindow = await promise; + let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe"); + let iframeDoc = iframe.contentDocument; + + Assert.equal( + iframeDoc.querySelector("#item-title").value, + subject, + "the message subject was used as the event title" + ); + Assert.equal( + iframeDoc.querySelector("#item-description").contentDocument.body.innerText, + body, + "the message body was used as the event description" + ); + + await BrowserTestUtils.closeWindow(eventWindow); + await be_in_folder(inboxFolder); + folder.deleteSelf(null); +}); + +/** + * Tests dropping an entry from the address book adds the address as an attendee + * to a new event when dropped on the today pane. + */ +add_task(async function testMozAddressDrop() { + let vcard = CalendarTestUtils.dedent` + BEGIN:VCARD + VERSION:4.0 + EMAIL;PREF=1:person@example.com + FN:Some Person + N:Some;Person;;; + UID:d5f9113d-5ede-4a5c-ba8e-0f2345369993 + END:VCARD + `; + + let address = "Some Person <person@example.com>"; + + // Setup a DataTransfer to mimic what the address book sends. + let dataTransfer = new DataTransfer(); + dataTransfer.setData("moz/abcard", "0"); + dataTransfer.setData("text/x-moz-address", address); + dataTransfer.setData("text/plain", address); + dataTransfer.setData("text/vcard", decodeURIComponent(vcard)); + dataTransfer.setData("application/x-moz-file-promise-dest-filename", "person.vcf"); + dataTransfer.setData("application/x-moz-file-promise-url", "data:text/vcard," + vcard); + dataTransfer.setData("application/x-moz-file-promise", window.abFlavorDataProvider); + + let promise = CalendarTestUtils.waitForEventDialog("edit"); + await ensureTodayPane(); + document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer })); + + let eventWindow = await promise; + let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe"); + let iframeWin = iframe.cotnentWindow; + let iframeDoc = iframe.contentDocument; + + // Verify the address was added as an attendee. + EventUtils.synthesizeMouseAtCenter( + iframeDoc.querySelector("#event-grid-tab-attendees"), + {}, + iframeWin + ); + + let box = iframeDoc.querySelector('[attendeeid="mailto:person@example.com"]'); + Assert.ok(box, "address included as an attendee to the new event"); + await BrowserTestUtils.closeWindow(eventWindow); +}); + +/** + * Tests dropping plain text that is actually ics data format is picked up by + * the today pane. + */ +add_task(async function testPlainTextICSDrop() { + let event = CalendarTestUtils.dedent` + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:An Event + DESCRIPTION:Parking is not available. + DTSTART:20210325T110000Z + DTEND:20210325T120000Z + UID:916bd967-35ac-40f6-8cd5-487739c9d245 + END:VEVENT + END:VCALENDAR + `; + + // Setup a DataTransfer to mimic what the address book sends. + let dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", event); + + let promise = CalendarTestUtils.waitForEventDialog("edit"); + await ensureTodayPane(); + document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer })); + + let eventWindow = await promise; + let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe"); + let iframeDoc = iframe.contentDocument; + Assert.equal(iframeDoc.querySelector("#item-title").value, "An Event"); + + let startTime = iframeDoc.querySelector("#event-starttime"); + Assert.equal( + startTime._datepicker._inputBoxValue, + cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T110000Z")) + ); + + let endTime = iframeDoc.querySelector("#event-endtime"); + Assert.equal( + endTime._datepicker._inputBoxValue, + cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T120000Z")) + ); + + Assert.equal( + iframeDoc.querySelector("#item-description").contentDocument.body.innerText, + "Parking is not available." + ); + await BrowserTestUtils.closeWindow(eventWindow); +}); + +/** + * Tests dropping a file with an ics extension on the today pane is parsed as an + * ics file. + */ +add_task(async function testICSFileDrop() { + let file = await File.createFromFileName(getTestFilePath("data/event.ics")); + let dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + let promise = CalendarTestUtils.waitForEventDialog("edit"); + await ensureTodayPane(); + + // For some reason, dataTransfer.items.add() results in a mozItemCount of 2 + // instead of one. Call onExternalDrop directly to get around that. + window.calendarCalendarButtonDNDObserver.onExternalDrop(dataTransfer); + + let eventWindow = await promise; + let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe"); + let iframeDoc = iframe.contentDocument; + + Assert.equal(iframeDoc.querySelector("#item-title").value, "An Event"); + + let startTime = iframeDoc.querySelector("#event-starttime"); + Assert.equal( + startTime._datepicker._inputBoxValue, + cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T110000Z")) + ); + + let endTime = iframeDoc.querySelector("#event-endtime"); + Assert.equal( + endTime._datepicker._inputBoxValue, + cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T120000Z")) + ); + + Assert.equal( + iframeDoc.querySelector("#item-description").contentDocument.body.innerText, + "Parking is not available." + ); + await BrowserTestUtils.closeWindow(eventWindow); +}); + +/** + * Tests dropping any other file on the today pane ends up as an attachment + * to a new event. + */ +add_task(async function testOtherFileDrop() { + let file = await File.createFromNsIFile( + new FileUtils.File(getTestFilePath("data/attachment.png")) + ); + let dataTransfer = new DataTransfer(); + dataTransfer.setData("image/png", file); + dataTransfer.items.add(file); + + let promise = CalendarTestUtils.waitForEventDialog("edit"); + await ensureTodayPane(); + document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer })); + + let eventWindow = await promise; + let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe"); + let iframeWin = iframe.contentWindow; + let iframeDoc = iframe.contentDocument; + + EventUtils.synthesizeMouseAtCenter( + iframeDoc.querySelector("#event-grid-tab-attachments"), + {}, + iframeWin + ); + + let listBox = iframeDoc.querySelector("#attachment-link"); + let listItem = listBox.itemChildren[0]; + Assert.equal(listItem.querySelector("label").value, "attachment.png"); + await BrowserTestUtils.closeWindow(eventWindow); +}); diff --git a/comm/calendar/test/browser/browser_todayPane_visibility.js b/comm/calendar/test/browser/browser_todayPane_visibility.js new file mode 100644 index 0000000000..d2176218ed --- /dev/null +++ b/comm/calendar/test/browser/browser_todayPane_visibility.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/. */ + +/* globals openAddonsTab, openChatTab, openNewCalendarEventTab, + * openNewCalendarTaskTab, openPreferencesTab, openTasksTab, + * selectCalendarEventTab, selectCalendarTaskTab, selectFolderTab, + * toAddressBook */ + +// Test that today pane is visible/collapsed correctly for various tab types. +// In all cases today pane should not be visible in preferences or addons tab. +// Also test that the today pane button is visible/hidden for various tab types. +add_task(async () => { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + const todayPane = document.getElementById("today-pane-panel"); + const todayPaneButton = document.getElementById("calendar-status-todaypane-button"); + + let eventTabPanelId, taskTabPanelId; + + async function clickTodayPaneButton() { + // The today pane button will be hidden for certain tabs (e.g. preferences), and then + // the user won't be able to click it, so we shouldn't be able to here either. + if (BrowserTestUtils.is_visible(todayPaneButton)) { + EventUtils.synthesizeMouseAtCenter(todayPaneButton, { clickCount: 1 }); + } + await new Promise(resolve => setTimeout(resolve)); + } + + /** + * Tests whether the today pane is only open in certain tabs. + * + * @param {string[]} tabsWhereVisible - Array of tab mode names for tabs where + * the today pane should be visible. + */ + async function checkTodayPaneVisibility(tabsWhereVisible) { + function check(tabModeName) { + let shouldBeVisible = tabsWhereVisible.includes(tabModeName); + is( + BrowserTestUtils.is_visible(todayPane), + shouldBeVisible, + `today pane is ${shouldBeVisible ? "visible" : "collapsed"} in ${tabModeName} tab` + ); + } + + await selectFolderTab(); + check("folder"); + await CalendarTestUtils.openCalendarTab(window); + check("calendar"); + await openTasksTab(); + check("tasks"); + await openChatTab(); + check("chat"); + await selectCalendarEventTab(eventTabPanelId); + check("calendarEvent"); + await selectCalendarTaskTab(taskTabPanelId); + check("calendarTask"); + await toAddressBook(); + check("addressBookTab"); + await openPreferencesTab(); + check("preferencesTab"); + await openAddonsTab(); + check("contentTab"); + } + + // Show today pane in folder (mail) tab, but not in other tabs. + await selectFolderTab(); + if (!BrowserTestUtils.is_visible(todayPane)) { + await clickTodayPaneButton(); + } + await CalendarTestUtils.openCalendarTab(window); + if (BrowserTestUtils.is_visible(todayPane)) { + await clickTodayPaneButton(); + } + await openTasksTab(); + if (BrowserTestUtils.is_visible(todayPane)) { + await clickTodayPaneButton(); + } + await openChatTab(); + if (BrowserTestUtils.is_visible(todayPane)) { + await clickTodayPaneButton(); + } + eventTabPanelId = await openNewCalendarEventTab(); + if (BrowserTestUtils.is_visible(todayPane)) { + await clickTodayPaneButton(); + } + taskTabPanelId = await openNewCalendarTaskTab(); + if (BrowserTestUtils.is_visible(todayPane)) { + await clickTodayPaneButton(); + } + + await checkTodayPaneVisibility(["folder"]); + + // Show today pane in calendar tab, but not in other tabs. + // Hide it in folder tab. + await selectFolderTab(); + await clickTodayPaneButton(); + // Show it in calendar tab. + await CalendarTestUtils.openCalendarTab(window); + await clickTodayPaneButton(); + + await checkTodayPaneVisibility(["calendar"]); + + // Show today pane in tasks tab, but not in other tabs. + // Hide it in calendar tab. + await CalendarTestUtils.openCalendarTab(window); + await clickTodayPaneButton(); + // Show it in tasks tab. + await openTasksTab(); + await clickTodayPaneButton(); + + await checkTodayPaneVisibility(["tasks"]); + + // Show today pane in chat tab, but not in other tabs. + // Hide it in tasks tab. + await openTasksTab(); + await clickTodayPaneButton(); + // Show it in chat tab. + await openChatTab(); + await clickTodayPaneButton(); + + await checkTodayPaneVisibility(["chat"]); + + // Show today pane in calendar event tab, but not in other tabs. + // Hide it in chat tab. + await openChatTab(); + await clickTodayPaneButton(); + // Show it in calendar event tab. + await selectCalendarEventTab(eventTabPanelId); + await clickTodayPaneButton(); + + await checkTodayPaneVisibility(["calendarEvent"]); + + // Show today pane in calendar task tab, but not in other tabs. + // Hide it in calendar event tab. + await selectCalendarEventTab(eventTabPanelId); + await clickTodayPaneButton(); + // Show it in calendar task tab. + await selectCalendarTaskTab(taskTabPanelId); + await clickTodayPaneButton(); + + await checkTodayPaneVisibility(["calendarTask"]); + + // Check the visibility of the today pane button. + const button = document.getElementById("calendar-status-todaypane-button"); + await selectFolderTab(); + ok(BrowserTestUtils.is_visible(button), "today pane button is visible in folder tab"); + await CalendarTestUtils.openCalendarTab(window); + ok(BrowserTestUtils.is_visible(button), "today pane button is visible in calendar tab"); + await openTasksTab(); + ok(BrowserTestUtils.is_visible(button), "today pane button is visible in tasks tab"); + await openChatTab(); + ok(BrowserTestUtils.is_visible(button), "today pane button is visible in chat tab"); + await selectCalendarEventTab(eventTabPanelId); + ok(BrowserTestUtils.is_visible(button), "today pane button is visible in event tab"); + await selectCalendarTaskTab(taskTabPanelId); + ok(BrowserTestUtils.is_visible(button), "today pane button is visible in task tab"); + await toAddressBook(); + is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in address book tab"); + await openPreferencesTab(); + is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in preferences tab"); + await openAddonsTab(); + is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in addons tab"); +}); diff --git a/comm/calendar/test/browser/contextMenu/browser.ini b/comm/calendar/test/browser/contextMenu/browser.ini new file mode 100644 index 0000000000..b6590e849c --- /dev/null +++ b/comm/calendar/test/browser/contextMenu/browser.ini @@ -0,0 +1,14 @@ +[default] +prefs = + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird + +[browser_edit.js] diff --git a/comm/calendar/test/browser/contextMenu/browser_edit.js b/comm/calendar/test/browser/contextMenu/browser_edit.js new file mode 100644 index 0000000000..672055709f --- /dev/null +++ b/comm/calendar/test/browser/contextMenu/browser_edit.js @@ -0,0 +1,187 @@ +/* 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 { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +const { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +/** + * Grabs a calendar-month-day-box-item from the view using an attribute CSS + * selector. Only works when the calendar is in month view. + */ +async function getDayBoxItem(attrSelector) { + let itemBox; + await TestUtils.waitForCondition(() => { + itemBox = document.querySelector( + `calendar-month-day-box[${attrSelector}] calendar-month-day-box-item` + ); + return itemBox != null; + }, "calendar item did not appear in time"); + return itemBox; +} + +/** + * Switches to the view to the calendar. + */ +add_setup(function () { + return CalendarTestUtils.setCalendarView(window, "month"); +}); + +/** + * Tests the "Edit" menu item is available and opens up the event dialog. + */ +add_task(async function testEditEditableItem() { + let calendar = CalendarTestUtils.createCalendar("Editable", "memory"); + registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar)); + + let title = "Editable Event"; + let event = new CalEvent(); + event.title = title; + event.startDate = cal.createDateTime("20200101T000001Z"); + + await calendar.addItem(event); + window.goToDate(event.startDate); + + let menu = document.querySelector("#calendar-item-context-menu"); + let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem"); + let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + + EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="1"'), { type: "contextmenu" }); + await popupPromise; + Assert.ok(!editMenu.disabled, 'context menu "Edit" item is not disabled for editable event'); + + let editDialogPromise = BrowserTestUtils.domWindowOpened(null, async win => { + await BrowserTestUtils.waitForEvent(win, "load"); + + let doc = win.document; + Assert.ok( + doc.documentURI == "chrome://calendar/content/calendar-event-dialog.xhtml", + "editing event dialog opened" + ); + + let iframe = doc.querySelector("#calendar-item-panel-iframe"); + await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load"); + + let iframeDoc = iframe.contentDocument; + Assert.ok( + (iframeDoc.querySelector("#item-title").value = title), + 'context menu "Edit" item opens the editing dialog' + ); + doc.querySelector("dialog").acceptDialog(); + return true; + }); + + menu.activateItem(editMenu); + await editDialogPromise; +}); + +/** + * Tests that the "Edit" menu item is disabled for events we are not allowed to + * modify. + */ +add_task(async function testEditNonEditableItem() { + let calendar = CalendarTestUtils.createCalendar("Non-Editable", "memory"); + registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar)); + + let event = new CalEvent(); + let acl = { + QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]), + userCanModify: false, + userCanRespond: true, + userCanViewAll: true, + userCanViewDateAndTime: true, + calendarEntry: { + hasAccessControl: true, + userIsOwner: false, + }, + }; + event.title = "Read Only Event"; + event.startDate = cal.createDateTime("20200102T000001Z"); + event.mACLEntry = acl; + + await calendar.addItem(event); + window.goToDate(event.startDate); + + let menu = document.querySelector("#calendar-item-context-menu"); + let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem"); + let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing"); + + EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="2"'), { type: "contextmenu" }); + await popupPromise; + Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled for non-editable event'); + menu.hidePopup(); +}); + +/** + * Tests that the "Edit" menu item is disabled when the event is an invitation. + */ +add_task(async function testInvitation() { + let calendar = CalendarTestUtils.createCalendar("Invitation", "memory"); + calendar.setProperty("organizerId", "mailto:attendee@example.com"); + registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar)); + + let icalString = CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20200103T152601Z + DTSTAMP:20200103T192729Z + UID:x131e + SUMMARY:Invitation + ORGANIZER;CN=Org:mailto:organizer@example.com + ATTENDEE;RSVP=TRUE;CN=attendee@example.com;PARTSTAT=NEEDS-ACTION;CUTY + PE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:attendee@example.com + DTSTART:20200103T153000Z + DTEND:20200103T163000Z + DESCRIPTION:Just a Test + SEQUENCE:0 + TRANSP:OPAQUE + END:VEVENT + `; + + let invitation = new CalEvent(icalString); + await calendar.addItem(invitation); + window.goToDate(invitation.startDate); + + let menu = document.querySelector("#calendar-item-context-menu"); + let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem"); + let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing"); + + EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="3"'), { type: "contextmenu" }); + await popupPromise; + Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled for invitations'); + menu.hidePopup(); +}); + +/** + * Tests that the "Edit" menu item is disabled when the calendar is read-only. + */ +add_task(async function testCalendarReadOnly() { + let calendar = CalendarTestUtils.createCalendar("ReadOnly", "memory"); + registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar)); + + let event = new CalEvent(); + event.title = "ReadOnly Event"; + event.startDate = cal.createDateTime("20200104T000001Z"); + + await calendar.addItem(event); + calendar.setProperty("readOnly", true); + window.goToDate(event.startDate); + + let menu = document.querySelector("#calendar-item-context-menu"); + let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem"); + let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing"); + + EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="4"'), { type: "contextmenu" }); + await popupPromise; + Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled when calendar is read-only'); + menu.hidePopup(); +}); + +registerCleanupFunction(() => { + return CalendarTestUtils.closeCalendarTab(window); +}); diff --git a/comm/calendar/test/browser/data/attachment.png b/comm/calendar/test/browser/data/attachment.png Binary files differnew file mode 100644 index 0000000000..30caecab7b --- /dev/null +++ b/comm/calendar/test/browser/data/attachment.png diff --git a/comm/calendar/test/browser/data/calendars.sjs b/comm/calendar/test/browser/data/calendars.sjs new file mode 100644 index 0000000000..f1175f1903 --- /dev/null +++ b/comm/calendar/test/browser/data/calendars.sjs @@ -0,0 +1,126 @@ +/* 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/. */ + +Cu.importGlobalProperties(["TextEncoder"]); + +function handleRequest(request, response) { + if (!request.hasHeader("Authorization")) { + response.setStatusLine("1.1", 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", `Basic realm="test"`); + return; + } + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml; charset=utf-8", false); + + // Request: + // <propfind> + // <prop> + // <resourcetype/> + // <displayname/> + // <current-user-privilege-set/> + // <calendar-color/> + // </prop> + // </propfind> + + let res = `<multistatus xmlns="DAV:" + xmlns:A="http://apple.com/ns/ical/" + xmlns:C="urn:ietf:params:xml:ns:caldav" + xmlns:CS="http://calendarserver.org/ns/"> + <response> + <href>/browser/comm/calendar/test/browser/data/calendars.sjs</href> + <propstat> + <prop> + <resourcetype> + <collection/> + </resourcetype> + <displayname>Things found by DNS</displayname> + </prop> + <status>HTTP/1.1 200 OK</status> + </propstat> + <propstat> + <prop> + <current-user-privilege-set/> + <A:calendar-color/> + </prop> + <status>HTTP/1.1 404 Not Found</status> + </propstat> + </response> + <response> + <href>/browser/comm/calendar/test/browser/data/calendar.sjs</href> + <propstat> + <prop> + <resourcetype> + <collection/> + <C:calendar/> + <CS:shared/> + </resourcetype> + <displayname>You found me!</displayname> + <A:calendar-color>#008000</A:calendar-color> + </prop> + <status>HTTP/1.1 200 OK</status> + </propstat> + <propstat> + <prop> + <current-user-privilege-set/> + </prop> + <status>HTTP/1.1 404 Not Found</status> + </propstat> + </response> + <response> + <href>/browser/comm/calendar/test/browser/data/calendar2.sjs</href> + <propstat> + <prop> + <resourcetype> + <collection/> + <C:calendar/> + <CS:shared/> + </resourcetype> + <displayname>RΓΆda dagar</displayname> + <A:calendar-color>#ff0000</A:calendar-color> + <current-user-privilege-set> + <privilege> + <read/> + </privilege> + <privilege> + <C:read-free-busy/> + </privilege> + <privilege> + <read-current-user-privilege-set/> + </privilege> + <privilege> + <write/> + </privilege> + <privilege> + <write-content/> + </privilege> + <privilege> + <write-properties/> + </privilege> + <privilege> + <bind/> + </privilege> + <privilege> + <unbind/> + </privilege> + </current-user-privilege-set> + </prop> + <status>HTTP/1.1 200 OK</status> + </propstat> + <propstat> + <prop> + <current-user-privilege-set/> + </prop> + <status>HTTP/1.1 404 Not Found</status> + </propstat> + </response> + </multistatus>`; + + let bytes = new TextEncoder().encode(res); + let str = ""; + for (let i = 0; i < bytes.length; i += 65536) { + str += String.fromCharCode.apply(null, bytes.subarray(i, i + 65536)); + } + response.write(str); +} diff --git a/comm/calendar/test/browser/data/dns.sjs b/comm/calendar/test/browser/data/dns.sjs new file mode 100644 index 0000000000..85b8777233 --- /dev/null +++ b/comm/calendar/test/browser/data/dns.sjs @@ -0,0 +1,56 @@ +/* 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/. */ + +function handleRequest(request, response) { + if (!request.hasHeader("Authorization")) { + response.setStatusLine("1.1", 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", `Basic realm="test"`); + return; + } + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml", false); + + // Request: + // <propfind> + // <prop> + // <resourcetype/> + // <owner/> + // <displayname/> + // <current-user-principal/> + // <current-user-privilege-set/> + // <calendar-color/> + // <calendar-home-set/> + // </prop> + // </propfind> + + response.write(`<multistatus xmlns="DAV:" + xmlns:A="http://apple.com/ns/ical/" + xmlns:C="urn:ietf:params:xml:ns:caldav"> + <response> + <href>/browser/comm/calendar/test/browser/data/dns.sjs</href> + <propstat> + <prop> + <resourcetype> + <collection/> + </resourcetype> + <current-user-principal> + <href>/browser/comm/calendar/test/browser/data/principal.sjs</href> + </current-user-principal> + </prop> + <status>HTTP/1.1 200 OK</status> + </propstat> + <propstat> + <prop> + <owner/> + <displayname/> + <current-user-privilege-set/> + <A:calendar-color/> + <C:calendar-home-set/> + </prop> + <status>HTTP/1.1 404 Not Found</status> + </propstat> + </response> + </multistatus>`); +} diff --git a/comm/calendar/test/browser/data/event.ics b/comm/calendar/test/browser/data/event.ics new file mode 100644 index 0000000000..3ee7fd4495 --- /dev/null +++ b/comm/calendar/test/browser/data/event.ics @@ -0,0 +1,10 @@ +BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:An Event
+DESCRIPTION:Parking is not available.
+DTSTART:20210325T110000Z
+DTEND:20210325T120000Z
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/browser/data/import.ics b/comm/calendar/test/browser/data/import.ics new file mode 100644 index 0000000000..b6e7a965d7 --- /dev/null +++ b/comm/calendar/test/browser/data/import.ics @@ -0,0 +1,24 @@ +BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:Event One
+DTSTART:20190101T150000
+DTEND:20190101T160000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Four
+DTSTART:20190101T180000
+DTEND:20190101T190000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Three
+DTSTART:20190101T170000
+DTEND:20190101T180000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Two
+DTSTART:20190101T160000
+DTEND:20190101T170000
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/browser/data/principal.sjs b/comm/calendar/test/browser/data/principal.sjs new file mode 100644 index 0000000000..4cebd9660e --- /dev/null +++ b/comm/calendar/test/browser/data/principal.sjs @@ -0,0 +1,39 @@ +/* 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/. */ + +function handleRequest(request, response) { + if (!request.hasHeader("Authorization")) { + response.setStatusLine("1.1", 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", `Basic realm="test"`); + return; + } + + response.setStatusLine("1.1", 207, "Multi-Status"); + response.setHeader("Content-Type", "text/xml", false); + + // Request: + // <propfind> + // <prop> + // <resourcetype/> + // <calendar-home-set/> + // </prop> + // </propfind> + + response.write(`<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> + <response> + <href>/browser/comm/calendar/test/browser/data/principal.sjs</href> + <propstat> + <prop> + <resourcetype> + <principal/> + </resourcetype> + <C:calendar-home-set> + <href>/browser/comm/calendar/test/browser/data/calendars.sjs</href> + </C:calendar-home-set> + </prop> + <status>HTTP/1.1 200 OK</status> + </propstat> + </response> + </multistatus>`); +} diff --git a/comm/calendar/test/browser/eventDialog/browser.ini b/comm/calendar/test/browser/eventDialog/browser.ini new file mode 100644 index 0000000000..85f569c0cc --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser.ini @@ -0,0 +1,27 @@ +[default] +head = head.js +prefs = + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +support-files = data/** + +[browser_alarmDialog.js] +[browser_attachMenu.js] +[browser_attendeesDialog.js] +[browser_attendeesDialogAdd.js] +[browser_attendeesDialogNoEdit.js] +[browser_attendeesDialogRemove.js] +[browser_attendeesDialogUpdate.js] +[browser_eventDialog.js] +[browser_eventDialogDescriptionEditor.js] +[browser_eventDialogEditButton.js] +[browser_eventDialogModificationPrompt.js] +[browser_utf8.js] diff --git a/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js b/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js new file mode 100644 index 0000000000..0d6a07a3c4 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js @@ -0,0 +1,88 @@ +/* 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 { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { dayView } = CalendarTestUtils; + +add_task(async function testAlarmDialog() { + let now = new Date(); + + const TITLE = "Event"; + + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate( + window, + now.getUTCFullYear(), + now.getUTCMonth() + 1, + now.getUTCDate() + ); + await CalendarTestUtils.calendarViewForward(window, 1); + + let allDayHeader = dayView.getAllDayHeader(window); + Assert.ok(allDayHeader); + EventUtils.synthesizeMouseAtCenter(allDayHeader, {}, window); + + // Create a new all-day event tomorrow. + + // Prepare to dismiss the alarm. + let alarmPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-alarm-dialog.xhtml", + { + async callback(alarmWindow) { + await new Promise(resolve => alarmWindow.setTimeout(resolve, 500)); + + let dismissButton = alarmWindow.document.getElementById("alarm-dismiss-all-button"); + EventUtils.synthesizeMouseAtCenter(dismissButton, {}, alarmWindow); + }, + } + ); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window); + await setData(dialogWindow, iframeWindow, { + allday: true, + reminder: "1day", + title: TITLE, + }); + + await saveAndCloseItemDialog(dialogWindow); + await alarmPromise; + + // Change the reminder duration, this resets the alarm. + let eventBox = await dayView.waitForAllDayItemAt(window, 1); + + // Prepare to snooze the alarm. + alarmPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-alarm-dialog.xhtml", + { + async callback(alarmWindow) { + await new Promise(resolve => alarmWindow.setTimeout(resolve, 500)); + + let snoozeAllButton = alarmWindow.document.getElementById("alarm-snooze-all-button"); + let popup = alarmWindow.document.querySelector("#alarm-snooze-all-popup"); + let menuitems = alarmWindow.document.querySelectorAll("#alarm-snooze-all-popup > menuitem"); + + let shownPromise = BrowserTestUtils.waitForEvent(snoozeAllButton, "popupshown"); + EventUtils.synthesizeMouseAtCenter(snoozeAllButton, {}, alarmWindow); + await shownPromise; + popup.activateItem(menuitems[5]); + }, + } + ); + + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventBox)); + await setData(dialogWindow, iframeWindow, { reminder: "2days", title: TITLE }); + await saveAndCloseItemDialog(dialogWindow); + await alarmPromise; + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_attachMenu.js b/comm/calendar/test/browser/eventDialog/browser_attachMenu.js new file mode 100644 index 0000000000..2a0b2afc4c --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_attachMenu.js @@ -0,0 +1,266 @@ +/* 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/. */ + +/** + * Tests for the attach menu in the event dialog window. + */ + +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +const { cloudFileAccounts } = ChromeUtils.import("resource:///modules/cloudFileAccounts.jsm"); +const { MockFilePicker } = ChromeUtils.importESModule( + "resource://testing-common/MockFilePicker.sys.mjs" +); +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +// Remove the save prompt observer that head.js added. It's causing trouble here. +Services.ww.unregisterNotification(savePromptObserver); + +let calendar = CalendarTestUtils.createCalendar("Attachments"); +registerCleanupFunction(() => { + cal.manager.unregisterCalendar(calendar); + MockFilePicker.cleanup(); +}); + +async function getEventBox(selector) { + let itemBox; + await TestUtils.waitForCondition(() => { + itemBox = document.querySelector(selector); + return itemBox != null; + }, "calendar item did not appear in time"); + return itemBox; +} + +async function openEventFromBox(eventBox) { + if (Services.focus.activeWindow != window) { + await BrowserTestUtils.waitForEvent(window, "focus"); + } + let promise = CalendarTestUtils.waitForEventDialog(); + EventUtils.synthesizeMouseAtCenter(eventBox, { clickCount: 2 }); + return promise; +} + +/** + * Tests using the "Website" menu item attaches a link to the event. + */ +add_task(async function testAttachWebPage() { + let startDate = cal.createDateTime("20200101T000001Z"); + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(startDate); + + let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } = + await CalendarTestUtils.editNewEvent(window); + + await setData(dialogWindow, iframeWindow, { + title: "Web Link Event", + startDate, + }); + + // Attach the url. + let attachButton = dialogWindow.document.querySelector("#button-url"); + Assert.ok(attachButton, "attach menu button found"); + + let menu = dialogDocument.querySelector("#button-attach-menupopup"); + let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(attachButton, {}, dialogWindow); + await menuShowing; + + let url = "https://thunderbird.net/"; + let urlPrompt = BrowserTestUtils.promiseAlertDialogOpen( + "", + "chrome://global/content/commonDialog.xhtml", + { + async callback(win) { + win.document.querySelector("#loginTextbox").value = url; + EventUtils.synthesizeKey("VK_RETURN", {}, win); + }, + } + ); + EventUtils.synthesizeMouseAtCenter( + dialogDocument.querySelector("#button-attach-url"), + {}, + dialogWindow + ); + await urlPrompt; + + // Now check that the url shows in the attachments list. + EventUtils.synthesizeMouseAtCenter( + iframeDocument.querySelector("#event-grid-tab-attachments"), + {}, + iframeWindow + ); + + let listBox = iframeDocument.querySelector("#attachment-link"); + await BrowserTestUtils.waitForCondition( + () => listBox.itemChildren.length == 1, + "attachment list did not show in time" + ); + + Assert.equal(listBox.itemChildren[0].tooltipText, url, "url included in attachments list"); + + // Save the new event. + await saveAndCloseItemDialog(dialogWindow); + + // Open the event to verify the attachment is shown in the summary dialog. + let summaryWin = await openEventFromBox(await getEventBox("calendar-month-day-box-item")); + let label = summaryWin.document.querySelector(`label[value="${url}"]`); + Assert.ok(label, "attachment label found on calendar summary dialog"); + await BrowserTestUtils.closeWindow(summaryWin); + + // Clean up. + let eventBox = await getEventBox("calendar-month-day-box-item"); + eventBox.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}); +}); + +/** + * Tests selecting a provider from the attach menu works. + */ +add_task(async function testAttachProvider() { + let fileUrl = "https://path/to/mock/file.pdf"; + let iconURL = "chrome://messenger/content/extension.svg"; + let provider = { + type: "Mochitest", + displayName: "Mochitest", + iconURL, + initAccount(accountKey) { + return { + accountKey, + type: "Mochitest", + get displayName() { + return Services.prefs.getCharPref( + `mail.cloud_files.accounts.${this.accountKey}.displayName`, + "Mochitest Account" + ); + }, + iconURL, + configured: true, + managementURL: "", + uploadFile(window, aFile) { + return new Promise(resolve => + setTimeout(() => + resolve({ + id: 1, + path: aFile.path, + size: aFile.fileSize, + url: fileUrl, + // The uploadFile() function should return serviceIcon, serviceName + // and serviceUrl - either default or user defined values specified + // by the onFileUpload event. The item-edit dialog uses only the + // serviceIcon. + serviceIcon: "chrome://messenger/skin/icons/globe.svg", + }) + ) + ); + }, + }; + }, + }; + + cloudFileAccounts.registerProvider("Mochitest", provider); + cloudFileAccounts.createAccount("Mochitest"); + registerCleanupFunction(() => { + cloudFileAccounts.unregisterProvider("Mochitest"); + }); + + let file = new FileUtils.File(getTestFilePath("data/guests.txt")); + MockFilePicker.init(window); + MockFilePicker.setFiles([file]); + MockFilePicker.returnValue = MockFilePicker.returnOk; + + let startDate = cal.createDateTime("20200201T000001Z"); + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(startDate); + + let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } = + await CalendarTestUtils.editNewEvent(window); + + await setData(dialogWindow, iframeWindow, { + title: "Provider Attachment Event", + startDate, + }); + + let attachButton = dialogDocument.querySelector("#button-url"); + Assert.ok(attachButton, "attach menu button found"); + + let menu = dialogDocument.querySelector("#button-attach-menupopup"); + let menuItem; + + await BrowserTestUtils.waitForCondition(() => { + menuItem = menu.querySelector("menuitem[label='File using Mochitest Account']"); + return menuItem; + }); + + Assert.ok(menuItem, "custom provider menuitem found"); + Assert.equal(menuItem.image, iconURL, "provider image src is provider image"); + + // Click on the "Attach" menu. + let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(attachButton, {}, dialogWindow); + await menuShowing; + + // Click on the menuitem to attach a file using our provider. + let menuHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(menuItem, {}, dialogWindow); + await menuHidden; + + // Check if the file dialog was "shown". MockFilePicker.open() is asynchronous + // but does not return a promise. + await BrowserTestUtils.waitForCondition( + () => MockFilePicker.shown, + "file picker was not shown in time" + ); + + // Click on the attachments tab of the event dialog. + EventUtils.synthesizeMouseAtCenter( + iframeDocument.querySelector("#event-grid-tab-attachments"), + {}, + iframeWindow + ); + + // Wait until the file we attached appears. + let listBox = iframeDocument.querySelector("#attachment-link"); + await BrowserTestUtils.waitForCondition( + () => listBox.itemChildren.length == 1, + "attachment list did not show in time" + ); + + let listItem = listBox.itemChildren[0]; + + // XXX: This property is set after an async operation. Unfortunately, that + // operation is not awaited on in its surrounding code so the assertion + // after this will occasionally fail if this is not done. + await BrowserTestUtils.waitForCondition( + () => listItem.attachCloudFileUpload, + "attachCloudFileUpload property not set on attachment listitem in time." + ); + + Assert.equal(listItem.attachCloudFileUpload.url, fileUrl, "upload attached to event"); + + let listItemImage = listItem.querySelector("img"); + Assert.equal( + listItemImage.src, + "chrome://messenger/skin/icons/globe.svg", + "attachment image is provider image" + ); + + // Save the new event. + dialogDocument.querySelector("#button-saveandclose").click(); + + // Open it and verify the attachment is shown. + let summaryWin = await openEventFromBox(await getEventBox("calendar-month-day-box-item")); + let label = summaryWin.document.querySelector(`label[value="${fileUrl}"]`); + Assert.ok(label, "attachment label found on calendar summary dialog"); + await BrowserTestUtils.closeWindow(summaryWin); + + if (Services.focus.activeWindow != window) { + await BrowserTestUtils.waitForEvent(window, "focus"); + } + + // Clean up. + let eventBox = await getEventBox("calendar-month-day-box-item"); + eventBox.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js new file mode 100644 index 0000000000..f6e73f3957 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js @@ -0,0 +1,462 @@ +/* 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/. */ + +/* globals createEventWithDialog, openAttendeesWindow, closeAttendeesWindow */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +add_task(async () => { + let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory"); + calendar.name = "Mochitest"; + calendar.setProperty("organizerId", "mailto:mochitest@example.com"); + + cal.freeBusyService.addProvider(freeBusyProvider); + + let book = MailServices.ab.getDirectoryFromId( + MailServices.ab.newAddressBook("Mochitest", null, 101) + ); + let contacts = {}; + for (let name of ["Charlie", "Juliet", "Mike", "Oscar", "Romeo", "Victor"]) { + let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(Ci.nsIAbCard); + card.firstName = name; + card.lastName = "Mochitest"; + card.displayName = `${name} Mochitest`; + card.primaryEmail = `${name.toLowerCase()}@example.com`; + contacts[name.toUpperCase()] = book.addCard(card); + } + let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(Ci.nsIAbDirectory); + list.isMailList = true; + list.dirName = "The Boys"; + list = book.addMailList(list); + list.addCard(contacts.MIKE); + list.addCard(contacts.OSCAR); + list.addCard(contacts.ROMEO); + list.addCard(contacts.VICTOR); + + let today = new Date(); + let times = { + ONE: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 13, 0, 0) + ), + TWO_THIRTY: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 14, 30, 0) + ), + THREE_THIRTY: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 15, 30, 0) + ), + FOUR: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 16, 0, 0) + ), + }; + + registerCleanupFunction(async () => { + CalendarTestUtils.removeCalendar(calendar); + cal.freeBusyService.removeProvider(freeBusyProvider); + MailServices.ab.deleteAddressBook(book.URI); + }); + + let eventWindow = await openEventWindow(calendar); + let eventDocument = eventWindow.document; + let iframeDocument = eventDocument.getElementById("calendar-item-panel-iframe").contentDocument; + + let eventStartTime = iframeDocument.getElementById("event-starttime"); + eventStartTime.value = times.ONE; + let eventEndTime = iframeDocument.getElementById("event-endtime"); + eventEndTime.value = times.THREE_THIRTY; + + async function checkAttendeesInAttendeesDialog(attendeesDocument, expectedAttendees) { + let attendeesList = attendeesDocument.getElementById("attendee-list"); + await TestUtils.waitForCondition( + () => attendeesList.childElementCount == expectedAttendees.length + 1, + "empty attendee input should have been added" + ); + + function getInputValueFromAttendeeRow(row) { + const input = row.querySelector("input"); + return input.value; + } + + Assert.deepEqual( + Array.from(attendeesList.children, getInputValueFromAttendeeRow), + [...expectedAttendees, ""], + "attendees list matches what was expected" + ); + Assert.equal( + attendeesDocument.activeElement, + attendeesList.children[expectedAttendees.length].querySelector("input"), + "empty attendee input should have focus" + ); + } + + async function checkFreeBusy(row, count) { + Assert.equal(row._freeBusyDiv.querySelectorAll(".pending").length, 1); + Assert.equal(row._freeBusyDiv.querySelectorAll(".busy").length, 0); + let responsePromise = BrowserTestUtils.waitForEvent(row, "freebusy-update-finished"); + freeBusyProvider.sendNextResponse(); + await responsePromise; + Assert.equal(row._freeBusyDiv.querySelectorAll(".pending").length, 0); + Assert.equal(row._freeBusyDiv.querySelectorAll(".busy").length, count); + } + + { + info("Opening for the first time"); + let attendeesWindow = await openAttendeesWindow(eventWindow); + let attendeesDocument = attendeesWindow.document; + let attendeesList = attendeesDocument.getElementById("attendee-list"); + + Assert.equal(attendeesWindow.arguments[0].calendar, calendar); + Assert.equal(attendeesWindow.arguments[0].organizer, null); + Assert.equal(calendar.getProperty("organizerId"), "mailto:mochitest@example.com"); + Assert.deepEqual(attendeesWindow.arguments[0].attendees, []); + + await new Promise(resolve => attendeesWindow.setTimeout(resolve)); + + let attendeesStartTime = attendeesDocument.getElementById("event-starttime"); + let attendeesEndTime = attendeesDocument.getElementById("event-endtime"); + Assert.equal(attendeesStartTime.value.toISOString(), times.ONE.toISOString()); + Assert.equal(attendeesEndTime.value.toISOString(), times.THREE_THIRTY.toISOString()); + + attendeesStartTime.value = times.TWO_THIRTY; + attendeesEndTime.value = times.FOUR; + + // Check free/busy of organizer. + + await checkAttendeesInAttendeesDialog(attendeesDocument, ["mochitest@example.com"]); + + let organizer = attendeesList.firstElementChild; + await checkFreeBusy(organizer, 5); + + // Add attendee. + + EventUtils.sendString("test@example.com", attendeesWindow); + EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow); + + await checkAttendeesInAttendeesDialog(attendeesDocument, [ + "mochitest@example.com", + "test@example.com", + ]); + await checkFreeBusy(attendeesList.children[1], 0); + + // Add another attendee, from the address book. + + let input = attendeesDocument.activeElement; + EventUtils.sendString("julie", attendeesWindow); + await new Promise(resolve => attendeesWindow.setTimeout(resolve, 1000)); + Assert.equal(input.value, "juliet Mochitest <juliet@example.com>"); + Assert.ok(input.popupElement.popupOpen); + Assert.equal(input.popupElement.richlistbox.childElementCount, 1); + Assert.equal(input.popupElement._currentIndex, 1); + EventUtils.synthesizeKey("VK_DOWN", {}, attendeesWindow); + Assert.equal(input.popupElement._currentIndex, 1); + EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow); + + await checkAttendeesInAttendeesDialog(attendeesDocument, [ + "mochitest@example.com", + "test@example.com", + "Juliet Mochitest <juliet@example.com>", + ]); + await checkFreeBusy(attendeesList.children[2], 1); + + // Add a mailing list which should expand. + + input = attendeesDocument.activeElement; + EventUtils.sendString("boys", attendeesWindow); + await new Promise(resolve => attendeesWindow.setTimeout(resolve, 1000)); + Assert.equal(input.value, "boys >> The Boys <The Boys>"); + Assert.ok(input.popupElement.popupOpen); + Assert.equal(input.popupElement.richlistbox.childElementCount, 1); + Assert.equal(input.popupElement._currentIndex, 1); + EventUtils.synthesizeKey("VK_DOWN", {}, attendeesWindow); + Assert.equal(input.popupElement._currentIndex, 1); + EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow); + + await checkAttendeesInAttendeesDialog(attendeesDocument, [ + "mochitest@example.com", + "test@example.com", + "Juliet Mochitest <juliet@example.com>", + "Mike Mochitest <mike@example.com>", + "Oscar Mochitest <oscar@example.com>", + "Romeo Mochitest <romeo@example.com>", + "Victor Mochitest <victor@example.com>", + ]); + await checkFreeBusy(attendeesList.children[3], 0); + await checkFreeBusy(attendeesList.children[4], 0); + await checkFreeBusy(attendeesList.children[5], 1); + await checkFreeBusy(attendeesList.children[6], 0); + + await closeAttendeesWindow(attendeesWindow); + await new Promise(resolve => eventWindow.setTimeout(resolve)); + } + + Assert.equal(eventStartTime.value.toISOString(), times.TWO_THIRTY.toISOString()); + Assert.equal(eventEndTime.value.toISOString(), times.FOUR.toISOString()); + + function checkAttendeesInEventDialog(organizer, expectedAttendees) { + Assert.equal(iframeDocument.getElementById("item-organizer-row").textContent, organizer); + + let attendeeItems = iframeDocument.querySelectorAll(".attendee-list .attendee-label"); + Assert.equal(attendeeItems.length, expectedAttendees.length); + for (let i = 0; i < expectedAttendees.length; i++) { + Assert.equal(attendeeItems[i].getAttribute("attendeeid"), expectedAttendees[i]); + } + } + + checkAttendeesInEventDialog("mochitest@example.com", [ + "mailto:mochitest@example.com", + "mailto:test@example.com", + "mailto:juliet@example.com", + "mailto:mike@example.com", + "mailto:oscar@example.com", + "mailto:romeo@example.com", + "mailto:victor@example.com", + ]); + + { + info("Opening for a second time"); + let attendeesWindow = await openAttendeesWindow(eventWindow); + let attendeesDocument = attendeesWindow.document; + let attendeesList = attendeesDocument.getElementById("attendee-list"); + + let attendeesStartTime = attendeesDocument.getElementById("event-starttime"); + let attendeesEndTime = attendeesDocument.getElementById("event-endtime"); + Assert.equal(attendeesStartTime.value.toISOString(), times.TWO_THIRTY.toISOString()); + Assert.equal(attendeesEndTime.value.toISOString(), times.FOUR.toISOString()); + + await checkAttendeesInAttendeesDialog(attendeesDocument, [ + "mochitest@example.com", + "test@example.com", + "Juliet Mochitest <juliet@example.com>", + "Mike Mochitest <mike@example.com>", + "Oscar Mochitest <oscar@example.com>", + "Romeo Mochitest <romeo@example.com>", + "Victor Mochitest <victor@example.com>", + ]); + + await checkFreeBusy(attendeesList.children[0], 5); + await checkFreeBusy(attendeesList.children[1], 0); + await checkFreeBusy(attendeesList.children[2], 1); + await checkFreeBusy(attendeesList.children[3], 0); + await checkFreeBusy(attendeesList.children[4], 0); + await checkFreeBusy(attendeesList.children[5], 1); + await checkFreeBusy(attendeesList.children[6], 0); + + await closeAttendeesWindow(attendeesWindow); + await new Promise(resolve => eventWindow.setTimeout(resolve)); + } + + Assert.equal(eventStartTime.value.toISOString(), times.TWO_THIRTY.toISOString()); + Assert.equal(eventEndTime.value.toISOString(), times.FOUR.toISOString()); + + checkAttendeesInEventDialog("mochitest@example.com", [ + "mailto:mochitest@example.com", + "mailto:test@example.com", + "mailto:juliet@example.com", + "mailto:mike@example.com", + "mailto:oscar@example.com", + "mailto:romeo@example.com", + "mailto:victor@example.com", + ]); + + iframeDocument.getElementById("notify-attendees-checkbox").checked = false; + await closeEventWindow(eventWindow); +}); + +add_task(async () => { + let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory"); + calendar.setProperty("organizerId", "mailto:mochitest@example.com"); + + registerCleanupFunction(async () => { + CalendarTestUtils.removeCalendar(calendar); + }); + + let defaults = { + displayTimezone: true, + attendees: [], + organizer: null, + calendar, + onOk: () => {}, + }; + + async function testDays(startTime, endTime, expectedFirst, expectedLast) { + let attendeesWindow = await openAttendeesWindow({ ...defaults, startTime, endTime }); + let attendeesDocument = attendeesWindow.document; + + let days = attendeesDocument.querySelectorAll("calendar-day"); + Assert.equal(days.length, 16); + Assert.equal(days[0].date.icalString, expectedFirst); + Assert.equal(days[15].date.icalString, expectedLast); + + await closeAttendeesWindow(attendeesWindow); + } + + // With the management of the reduced days or not, the format of the dates is different according to the cases. + // In case of a reduced day, the day format will include the start hour of the day (defined by calendar.view.daystarthour). + // In the case of a full day, we keep the behavior similar to before. + + //Full day tests + await testDays( + cal.createDateTime("20100403T020000"), + cal.createDateTime("20100403T030000"), + "20100403", + "20100418" + ); + for (let i = -2; i < 0; i++) { + await testDays( + fromToday({ days: i, hours: 2 }), + fromToday({ days: i, hours: 3 }), + fromToday({ days: i }).icalString.substring(0, 8), + fromToday({ days: i + 15 }).icalString.substring(0, 8) + ); + } + for (let i = 0; i < 3; i++) { + await testDays( + fromToday({ days: i, hours: 2 }), + fromToday({ days: i, hours: 3 }), + fromToday({ days: 0 }).icalString.substring(0, 8), + fromToday({ days: 15 }).icalString.substring(0, 8) + ); + } + for (let i = 3; i < 5; i++) { + await testDays( + fromToday({ days: i, hours: 2 }), + fromToday({ days: i, hours: 3 }), + fromToday({ days: i - 2 }).icalString.substring(0, 8), + fromToday({ days: i + 13 }).icalString.substring(0, 8) + ); + } + await testDays( + cal.createDateTime("20300403T020000"), + cal.createDateTime("20300403T030000"), + "20300401", + "20300416" + ); + + // Reduced day tests + let dayStartHour = Services.prefs.getIntPref("calendar.view.daystarthour", 8).toString(); + if (dayStartHour.length == 1) { + dayStartHour = "0" + dayStartHour; + } + + await testDays( + cal.createDateTime("20100403T120000"), + cal.createDateTime("20100403T130000"), + "20100403T" + dayStartHour + "0000Z", + "20100418T" + dayStartHour + "0000Z" + ); + for (let i = -2; i < 0; i++) { + await testDays( + fromToday({ days: i, hours: 12 }), + fromToday({ days: i, hours: 13 }), + fromToday({ days: i }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z", + fromToday({ days: i + 15 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z" + ); + } + for (let i = 0; i < 3; i++) { + await testDays( + fromToday({ days: i, hours: 12 }), + fromToday({ days: i, hours: 13 }), + fromToday({ days: 0 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z", + fromToday({ days: 15 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z" + ); + } + for (let i = 3; i < 5; i++) { + await testDays( + fromToday({ days: i, hours: 12 }), + fromToday({ days: i, hours: 13 }), + fromToday({ days: i - 2 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z", + fromToday({ days: i + 13 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z" + ); + } + await testDays( + cal.createDateTime("20300403T120000"), + cal.createDateTime("20300403T130000"), + "20300401T" + dayStartHour + "0000Z", + "20300416T" + dayStartHour + "0000Z" + ); +}); + +function openEventWindow(calendar) { + let eventWindowPromise = BrowserTestUtils.domWindowOpened(null, async win => { + await BrowserTestUtils.waitForEvent(win, "load"); + + let doc = win.document; + if (doc.documentURI == "chrome://calendar/content/calendar-event-dialog.xhtml") { + let iframe = doc.getElementById("calendar-item-panel-iframe"); + await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load"); + return true; + } + return false; + }); + createEventWithDialog(calendar, null, null, "Event"); + return eventWindowPromise; +} + +async function closeEventWindow(eventWindow) { + let eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow); + eventWindow.document.getElementById("button-saveandclose").click(); + await eventWindowPromise; + await new Promise(resolve => setTimeout(resolve)); +} + +function fromToday({ days = 0, hours = 0 }) { + if (!fromToday.today) { + fromToday.today = cal.dtz.now(); + fromToday.today.hour = fromToday.today.minute = fromToday.today.second = 0; + } + + let duration = cal.createDuration(); + duration.days = days; + duration.hours = hours; + + let value = fromToday.today.clone(); + value.addDuration(duration); + return value; +} + +var freeBusyProvider = { + pendingRequests: [], + sendNextResponse() { + let next = this.pendingRequests.shift(); + if (next) { + next(); + } + }, + getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) { + this.pendingRequests.push(() => { + info(`Sending free/busy response for ${aCalId}`); + if (aCalId in this.data) { + aListener.onResult( + null, + this.data[aCalId].map(([startDuration, duration]) => { + let start = fromToday(startDuration); + + let end = start.clone(); + end.addDuration(cal.createDuration(duration)); + + return new cal.provider.FreeBusyInterval( + aCalId, + Ci.calIFreeBusyInterval.BUSY, + start, + end + ); + }) + ); + } else { + aListener.onResult(null, []); + } + }); + }, + data: { + "mailto:mochitest@example.com": [ + [{ days: 1, hours: 4 }, "PT3H"], + [{ days: 1, hours: 8 }, "PT3H"], + [{ days: 1, hours: 12 }, "PT3H"], + [{ days: 1, hours: 16 }, "PT3H"], + [{ days: 2, hours: 4 }, "PT3H"], + ], + "mailto:juliet@example.com": [["P1DT9H", "PT8H"]], + "mailto:romeo@example.com": [["P1DT14H", "PT5H"]], + }, +}; diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js new file mode 100644 index 0000000000..c1f2778118 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js @@ -0,0 +1,248 @@ +/* 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/. */ + +/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */ + +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); + +add_setup(async function () { + await CalendarTestUtils.setCalendarView(window, "day"); + CalendarTestUtils.goToDate(window, 2023, 2, 18); +}); + +add_task(async function testAddAttendeeToEventWithNone() { + const calendar = CalendarTestUtils.createCalendar(); + calendar.setProperty("organizerId", "mailto:foo@example.com"); + calendar.setProperty("organizerCN", "Foo Fooson"); + + // Create an event which currently has no attendees or organizer. + const event = await calendar.addItem( + new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + SUMMARY:An event + DTSTART:20230218T100000Z + DTEND:20230218T110000Z + END:VEVENT + `) + ); + + // Remember event details so we can refetch it after editing. + const eventId = event.id; + const eventModified = event.lastModifiedTime; + + // Sanity check. + Assert.equal(event.organizer, null, "event should not have an organizer"); + Assert.equal(event.getAttendees().length, 0, "event should not have any attendees"); + + // Open our event for editing. + const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1); + const attendeesWindow = await openAttendeesWindow(eventWindow); + + // Set text in the empty row to create a new attendee. + findAndEditMatchingRow( + attendeesWindow, + "bar@example.com", + "there should an empty input", + value => value === "" + ); + + // Save and close the event. + await closeAttendeesWindow(attendeesWindow); + await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow); + + await TestUtils.waitForCondition(async () => { + const item = await calendar.getItem(eventId); + return item.lastModifiedTime != eventModified; + }); + + const editedEvent = await calendar.getItem(eventId); + + // Verify that the organizer was set on the event. + const organizer = editedEvent.organizer; + Assert.ok(organizer, "there should be an organizer for the event after editing"); + Assert.equal( + organizer.id, + "mailto:foo@example.com", + "organizer ID should match calendar property" + ); + Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match calendar property"); + + const attendees = editedEvent.getAttendees(); + Assert.equal(attendees.length, 2, "there should be two attendees of the event after editing"); + + // Verify that the organizer was added as an attendee. + const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com"); + Assert.ok(fooFooson, "the organizer should have been added as an attendee"); + Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match organizer's"); + Assert.equal( + fooFooson.participationStatus, + "ACCEPTED", + "organizer attendee should have automatically accepted" + ); + Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "organizer attendee should be required"); + + // Verify that the attendee we added to the list is represented on the event. + const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com"); + Assert.ok(barBarrington, "an attendee should have the address bar@example.com"); + Assert.equal(barBarrington.commonName, null, "new attendee name should not be set"); + Assert.equal( + barBarrington.participationStatus, + "NEEDS-ACTION", + "new attendee should have default participation status" + ); + Assert.equal(barBarrington.role, "REQ-PARTICIPANT", "new attendee should have default role"); + + CalendarTestUtils.removeCalendar(calendar); +}); + +add_task(async function testAddAttendeeToEventWithoutOrganizerAsAttendee() { + const calendar = CalendarTestUtils.createCalendar(); + calendar.setProperty("organizerId", "mailto:foo@example.com"); + calendar.setProperty("organizerCN", "Foo Fooson"); + + // Create an event which has an organizer and attendees, but no attendee + // matching the organizer. + const event = await calendar.addItem( + new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + SUMMARY:An event + DTSTART:20230218T100000Z + DTEND:20230218T110000Z + ORGANIZER;CN="Foo Fooson":mailto:foo@example.com + ATTENDEE;CN="Bar Barrington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@examp + le.com + ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV + P=TRUE:mailto:baz@example.com + END:VEVENT + `) + ); + + // Remember event details so we can refetch it after editing. + const eventId = event.id; + const eventModified = event.lastModifiedTime; + + // Sanity check. Note that order of attendees is not significant and thus not + // guaranteed. + const organizer = event.organizer; + Assert.ok(organizer, "the organizer should be set"); + Assert.equal(organizer.id, "mailto:foo@example.com", "organizer ID should match"); + Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match"); + + const attendees = event.getAttendees(); + Assert.equal(attendees.length, 2, "there should be two attendees of the event"); + + const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com"); + Assert.ok(!fooFooson, "there should be no attendee matching the organizer"); + + const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com"); + Assert.ok(barBarrington, "an attendee should have the address bar@example.com"); + Assert.equal(barBarrington.commonName, "Bar Barrington", "attendee name should match"); + Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined"); + Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair"); + + const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com"); + Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com"); + Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match"); + Assert.equal( + bazLuhrmann.participationStatus, + "NEEDS-ACTION", + "attendee should not have responded yet" + ); + Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional"); + Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP"); + + // Open our event for editing. + const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1); + const attendeesWindow = await openAttendeesWindow(eventWindow); + + // Verify that we don't display an attendee for the organizer if there is no + // attendee on the event for them. + const attendeeList = attendeesWindow.document.getElementById("attendee-list"); + const attendeeInput = Array.from(attendeeList.children) + .map(child => child.querySelector("input")) + .find(input => { + return input ? input.value.includes("foo@example.com") : false; + }); + Assert.ok(!attendeeInput, "there should be no row in the dialog for the organizer"); + + // Set text in the empty row to create a new attendee. + findAndEditMatchingRow( + attendeesWindow, + "Jim James <jim@example.com>", + "there should an empty input", + value => value === "" + ); + + // Save and close the event. + await closeAttendeesWindow(attendeesWindow); + await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow); + + await TestUtils.waitForCondition(async () => { + const item = await calendar.getItem(eventId); + return item.lastModifiedTime != eventModified; + }); + + const editedEvent = await calendar.getItem(eventId); + + // Verify that the organizer hasn't changed. + const editedOrganizer = editedEvent.organizer; + Assert.ok(editedOrganizer, "the organizer should still be set on the event after editing"); + Assert.equal( + editedOrganizer.id, + "mailto:foo@example.com", + "organizer ID should not have changed" + ); + Assert.equal(editedOrganizer.commonName, "Foo Fooson", "organizer name should not have changed"); + + const editedAttendees = editedEvent.getAttendees(); + Assert.equal( + editedAttendees.length, + 3, + "there should be three attendees of the event after editing" + ); + + // Verify that no attendee matching the organizer was added. + const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com"); + Assert.ok(!editedFooFooson, "there should still be no attendee matching the organizer"); + + // Verify that a new attendee was added. + const jimJames = editedAttendees.find(attendee => attendee.id == "mailto:jim@example.com"); + Assert.ok(jimJames, "an attendee should have the address jim@example.com"); + Assert.equal(jimJames.commonName, "Jim James", "new attendee name should be set"); + Assert.equal( + jimJames.participationStatus, + "NEEDS-ACTION", + "new attendee should have default participation status" + ); + Assert.equal(jimJames.role, "REQ-PARTICIPANT", "new attendee should have default role"); + + // Verify that the original first attendee's properties remain untouched. + const editedBarBarrington = editedAttendees.find( + attendee => attendee.id == "mailto:bar@example.com" + ); + Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com"); + Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match"); + Assert.equal( + editedBarBarrington.participationStatus, + "DECLINED", + "attendee should have declined" + ); + Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair"); + + // Verify that the original second attendee's properties remain untouched. + const editedBazLuhrmann = editedAttendees.find( + attendee => attendee.id == "mailto:baz@example.com" + ); + Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com"); + Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match"); + Assert.equal( + editedBazLuhrmann.participationStatus, + "NEEDS-ACTION", + "attendee should not have responded yet" + ); + Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional"); + Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP"); + + CalendarTestUtils.removeCalendar(calendar); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js new file mode 100644 index 0000000000..a103173790 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js @@ -0,0 +1,68 @@ +/* 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/. */ + +/* globals openAttendeesWindow, closeAttendeesWindow, findAndFocusMatchingRow */ + +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); + +add_setup(async function () { + await CalendarTestUtils.setCalendarView(window, "day"); + CalendarTestUtils.goToDate(window, 2023, 2, 18); +}); + +add_task(async function testBackingOutWithNoAttendees() { + const calendar = CalendarTestUtils.createCalendar(); + calendar.setProperty("organizerId", "mailto:foo@example.com"); + calendar.setProperty("organizerCN", "Foo Fooson"); + + // Create an event which currently has no attendees or organizer. + const event = await calendar.addItem( + new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + SUMMARY:An event + DTSTART:20230218T100000Z + DTEND:20230218T110000Z + END:VEVENT + `) + ); + + // Remember event details so we can refetch it after editing. + const eventId = event.id; + const eventModified = event.lastModifiedTime; + + // Sanity check. + Assert.equal(event.organizer, null, "event should not have an organizer"); + Assert.equal(event.getAttendees().length, 0, "event should not have any attendees"); + + // Open our event for editing. + const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1); + const attendeesWindow = await openAttendeesWindow(eventWindow); + + findAndFocusMatchingRow(attendeesWindow, "there should be a row matching the organizer", value => + value.includes(calendar.getProperty("organizerCN")) + ); + + // We changed our mind. Save and close the event. + await closeAttendeesWindow(attendeesWindow); + await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow); + + // The event is still counted as modified even with no changes. If this + // changes in the future, we'll just need to wait a reasonable time and fetch + // the event again. + await TestUtils.waitForCondition(async () => { + const item = await calendar.getItem(eventId); + return item.lastModifiedTime != eventModified; + }); + + const editedEvent = await calendar.getItem(eventId); + + // Verify that the organizer was set on the event. + const organizer = editedEvent.organizer; + Assert.ok(!organizer, "there should still be no organizer for the event"); + + const attendees = editedEvent.getAttendees(); + Assert.equal(attendees.length, 0, "there should still be no attendees of the event"); + + CalendarTestUtils.removeCalendar(calendar); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js new file mode 100644 index 0000000000..7ad5a3cf68 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js @@ -0,0 +1,147 @@ +/* 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/. */ + +/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */ + +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); + +add_setup(async function () { + await CalendarTestUtils.setCalendarView(window, "day"); + CalendarTestUtils.goToDate(window, 2023, 2, 18); +}); + +add_task(async function testRemoveOrganizerAttendee() { + const calendar = CalendarTestUtils.createCalendar(); + calendar.setProperty("organizerId", "mailto:jim@example.com"); + calendar.setProperty("organizerCN", "Jim James"); + + // Create an event with several attendees, including one matching the current + // organizer. + const event = await calendar.addItem( + new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + SUMMARY:An event + DTSTART:20230218T100000Z + DTEND:20230218T110000Z + ORGANIZER;CN="Foo Fooson":mailto:foo@example.com + ATTENDEE;CN="Foo Fooson";PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT:mailto:f + oo@example.com + ATTENDEE;CN="Bar Barrington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@exam + ple.com + ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV + P=TRUE:mailto:baz@example.com + END:VEVENT + `) + ); + + // Remember event details so we can refetch it after editing. + const eventId = event.id; + const eventModified = event.lastModifiedTime; + + // Sanity check. Note that order of attendees is not significant and thus not + // guaranteed. + const organizer = event.organizer; + Assert.ok(organizer, "the organizer should be set"); + Assert.equal(organizer.id, "mailto:foo@example.com", "organizer ID should match"); + Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match"); + + const attendees = event.getAttendees(); + Assert.equal(attendees.length, 3, "there should be three attendees of the event"); + + const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com"); + Assert.ok(fooFooson, "an attendee should match the organizer"); + Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match"); + Assert.equal(fooFooson.participationStatus, "TENTATIVE", "attendee should be marked tentative"); + Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "attendee should be required"); + + const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com"); + Assert.ok(barBarrington, "an attendee should have the address bar@example.com"); + Assert.equal(barBarrington.commonName, "Bar Barrington", "attendee name should match"); + Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined"); + Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair"); + + const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com"); + Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com"); + Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match"); + Assert.equal( + bazLuhrmann.participationStatus, + "NEEDS-ACTION", + "attendee should not have responded yet" + ); + Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional"); + Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP"); + + // Open our event for editing. + const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1); + const attendeesWindow = await openAttendeesWindow(eventWindow); + + // Empty the row matching the organizer's attendee. + findAndEditMatchingRow( + attendeesWindow, + "", + "there should an input for attendee matching the organizer", + value => value.includes("foo@example.com") + ); + + // Save and close the event. + await closeAttendeesWindow(attendeesWindow); + await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow); + + await TestUtils.waitForCondition(async () => { + const item = await calendar.getItem(eventId); + return item.lastModifiedTime != eventModified; + }); + + const editedEvent = await calendar.getItem(eventId); + + // Verify that the organizer hasn't changed. + const editedOrganizer = editedEvent.organizer; + Assert.ok(editedOrganizer, "the organizer should still be set on the event after editing"); + Assert.equal( + editedOrganizer.id, + "mailto:foo@example.com", + "organizer ID should not have changed" + ); + Assert.equal(editedOrganizer.commonName, "Foo Fooson", "organizer name should not have changed"); + + const editedAttendees = editedEvent.getAttendees(); + Assert.equal( + editedAttendees.length, + 2, + "there should be two attendees of the event after editing" + ); + + // Verify that the attendee matching the organizer was removed. + const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com"); + Assert.ok(!editedFooFooson, "there should be no attendee matching the organizer after editing"); + + // Verify that the second attendee's properties remain untouched. + const editedBarBarrington = editedAttendees.find( + attendee => attendee.id == "mailto:bar@example.com" + ); + Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com"); + Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match"); + Assert.equal( + editedBarBarrington.participationStatus, + "DECLINED", + "attendee should have declined" + ); + Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair"); + + // Verify that the final attendee's properties remain untouched. + const editedBazLuhrmann = editedAttendees.find( + attendee => attendee.id == "mailto:baz@example.com" + ); + Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com"); + Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match"); + Assert.equal( + editedBazLuhrmann.participationStatus, + "NEEDS-ACTION", + "attendee should not have responded yet" + ); + Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional"); + Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP"); + + CalendarTestUtils.removeCalendar(calendar); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js new file mode 100644 index 0000000000..b4e30344d0 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js @@ -0,0 +1,140 @@ +/* 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/. */ + +/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */ + +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); + +add_setup(async function () { + await CalendarTestUtils.setCalendarView(window, "day"); + CalendarTestUtils.goToDate(window, 2023, 2, 18); +}); + +add_task(async function testUpdateAttendee() { + const calendar = CalendarTestUtils.createCalendar(); + calendar.setProperty("organizerId", "mailto:foo@example.com"); + + // Create an event with several attendees, all of which should have some + // non-default properties which aren't covered in the attendees dialog to + // ensure that we aren't throwing properties away when we close the dialog. + const event = await calendar.addItem( + new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + SUMMARY:An event + DTSTART:20230218T100000Z + DTEND:20230218T110000Z + ORGANIZER;CN="Foo Fooson":mailto:foo@example.com + ATTENDEE;CN="Foo Fooson";PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT:mailto:f + oo@example.com + ATTENDEE;CN="Bar Barington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@examp + le.com + ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV + P=TRUE:mailto:baz@example.com + END:VEVENT + `) + ); + + // Remember event details so we can refetch it after editing. + const eventId = event.id; + const eventModified = event.lastModifiedTime; + + // Sanity check. Note that order of attendees is not significant and thus not + // guaranteed. + const attendees = event.getAttendees(); + Assert.equal(attendees.length, 3, "there should be three attendees of the event"); + + const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com"); + Assert.ok(fooFooson, "an attendee should have the address foo@example.com"); + Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match"); + Assert.equal(fooFooson.participationStatus, "TENTATIVE", "attendee should be marked tentative"); + Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "attendee should be required"); + + const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com"); + Assert.ok(barBarrington, "an attendee should have the address bar@example.com"); + Assert.equal(barBarrington.commonName, "Bar Barington", "attendee name should match"); + Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined"); + Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair"); + + const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com"); + Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com"); + Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match"); + Assert.equal( + bazLuhrmann.participationStatus, + "NEEDS-ACTION", + "attendee should not have responded yet" + ); + Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional"); + Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP"); + + // Open our event for editing. + const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1); + const attendeesWindow = await openAttendeesWindow(eventWindow); + + // Edit the second attendee to correct their name. + findAndEditMatchingRow( + attendeesWindow, + "Bar Barrington <bar@example.com>", + "there should an input containing the provided email", + value => value.includes("bar@example.com") + ); + + // Save and close the event. + await closeAttendeesWindow(attendeesWindow); + await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow); + + await TestUtils.waitForCondition(async () => { + const item = await calendar.getItem(eventId); + return item.lastModifiedTime != eventModified; + }); + + const editedEvent = await calendar.getItem(eventId); + const editedAttendees = editedEvent.getAttendees(); + Assert.equal( + editedAttendees.length, + 3, + "there should be three attendees of the event after editing" + ); + + // Verify that the first attendee's properties have not been overwritten or + // lost. + const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com"); + Assert.ok(editedFooFooson, "an attendee should have the address foo@example.com"); + Assert.equal(editedFooFooson.commonName, "Foo Fooson", "attendee name should match"); + Assert.equal( + editedFooFooson.participationStatus, + "TENTATIVE", + "attendee should be marked tentative" + ); + Assert.equal(editedFooFooson.role, "REQ-PARTICIPANT", "attendee should be required"); + + // Verify that the second attendee's name has been changed and all other + // fields remain untouched. + const editedBarBarrington = editedAttendees.find( + attendee => attendee.id == "mailto:bar@example.com" + ); + Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com"); + Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match"); + Assert.equal( + editedBarBarrington.participationStatus, + "DECLINED", + "attendee should have declined" + ); + Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair"); + + // Verify that the final attendee's properties remain untouched. + const editedBazLuhrmann = editedAttendees.find( + attendee => attendee.id == "mailto:baz@example.com" + ); + Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com"); + Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match"); + Assert.equal( + editedBazLuhrmann.participationStatus, + "NEEDS-ACTION", + "attendee should not have responded yet" + ); + Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional"); + Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP"); + + CalendarTestUtils.removeCalendar(calendar); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialog.js b/comm/calendar/test/browser/eventDialog/browser_eventDialog.js new file mode 100644 index 0000000000..44d75d7169 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_eventDialog.js @@ -0,0 +1,399 @@ +/* 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 { TIMEOUT_MODAL_DIALOG, checkMonthAlarmIcon, handleDeleteOccurrencePrompt } = + ChromeUtils.import("resource://testing-common/calendar/CalendarUtils.jsm"); +var { cancelItemDialog, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +const EVENTTITLE = "Event"; +const EVENTLOCATION = "Location"; +const EVENTDESCRIPTION = "Event Description"; +const EVENTATTENDEE = "foo@example.com"; +const EVENTURL = "https://mozilla.org/"; +const EVENT_ORGANIZER_EMAIL = "pillow@example.com"; +var firstDay; + +var { dayView, monthView } = CalendarTestUtils; + +let calendar = CalendarTestUtils.createCalendar(); +// This is done so that calItemBase#isInvitation returns true. +calendar.setProperty("organizerId", `mailto:${EVENT_ORGANIZER_EMAIL}`); +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +add_task(async function testEventDialog() { + let now = new Date(); + + // Since from other tests we may be elsewhere, make sure we start today. + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate( + window, + now.getUTCFullYear(), + now.getUTCMonth() + 1, + now.getUTCDate() + ); + await CalendarTestUtils.calendarViewBackward(window, 1); + + // Open month view. + await CalendarTestUtils.setCalendarView(window, "month"); + firstDay = window.currentView().startDay; + dump(`First day in view is: ${firstDay.year}-${firstDay.month + 1}-${firstDay.day}\n`); + + // Setup start- & endTime. + // Next full hour except last hour of the day. + let hour = now.getUTCHours(); + let startHour = hour == 23 ? hour : (hour + 1) % 24; + + let nextHour = cal.dtz.now(); + nextHour.resetTo(firstDay.year, firstDay.month, firstDay.day, startHour, 0, 0, cal.dtz.UTC); + let startTime = formatTime(nextHour); + nextHour.resetTo( + firstDay.year, + firstDay.month, + firstDay.day, + (startHour + 1) % 24, + 0, + 0, + cal.dtz.UTC + ); + let endTime = formatTime(nextHour); + + // Create new event on first day in view. + EventUtils.synthesizeMouseAtCenter(monthView.getDayBox(window, 1, 1), {}, window); + + let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } = + await CalendarTestUtils.editNewEvent(window); + + // First check all standard-values are set correctly. + let startPicker = iframeDocument.getElementById("event-starttime"); + Assert.equal(startPicker._timepicker._inputField.value, startTime); + + // Check selected calendar. + Assert.equal(iframeDocument.getElementById("item-calendar").value, "Test"); + + // Check standard title. + let defTitle = cal.l10n.getAnyString("calendar", "calendar", "newEvent"); + Assert.equal(iframeDocument.getElementById("item-title").placeholder, defTitle); + + // Prepare category. + let categories = cal.l10n.getAnyString("calendar", "categories", "categories2"); + // Pick 4th value in a comma-separated list. + let category = categories.split(",")[4]; + // Calculate date to repeat until. + let untildate = firstDay.clone(); + untildate.addDuration(cal.createDuration("P20D")); + + // Fill in the rest of the values. + await setData(dialogWindow, iframeWindow, { + title: EVENTTITLE, + location: EVENTLOCATION, + description: EVENTDESCRIPTION, + categories: [category], + repeat: "daily", + repeatuntil: untildate, + reminder: "5minutes", + privacy: "private", + attachment: { add: EVENTURL }, + attendees: { add: EVENTATTENDEE }, + }); + + // Verify attendee added. + EventUtils.synthesizeMouseAtCenter( + iframeDocument.getElementById("event-grid-tab-attendees"), + {}, + dialogWindow + ); + + let attendeesTab = iframeDocument.getElementById("event-grid-tabpanel-attendees"); + let attendeeNameElements = attendeesTab.querySelectorAll(".attendee-list .attendee-name"); + Assert.equal(attendeeNameElements.length, 2, "there should be two attendees after save"); + Assert.equal(attendeeNameElements[0].textContent, EVENT_ORGANIZER_EMAIL); + Assert.equal(attendeeNameElements[1].textContent, EVENTATTENDEE); + Assert.ok(!iframeDocument.getElementById("notify-attendees-checkbox").checked); + + // Verify private label visible. + await TestUtils.waitForCondition( + () => !dialogDocument.getElementById("status-privacy-private-box").hasAttribute("collapsed") + ); + dialogDocument.getElementById("event-privacy-menupopup").hidePopup(); + + // Add attachment and verify added. + EventUtils.synthesizeMouseAtCenter( + iframeDocument.getElementById("event-grid-tab-attachments"), + {}, + iframeWindow + ); + + let attachmentsTab = iframeDocument.getElementById("event-grid-tabpanel-attachments"); + Assert.equal(attachmentsTab.querySelectorAll("richlistitem").length, 1); + + let alarmPromise = BrowserTestUtils.promiseAlertDialog( + undefined, + "chrome://calendar/content/calendar-alarm-dialog.xhtml", + { + callback(alarmWindow) { + let dismissAllButton = alarmWindow.document.getElementById("alarm-dismiss-all-button"); + EventUtils.synthesizeMouseAtCenter(dismissAllButton, {}, alarmWindow); + }, + } + ); + + // save + await saveAndCloseItemDialog(dialogWindow); + + // Catch and dismiss alarm. + await alarmPromise; + + // Verify event and alarm icon visible until endDate (3 full rows) and check tooltip. + for (let row = 1; row <= 3; row++) { + for (let col = 1; col <= 7; col++) { + await monthView.waitForItemAt(window, row, col, 1); + checkMonthAlarmIcon(window, row, col); + checkTooltip(row, col, startTime, endTime); + } + } + Assert.ok(!monthView.getItemAt(window, 4, 1, 1)); + + // Delete and verify deleted 6th col in row 1. + EventUtils.synthesizeMouseAtCenter(monthView.getItemAt(window, 1, 6, 1), {}, window); + let elemToDelete = document.getElementById("month-view"); + await handleDeleteOccurrencePrompt(window, elemToDelete, false); + + await monthView.waitForNoItemAt(window, 1, 6, 1); + + // Verify all others still exist. + for (let col = 1; col <= 5; col++) { + Assert.ok(monthView.getItemAt(window, 1, col, 1)); + } + Assert.ok(monthView.getItemAt(window, 1, 7, 1)); + + for (let row = 2; row <= 3; row++) { + for (let col = 1; col <= 7; col++) { + Assert.ok(monthView.getItemAt(window, row, col, 1)); + } + } + + // Delete series by deleting last item in row 1 and confirming to delete all. + EventUtils.synthesizeMouseAtCenter(monthView.getItemAt(window, 1, 7, 1), {}, window); + elemToDelete = document.getElementById("month-view"); + await handleDeleteOccurrencePrompt(window, elemToDelete, true); + + // Verify all deleted. + await monthView.waitForNoItemAt(window, 1, 5, 1); + await monthView.waitForNoItemAt(window, 1, 6, 1); + await monthView.waitForNoItemAt(window, 1, 7, 1); + + for (let row = 2; row <= 3; row++) { + for (let col = 1; col <= 7; col++) { + await monthView.waitForNoItemAt(window, row, col, 1); + } + } +}); + +add_task(async function testOpenExistingEventDialog() { + let now = new Date(); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate( + window, + now.getUTCFullYear(), + now.getUTCMonth() + 1, + now.getUTCDate() + ); + + let createBox = dayView.getHourBoxAt(window, 8); + + // Create a new event. + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox); + await setData(dialogWindow, iframeWindow, { + title: EVENTTITLE, + location: EVENTLOCATION, + description: EVENTDESCRIPTION, + }); + await saveAndCloseItemDialog(dialogWindow); + + let eventBox = await dayView.waitForEventBoxAt(window, 1); + + // Open the event in the summary dialog, it will fail if otherwise. + let eventWin = await CalendarTestUtils.viewItem(window, eventBox); + Assert.equal( + eventWin.document.querySelector("calendar-item-summary .item-title").textContent, + EVENTTITLE + ); + Assert.equal( + eventWin.document.querySelector("calendar-item-summary .item-location").textContent, + EVENTLOCATION + ); + Assert.equal( + eventWin.document.querySelector("calendar-item-summary .item-description").contentDocument.body + .innerText, + EVENTDESCRIPTION + ); + EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWin); + + eventBox.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await dayView.waitForNoEventBoxAt(window, 1); +}); + +add_task(async function testEventReminderDisplay() { + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2020, 1, 1); + + let createBox = dayView.getHourBoxAt(window, 8); + + // Create an event without a reminder. + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox); + await setData(dialogWindow, iframeWindow, { + title: EVENTTITLE, + location: EVENTLOCATION, + description: EVENTDESCRIPTION, + }); + await saveAndCloseItemDialog(dialogWindow); + + let eventBox = await dayView.waitForEventBoxAt(window, 1); + + let eventWindow = await CalendarTestUtils.viewItem(window, eventBox); + let doc = eventWindow.document; + let row = doc.querySelector(".reminder-row"); + Assert.ok(row.hidden, "reminder dropdown is not displayed"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow); + + await CalendarTestUtils.goToDate(window, 2020, 2, 1); + createBox = dayView.getHourBoxAt(window, 8); + + // Create an event with a reminder. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox)); + await setData(dialogWindow, iframeWindow, { + title: EVENTTITLE, + location: EVENTLOCATION, + description: EVENTDESCRIPTION, + reminder: "1week", + }); + await saveAndCloseItemDialog(dialogWindow); + + eventBox = await dayView.waitForEventBoxAt(window, 1); + eventWindow = await CalendarTestUtils.viewItem(window, eventBox); + doc = eventWindow.document; + row = doc.querySelector(".reminder-row"); + + Assert.ok( + row.textContent.includes("7 days before"), + "the details are shown when a reminder is set" + ); + EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow); + + // Create an invitation. + let icalString = + "BEGIN:VCALENDAR\r\n" + + "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\r\n" + + "VERSION:2.0\r\n" + + "BEGIN:VEVENT\r\n" + + "CREATED:20200301T152601Z\r\n" + + "DTSTAMP:20200301T192729Z\r\n" + + "UID:x137e\r\n" + + "SUMMARY:Nap Time\r\n" + + "ORGANIZER;CN=Papa Bois:mailto:papabois@example.com\r\n" + + "ATTENDEE;RSVP=TRUE;CN=pillow@example.com;PARTSTAT=NEEDS-ACTION;CUTY\r\n" + + " PE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:pillow@example.com\r\n" + + "DTSTART:20200301T153000Z\r\n" + + "DTEND:20200301T163000Z\r\n" + + "DESCRIPTION:Slumber In Lumber\r\n" + + "SEQUENCE:0\r\n" + + "TRANSP:OPAQUE\r\n" + + "BEGIN:VALARM\r\n" + + "TRIGGER:-PT30M\r\n" + + "REPEAT:2\r\n" + + "DURATION:PT15M\r\n" + + "ACTION:DISPLAY\r\n" + + "END:VALARM\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n"; + + let calendarEvent = await calendar.addItem(new CalEvent(icalString)); + await CalendarTestUtils.goToDate(window, 2020, 3, 1); + eventBox = await dayView.waitForEventBoxAt(window, 1); + + eventWindow = await CalendarTestUtils.viewItem(window, eventBox); + doc = eventWindow.document; + row = doc.querySelector(".reminder-row"); + + Assert.ok(!row.hidden, "reminder row is displayed"); + Assert.ok(row.querySelector("menulist") != null, "reminder dropdown is available"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow); + + // Delete directly, as using the UI causes a prompt to appear. + calendar.deleteItem(calendarEvent); + await dayView.waitForNoEventBoxAt(window, 1); +}); + +/** + * Test that using CTRL+Enter does not result in two events being created. + * This only happens in the dialog window. See bug 1668478. + */ +add_task(async function testCtrlEnterShortcut() { + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2020, 9, 1); + + let createBox = dayView.getHourBoxAt(window, 8); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox); + await setData(dialogWindow, iframeWindow, { + title: EVENTTITLE, + location: EVENTLOCATION, + description: EVENTDESCRIPTION, + }); + EventUtils.synthesizeKey("VK_RETURN", { ctrlKey: true }, dialogWindow); + + await CalendarTestUtils.setCalendarView(window, "month"); + + // Give the event boxes enough time to appear before checking for duplicates. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 2000)); + + let events = document.querySelectorAll("calendar-month-day-box-item"); + Assert.equal(events.length, 1, "event was created once"); + + if (Services.focus.activeWindow != window) { + await BrowserTestUtils.waitForEvent(window, "focus"); + } + + events[0].focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, window); +}); + +function checkTooltip(row, col, startTime, endTime) { + let item = monthView.getItemAt(window, row, col, 1); + + let toolTipNode = document.getElementById("itemTooltip"); + toolTipNode.ownerGlobal.onMouseOverItem({ currentTarget: item }); + + function getDescription(index) { + return toolTipNode.querySelector( + `.tooltipHeaderTable > tr:nth-of-type(${index}) > .tooltipHeaderDescription` + ).textContent; + } + + // Check title. + Assert.equal(getDescription(1), EVENTTITLE); + + // Check date and time. + let dateTime = getDescription(3); + + let currDate = firstDay.clone(); + currDate.addDuration(cal.createDuration(`P${7 * (row - 1) + (col - 1)}D`)); + let startDate = cal.dtz.formatter.formatDate(currDate); + + Assert.ok(dateTime.includes(`${startDate} ${startTime} β `)); + + // This could be on the next day if it is 00:00. + Assert.ok(dateTime.endsWith(endTime)); +} diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js new file mode 100644 index 0000000000..d838330e73 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js @@ -0,0 +1,154 @@ +/* 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 { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +add_setup(async function () { + await CalendarTestUtils.setCalendarView(window, "day"); + CalendarTestUtils.goToDate(window, 2023, 2, 18); +}); + +add_task(async function testPastePreformattedWithLinebreak() { + const calendar = CalendarTestUtils.createCalendar(); + + // Create an event which currently has no description. + const event = await calendar.addItem( + new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + SUMMARY:An event + DTSTART:20230218T100000Z + DTEND:20230218T110000Z + END:VEVENT + `) + ); + + // Remember event details so we can refetch it after editing. + const eventId = event.id; + const eventModified = event.lastModifiedTime; + + // Sanity check. + Assert.equal(event.descriptionHTML, null, "event should not have an HTML description"); + Assert.equal(event.descriptionText, null, "event should not have a text description"); + + // Open our event for editing. + const { dialogWindow: eventWindow, iframeDocument } = await CalendarTestUtils.dayView.editEventAt( + window, + 1 + ); + + const editor = iframeDocument.getElementById("item-description"); + editor.focus(); + + const expectedHTML = + "<pre><code>This event is one which includes\nan explicit linebreak inside a pre tag.</code></pre>"; + + // Create a paste which includes HTML data, which the editor will recognize as + // HTML and paste with formatting by default. + const stringData = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); + stringData.data = expectedHTML; + + const transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable); + transferable.init(null); + transferable.addDataFlavor("text/html"); + transferable.setTransferData("text/html", stringData); + Services.clipboard.setData(transferable, null, Ci.nsIClipboard.kGlobalClipboard); + + // Paste. + EventUtils.synthesizeKey("v", { accelKey: true }, eventWindow); + + await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow); + + await TestUtils.waitForCondition(async () => { + const item = await calendar.getItem(eventId); + return item.lastModifiedTime != eventModified; + }); + + const editedEvent = await calendar.getItem(eventId); + + // Verify that the description has been set appropriately. There should be no + // change to the HTML, which is preformatted, and the text description should + // include a linebreak in the same place as the HTML. + Assert.equal(editedEvent.descriptionHTML, expectedHTML, "HTML description should match input"); + Assert.equal( + editedEvent.descriptionText, + "This event is one which includes\nan explicit linebreak inside a pre tag.", + "text description should include linebreak" + ); + + CalendarTestUtils.removeCalendar(calendar); +}); + +add_task(async function testTypeLongTextWithLinebreaks() { + const calendar = CalendarTestUtils.createCalendar(); + + // Create an event which currently has no description. + const event = await calendar.addItem( + new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + SUMMARY:An event + DTSTART:20230218T100000Z + DTEND:20230218T110000Z + END:VEVENT + `) + ); + + // Remember event details so we can refetch it after editing. + const eventId = event.id; + const eventModified = event.lastModifiedTime; + + // Sanity check. + Assert.equal(event.descriptionHTML, null, "event should not have an HTML description"); + Assert.equal(event.descriptionText, null, "event should not have a text description"); + + // Open our event for editing. + const { + dialogWindow: eventWindow, + iframeDocument, + iframeWindow, + } = await CalendarTestUtils.dayView.editEventAt(window, 1); + + const editor = iframeDocument.getElementById("item-description"); + editor.focus(); + + // Insert text with several long lines and explicit linebreaks. + const firstLine = + "This event is pretty much just plain text, albeit it has some pretty long lines so that we can ensure that we don't accidentally wrap it during conversion."; + EventUtils.sendString(firstLine, iframeWindow); + EventUtils.sendKey("RETURN", iframeWindow); + + const secondLine = "This line follows immediately after a linebreak."; + EventUtils.sendString(secondLine, iframeWindow); + EventUtils.sendKey("RETURN", iframeWindow); + EventUtils.sendKey("RETURN", iframeWindow); + + const thirdLine = + "And one after a couple more linebreaks, for good measure. It might as well be a fairly long string as well, just so we're certain."; + EventUtils.sendString(thirdLine, iframeWindow); + + await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow); + + await TestUtils.waitForCondition(async () => { + const item = await calendar.getItem(eventId); + return item.lastModifiedTime != eventModified; + }); + + const editedEvent = await calendar.getItem(eventId); + + // Verify that the description has been set appropriately. The HTML should + // match the input and use <br> as a linebreak, while the text should not be + // wrapped and should use \n as a linebreak. + Assert.equal( + editedEvent.descriptionHTML, + `${firstLine}<br>${secondLine}<br><br>${thirdLine}`, + "HTML description should match input with <br> for linebreaks" + ); + Assert.equal( + editedEvent.descriptionText, + `${firstLine}\n${secondLine}\n\n${thirdLine}`, + "text description should match input with linebreaks" + ); + + CalendarTestUtils.removeCalendar(calendar); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js new file mode 100644 index 0000000000..b7730444b2 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js @@ -0,0 +1,223 @@ +/* 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/. */ + +/** + * Tests for the edit button displayed in the calendar summary dialog. + */ + +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +const calendar = CalendarTestUtils.createCalendar("Edit Button Test", "storage"); + +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +function createNonRecurringEvent() { + let event = new CalEvent(); + event.title = "Non-Recurring Event"; + event.startDate = cal.createDateTime("20191201T000001Z"); + return event; +} + +function createRecurringEvent() { + let event = new CalEvent(); + event.title = "Recurring Event"; + event.startDate = cal.createDateTime("20200101T000001Z"); + event.recurrenceInfo = new CalRecurrenceInfo(event); + event.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=30")); + return event; +} + +/** + * Test the correct edit button is shown for a non-recurring event. + */ +add_task(async function testNonRecurringEvent() { + let event = await calendar.addItem(createNonRecurringEvent()); + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(event.startDate); + + let eventWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 1, 1); + let editMenuButton = eventWindow.document.querySelector( + "#calendar-summary-dialog-edit-menu-button" + ); + + Assert.ok( + !BrowserTestUtils.is_visible(editMenuButton), + "edit dropdown is not visible for non-recurring event" + ); + + let editButton = eventWindow.document.querySelector("#calendar-summary-dialog-edit-button"); + + Assert.ok( + BrowserTestUtils.is_visible(editButton), + "edit button is visible for non-recurring event" + ); + await CalendarTestUtils.items.cancelItemDialog(eventWindow); + await calendar.deleteItem(event); +}); + +/** + * Test the edit button for a non-recurring event actual edits the event. + */ +add_task(async function testEditNonRecurringEvent() { + let event = await calendar.addItem(createNonRecurringEvent()); + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(event.startDate); + + let modificationPromise = new Promise(resolve => { + calendar.wrappedJSObject.addObserver({ + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + onModifyItem(aNewItem, aOldItem) { + calendar.wrappedJSObject.removeObserver(this); + resolve(); + }, + }); + }); + + let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemAt( + window, + 1, + 1, + 1 + ); + + let newTitle = "Edited Non-Recurring Event"; + iframeDocument.querySelector("#item-title").value = newTitle; + + await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow); + await modificationPromise; + + let viewWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 1, 1); + let actualTitle = viewWindow.document.querySelector( + "#calendar-item-summary .item-title" + ).textContent; + + Assert.equal(actualTitle, newTitle, "edit non-recurring event successful"); + await CalendarTestUtils.items.cancelItemDialog(viewWindow); + await calendar.deleteItem(event); +}); + +/** + * Tests the dropdown menu is displayed for a recurring event. + */ +add_task(async function testRecurringEvent() { + let event = await calendar.addItem(createRecurringEvent()); + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(event.startDate); + + let viewWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 6, 1); + + Assert.ok( + !BrowserTestUtils.is_visible( + viewWindow.document.querySelector("#calendar-summary-dialog-edit-button") + ), + "non-recurring edit button is not visible for recurring event" + ); + Assert.ok( + BrowserTestUtils.is_visible( + viewWindow.document.querySelector("#calendar-summary-dialog-edit-menu-button") + ), + "edit dropdown is visible for recurring event" + ); + + await CalendarTestUtils.items.cancelItemDialog(viewWindow); + await calendar.deleteItem(event); +}); + +/** + * Tests the dropdown menu allows a single occurrence of a repeating event + * to be edited. + */ +add_task(async function testEditThisOccurrence() { + let event = createRecurringEvent(); + event = await calendar.addItem(event); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(event.startDate); + + let modificationPromise = new Promise(resolve => { + calendar.wrappedJSObject.addObserver({ + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + onModifyItem(aNewItem, aOldItem) { + calendar.wrappedJSObject.removeObserver(this); + resolve(); + }, + }); + }); + + let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemOccurrenceAt( + window, + 1, + 6, + 1 + ); + + let originalTitle = event.title; + let newTitle = "Edited This Occurrence"; + + iframeDocument.querySelector("#item-title").value = newTitle; + await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow); + + await modificationPromise; + + let changedBox = await CalendarTestUtils.monthView.waitForItemAt(window, 1, 6, 1); + let eventBoxes = document.querySelectorAll("calendar-month-day-box-item"); + + for (let box of eventBoxes) { + if (box !== changedBox) { + Assert.equal( + box.item.title, + originalTitle, + '"Edit this occurrence" did not edit other occurrences' + ); + } else { + Assert.equal(box.item.title, newTitle, '"Edit this occurrence only" edited this occurrence.'); + } + } + await calendar.deleteItem(event); +}); + +/** + * Tests the dropdown menu allows all occurrences of a recurring event to be + * edited. + */ +add_task(async function testEditAllOccurrences() { + let event = await calendar.addItem(createRecurringEvent()); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(event.startDate); + + // Setup an observer so we can wait for the event boxes to be updated. + let boxesRefreshed = false; + let observer = new MutationObserver(() => (boxesRefreshed = true)); + observer.observe(document.querySelector("#month-view"), { + childList: true, + subtree: true, + }); + + let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemOccurrencesAt( + window, + 1, + 6, + 1 + ); + + let newTitle = "Edited All Occurrences"; + + iframeDocument.querySelector("#item-title").value = newTitle; + await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow); + await TestUtils.waitForCondition(() => boxesRefreshed, "event boxes did not refresh in time"); + + let eventBoxes = document.querySelectorAll("calendar-month-day-box-item"); + for (let box of eventBoxes) { + Assert.equal(box.item.title, newTitle, '"Edit all occurrences" edited each occurrence'); + } + await calendar.deleteItem(event); +}); diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js new file mode 100644 index 0000000000..b0f3282b24 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js @@ -0,0 +1,160 @@ +/* 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/. */ + +requestLongerTimeout(2); + +var { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { data, newlines } = setupData(); + +var { dayView } = CalendarTestUtils; + +let calendar = CalendarTestUtils.createCalendar(); +// This is done so that calItemBase#isInvitation returns true. +calendar.setProperty("organizerId", "mailto:pillow@example.com"); +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +// Test that closing an event dialog with no changes does not prompt for save. +add_task(async function testEventDialogModificationPrompt() { + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + let createbox = dayView.getHourBoxAt(window, 8); + + // Create new event. + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createbox); + let categories = cal.l10n.getAnyString("calendar", "categories", "categories2").split(","); + data[0].categories.push(categories[0]); + data[1].categories.push(categories[1], categories[2]); + + // Enter first set of data. + await setData(dialogWindow, iframeWindow, data[0]); + await saveAndCloseItemDialog(dialogWindow); + + let eventbox = await dayView.waitForEventBoxAt(window, 1); + + // Open, but change nothing. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox)); + // Escape the event window, there should be no prompt to save event. + cancelItemDialog(dialogWindow); + // Wait to see if the prompt appears. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 2000)); + + eventbox = await dayView.waitForEventBoxAt(window, 1); + // Open, change all values then revert the changes. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox)); + // Change all values. + await setData(dialogWindow, iframeWindow, data[1]); + + // Edit all values back to original. + await setData(dialogWindow, iframeWindow, data[0]); + + // Escape the event window, there should be no prompt to save event. + cancelItemDialog(dialogWindow); + // Wait to see if the prompt appears. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Delete event. + document.getElementById("day-view").focus(); + if (window.currentView().getSelectedItems().length == 0) { + EventUtils.synthesizeMouseAtCenter(eventbox, {}, window); + } + Assert.equal(eventbox.isEditing, false, "event is not being edited"); + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await dayView.waitForNoEventBoxAt(window, 1); +}); + +add_task(async function testDescriptionWhitespace() { + for (let i = 0; i < newlines.length; i++) { + // test set i + let createbox = dayView.getHourBoxAt(window, 8); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createbox); + await setData(dialogWindow, iframeWindow, newlines[i]); + await saveAndCloseItemDialog(dialogWindow); + + let eventbox = await dayView.waitForEventBoxAt(window, 1); + + // Open and close. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox)); + await setData(dialogWindow, iframeWindow, newlines[i]); + cancelItemDialog(dialogWindow); + // Wait to see if the prompt appears. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Delete it. + document.getElementById("day-view").focus(); + if (window.currentView().getSelectedItems().length == 0) { + EventUtils.synthesizeMouseAtCenter(eventbox, {}, window); + } + Assert.equal(eventbox.isEditing, false, "event is not being edited"); + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await dayView.waitForNoEventBoxAt(window, 1); + } +}); + +function setupData() { + let date1 = cal.createDateTime("20090101T080000Z"); + let date2 = cal.createDateTime("20090102T090000Z"); + let date3 = cal.createDateTime("20090103T100000Z"); + return { + data: [ + { + title: "title1", + location: "location1", + description: "description1", + categories: [], + allday: false, + startdate: date1, + starttime: date1, + enddate: date2, + endtime: date2, + repeat: "none", + reminder: "none", + priority: "normal", + privacy: "public", + status: "confirmed", + freebusy: "busy", + timezonedisplay: true, + attachment: { add: "https://mozilla.org" }, + attendees: { add: "foo@bar.de,foo@bar.com" }, + }, + { + title: "title2", + location: "location2", + description: "description2", + categories: [], + allday: true, + startdate: date2, + starttime: date2, + enddate: date3, + endtime: date3, + repeat: "daily", + reminder: "5minutes", + priority: "high", + privacy: "private", + status: "tentative", + freebusy: "free", + timezonedisplay: false, + attachment: { remove: "mozilla.org" }, + attendees: { remove: "foo@bar.de,foo@bar.com" }, + }, + ], + newlines: [ + { title: "title", description: " test spaces " }, + { title: "title", description: "\ntest newline\n" }, + { title: "title", description: "\rtest \\r\r" }, + { title: "title", description: "\r\ntest \\r\\n\r\n" }, + { title: "title", description: "\ttest \\t\t" }, + ], + }; +} diff --git a/comm/calendar/test/browser/eventDialog/browser_utf8.js b/comm/calendar/test/browser/eventDialog/browser_utf8.js new file mode 100644 index 0000000000..5e9ff82d19 --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/browser_utf8.js @@ -0,0 +1,56 @@ +/* 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 { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var UTF8STRING = " π£ π₯ β£ "; + +add_task(async function testUTF8() { + let calendar = CalendarTestUtils.createCalendar(); + Services.prefs.setStringPref("calendar.categories.names", UTF8STRING); + + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + Services.prefs.clearUserPref("calendar.categories.names"); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + + // Create new event. + let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, 8); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + // Fill in name, location, description. + await setData(dialogWindow, iframeWindow, { + title: UTF8STRING, + location: UTF8STRING, + description: UTF8STRING, + categories: [UTF8STRING], + }); + await saveAndCloseItemDialog(dialogWindow); + + // open + let { dialogWindow: dlgWindow, iframeDocument } = await CalendarTestUtils.dayView.editEventAt( + window, + 1 + ); + // Check values. + Assert.equal(iframeDocument.getElementById("item-title").value, UTF8STRING); + Assert.equal(iframeDocument.getElementById("item-location").value, UTF8STRING); + // The trailing spaces confuse innerText, so we'll do this longhand + let editorEl = iframeDocument.getElementById("item-description"); + let editor = editorEl.getEditor(editorEl.contentWindow); + let description = editor.outputToString("text/plain", 0); + // The HTML editor makes the first character a NBSP instead of a space. + Assert.equal(description.replaceAll("\xA0", " "), UTF8STRING); + Assert.ok( + iframeDocument + .getElementById("item-categories") + .querySelector(`menuitem[label="${UTF8STRING}"][checked]`) + ); + + // Escape the event window. + cancelItemDialog(dlgWindow); +}); diff --git a/comm/calendar/test/browser/eventDialog/data/guests.txt b/comm/calendar/test/browser/eventDialog/data/guests.txt new file mode 100644 index 0000000000..e2959cf71e --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/data/guests.txt @@ -0,0 +1,2 @@ +Nobody +No one diff --git a/comm/calendar/test/browser/eventDialog/head.js b/comm/calendar/test/browser/eventDialog/head.js new file mode 100644 index 0000000000..0646cd709c --- /dev/null +++ b/comm/calendar/test/browser/eventDialog/head.js @@ -0,0 +1,97 @@ +/* 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 { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +// If the "do you want to save the event?" prompt appears, the test failed. +// Listen for all windows opening, and if one is the save prompt, fail. +var savePromptObserver = { + async observe(win, topic) { + if (topic == "domwindowopened") { + await BrowserTestUtils.waitForEvent(win, "load"); + // Make sure this is a prompt window. + if (win.location.href == "chrome://global/content/commonDialog.xhtml") { + let doc = win.document; + // Adding attachments also shows a prompt, but we can tell which one + // this is by checking whether the textbox is visible. + if (doc.querySelector("#loginContainer").hasAttribute("hidden")) { + Assert.report(true, undefined, undefined, "Unexpected save prompt appeared"); + doc.querySelector("dialog").getButton("cancel").click(); + } + } + } + }, +}; +Services.ww.registerNotification(savePromptObserver); + +const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window); + +registerCleanupFunction(async () => { + Services.ww.unregisterNotification(savePromptObserver); + await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState); +}); + +function openAttendeesWindow(eventWindowOrArgs) { + let attendeesWindowPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + "chrome://calendar/content/calendar-event-dialog-attendees.xhtml", + { + async callback(win) { + await new Promise(resolve => win.setTimeout(resolve)); + }, + } + ); + + if (Window.isInstance(eventWindowOrArgs)) { + EventUtils.synthesizeMouseAtCenter( + eventWindowOrArgs.document.getElementById("button-attendees"), + {}, + eventWindowOrArgs + ); + } else { + openDialog( + "chrome://calendar/content/calendar-event-dialog-attendees.xhtml", + "_blank", + "chrome,titlebar,resizable", + eventWindowOrArgs + ); + } + return attendeesWindowPromise; +} + +async function closeAttendeesWindow(attendeesWindow, buttonAction = "accept") { + let closedPromise = BrowserTestUtils.domWindowClosed(attendeesWindow); + let dialog = attendeesWindow.document.querySelector("dialog"); + dialog.getButton(buttonAction).click(); + await closedPromise; + + await new Promise(resolve => setTimeout(resolve)); +} + +function findAndFocusMatchingRow(attendeesWindow, message, matchFunction) { + // Get the attendee row for which the input matches. + const attendeeList = attendeesWindow.document.getElementById("attendee-list"); + const attendeeInput = Array.from(attendeeList.children) + .map(child => child.querySelector("input")) + .find(input => { + return input ? matchFunction(input.value) : false; + }); + Assert.ok(attendeeInput, message); + + attendeeInput.focus(); + + return attendeeInput; +} + +function findAndEditMatchingRow(attendeesWindow, newValue, message, matchFunction) { + // Get the attendee row we wish to edit. + const attendeeInput = findAndFocusMatchingRow(attendeesWindow, message, matchFunction); + + // Set the new value of the row. We set the input value directly due to issues + // experienced trying to use simulated keystrokes. + attendeeInput.value = newValue; + EventUtils.synthesizeKey("VK_RETURN", {}, attendeesWindow); +} diff --git a/comm/calendar/test/browser/head.js b/comm/calendar/test/browser/head.js new file mode 100644 index 0000000000..f76cc85754 --- /dev/null +++ b/comm/calendar/test/browser/head.js @@ -0,0 +1,374 @@ +/* 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/. */ + +/* import-globals-from ../../base/content/calendar-views-utils.js */ + +/* globals openOptionsDialog, openAddonsMgr */ + +const { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +async function openTasksTab() { + let tabmail = document.getElementById("tabmail"); + let tasksMode = tabmail.tabModes.tasks; + + if (tasksMode.tabs.length == 1) { + tabmail.selectedTab = tasksMode.tabs[0]; + } else { + let tasksTabButton = document.getElementById("tasksButton"); + EventUtils.synthesizeMouseAtCenter(tasksTabButton, { clickCount: 1 }); + } + + is(tasksMode.tabs.length, 1, "tasks tab is open"); + is(tabmail.selectedTab, tasksMode.tabs[0], "tasks tab is selected"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function closeTasksTab() { + let tabmail = document.getElementById("tabmail"); + let tasksMode = tabmail.tabModes.tasks; + + if (tasksMode.tabs.length == 1) { + tabmail.closeTab(tasksMode.tabs[0]); + } + + is(tasksMode.tabs.length, 0, "tasks tab is not open"); + + await new Promise(resolve => setTimeout(resolve)); +} + +/** + * Currently there's always a folder tab open, hence "select" not "open". + */ +async function selectFolderTab() { + const tabmail = document.getElementById("tabmail"); + const folderMode = tabmail.tabModes.mail3PaneTab; + + tabmail.selectedTab = folderMode.tabs[0]; + + is(folderMode.tabs.length > 0, true, "at least one folder tab is open"); + is(tabmail.selectedTab, folderMode.tabs[0], "a folder tab is selected"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function openChatTab() { + let tabmail = document.getElementById("tabmail"); + let chatMode = tabmail.tabModes.chat; + + if (chatMode.tabs.length == 1) { + tabmail.selectedTab = chatMode.tabs[0]; + } else { + window.showChatTab(); + } + + is(chatMode.tabs.length, 1, "chat tab is open"); + is(tabmail.selectedTab, chatMode.tabs[0], "chat tab is selected"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function closeChatTab() { + let tabmail = document.getElementById("tabmail"); + let chatMode = tabmail.tabModes.chat; + + if (chatMode.tabs.length == 1) { + tabmail.closeTab(chatMode.tabs[0]); + } + + is(chatMode.tabs.length, 0, "chat tab is not open"); + + await new Promise(resolve => setTimeout(resolve)); +} + +/** + * Opens a new calendar event or task tab. + * + * @param {string} tabMode - Mode of the new tab, either `calendarEvent` or `calendarTask`. + * @returns {string} - The id of the new tab's panel element. + */ +async function _openNewCalendarItemTab(tabMode) { + let tabmail = document.getElementById("tabmail"); + let itemTabs = tabmail.tabModes[tabMode].tabs; + let previousTabCount = itemTabs.length; + + Services.prefs.setBoolPref("calendar.item.editInTab", true); + let buttonId = "sidePanelNewEvent"; + if (tabMode == "calendarTask") { + await openTasksTab(); + buttonId = "sidePanelNewTask"; + } else { + await CalendarTestUtils.openCalendarTab(window); + } + + let newItemButton = document.getElementById(buttonId); + EventUtils.synthesizeMouseAtCenter(newItemButton, { clickCount: 1 }); + + let newTab = itemTabs[itemTabs.length - 1]; + + is(itemTabs.length, previousTabCount + 1, `new ${tabMode} tab is open`); + is(tabmail.selectedTab, newTab, `new ${tabMode} tab is selected`); + + await BrowserTestUtils.browserLoaded(newTab.iframe); + await new Promise(resolve => setTimeout(resolve)); + return newTab.panel.id; +} + +let openNewCalendarEventTab = _openNewCalendarItemTab.bind(null, "calendarEvent"); +let openNewCalendarTaskTab = _openNewCalendarItemTab.bind(null, "calendarTask"); + +/** + * Selects an existing (open) calendar event or task tab. + * + * @param {string} tabMode - The tab mode, either `calendarEvent` or `calendarTask`. + * @param {string} panelId - The id of the tab's panel element. + */ +async function _selectCalendarItemTab(tabMode, panelId) { + let tabmail = document.getElementById("tabmail"); + let itemTabs = tabmail.tabModes[tabMode].tabs; + let tabToSelect = itemTabs.find(tab => tab.panel.id == panelId); + + ok(tabToSelect, `${tabMode} tab is open`); + + tabmail.selectedTab = tabToSelect; + + is(tabmail.selectedTab, tabToSelect, `${tabMode} tab is selected`); + + await new Promise(resolve => setTimeout(resolve)); +} + +let selectCalendarEventTab = _selectCalendarItemTab.bind(null, "calendarEvent"); +let selectCalendarTaskTab = _selectCalendarItemTab.bind(null, "calendarTask"); + +/** + * Closes a calendar event or task tab. + * + * @param {string} tabMode - The tab mode, either `calendarEvent` or `calendarTask`. + * @param {string} panelId - The id of the panel of the tab to close. + */ +async function _closeCalendarItemTab(tabMode, panelId) { + let tabmail = document.getElementById("tabmail"); + let itemTabs = tabmail.tabModes[tabMode].tabs; + let previousTabCount = itemTabs.length; + let itemTab = itemTabs.find(tab => tab.panel.id == panelId); + + if (itemTab) { + // Tab does not immediately close, so wait for it. + let tabClosedPromise = new Promise(resolve => { + itemTab.tabNode.addEventListener("TabClose", resolve, { once: true }); + }); + tabmail.closeTab(itemTab); + await tabClosedPromise; + } + + is(itemTabs.length, previousTabCount - 1, `${tabMode} tab was closed`); + + await new Promise(resolve => setTimeout(resolve)); +} + +let closeCalendarEventTab = _closeCalendarItemTab.bind(null, "calendarEvent"); +let closeCalendarTaskTab = _closeCalendarItemTab.bind(null, "calendarTask"); + +async function openPreferencesTab() { + const tabmail = document.getElementById("tabmail"); + const prefsMode = tabmail.tabModes.preferencesTab; + + if (prefsMode.tabs.length == 1) { + tabmail.selectedTab = prefsMode.tabs[0]; + } else { + openOptionsDialog(); + } + + is(prefsMode.tabs.length, 1, "preferences tab is open"); + is(tabmail.selectedTab, prefsMode.tabs[0], "preferences tab is selected"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function closeAddressBookTab() { + let tabmail = document.getElementById("tabmail"); + let abMode = tabmail.tabModes.addressBookTab; + + if (abMode.tabs.length == 1) { + tabmail.closeTab(abMode.tabs[0]); + } + + is(abMode.tabs.length, 0, "address book tab is not open"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function closePreferencesTab() { + let tabmail = document.getElementById("tabmail"); + let prefsMode = tabmail.tabModes.preferencesTab; + + if (prefsMode.tabs.length == 1) { + tabmail.closeTab(prefsMode.tabs[0]); + } + + is(prefsMode.tabs.length, 0, "preferences tab is not open"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function openAddonsTab() { + const tabmail = document.getElementById("tabmail"); + const contentMode = tabmail.tabModes.contentTab; + + if (contentMode.tabs.length == 1) { + tabmail.selectedTab = contentMode.tabs[0]; + } else { + openAddonsMgr("addons://list/extension"); + } + + is(contentMode.tabs.length, 1, "addons tab is open"); + is(tabmail.selectedTab, contentMode.tabs[0], "addons tab is selected"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function closeAddonsTab() { + let tabmail = document.getElementById("tabmail"); + let contentMode = tabmail.tabModes.contentTab; + + if (contentMode.tabs.length == 1) { + tabmail.closeTab(contentMode.tabs[0]); + } + + is(contentMode.tabs.length, 0, "addons tab is not open"); + + await new Promise(resolve => setTimeout(resolve)); +} + +/** + * Create a calendar using the "Create New Calendar" dialog. + * + * @param {string} name - Name for the new calendar. + * @param {object} [data] - Data to enter into the dialog. + * @param {boolean} [data.showReminders] - False to disable reminders. + * @param {string} [data.email] - An email address. + * @param {object} [data.network] - Data for network calendars. + * @param {string} [data.network.location] - A URI (leave undefined for local ICS file). + * @param {boolean} [data.network.offline] - False to disable the cache. + */ +async function createCalendarUsingDialog(name, data = {}) { + /** + * Callback function to interact with the dialog. + * + * @param {nsIDOMWindow} win - The dialog window. + */ + async function useDialog(win) { + let doc = win.document; + let dialogElement = doc.querySelector("dialog"); + let acceptButton = dialogElement.getButton("accept"); + + if (data.network) { + // Choose network calendar type. + doc.querySelector("#calendar-type [value='network']").click(); + acceptButton.click(); + + // Enter a location. + if (data.network.location == undefined) { + let calendarFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + calendarFile.append(name + ".ics"); + let fileURI = Services.io.newFileURI(calendarFile); + data.network.location = fileURI.prePath + fileURI.pathQueryRef; + } + EventUtils.synthesizeMouseAtCenter(doc.querySelector("#network-location-input"), {}, win); + EventUtils.sendString(data.network.location, win); + + // Choose offline support. + if (data.network.offline == undefined) { + data.network.offline = true; + } + let offlineCheckbox = doc.querySelector("#network-cache-checkbox"); + if (!offlineCheckbox.checked) { + EventUtils.synthesizeMouseAtCenter(offlineCheckbox, {}, win); + } + acceptButton.click(); + + // Set up an observer to wait for calendar(s) to be found, before + // clicking the accept button to subscribe to the calendar(s). + let observer = new MutationObserver(mutationList => { + mutationList.forEach(async mutation => { + if (mutation.type === "childList") { + acceptButton.click(); + } + }); + }); + observer.observe(doc.querySelector("#network-calendar-list"), { childList: true }); + } else { + // Choose local calendar type. + doc.querySelector("#calendar-type [value='local']").click(); + acceptButton.click(); + + // Set calendar name. + // Setting the value does not activate the accept button on all platforms, + // so we need to type something in case the field is empty. + let nameInput = doc.querySelector("#local-calendar-name-input"); + if (nameInput.value == "") { + EventUtils.synthesizeMouseAtCenter(nameInput, {}, win); + EventUtils.sendString(name, win); + } + + // Set reminder option. + if (data.showReminders == undefined) { + data.showReminders = true; + } + let localFireAlarmsCheckbox = doc.querySelector("#local-fire-alarms-checkbox"); + if (localFireAlarmsCheckbox.checked != data.showReminders) { + EventUtils.synthesizeMouseAtCenter(localFireAlarmsCheckbox, {}, win); + } + + // Set email account. + if (data.email == undefined) { + data.email = "none"; + } + let emailIdentityMenulist = doc.querySelector("#email-identity-menulist"); + EventUtils.synthesizeMouseAtCenter(emailIdentityMenulist, {}, win); + emailIdentityMenulist.querySelector("menuitem[value='none']").click(); + + // Create the calendar. + acceptButton.click(); + } + } + + let dialogWindowPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-creation.xhtml", + { callback: useDialog } + ); + // Open the "create new calendar" dialog. + CalendarTestUtils.openCalendarTab(window); + // This double-click must be inside the calendar list but below the list items. + EventUtils.synthesizeMouseAtCenter(document.querySelector("#calendar-list"), { clickCount: 2 }); + return dialogWindowPromise; +} + +const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window); + +registerCleanupFunction(async () => { + await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState); + await closeTasksTab(); + await closeChatTab(); + await closeAddressBookTab(); + await closePreferencesTab(); + await closeAddonsTab(); + + // Close any event or task tabs that are open. + let tabmail = document.getElementById("tabmail"); + let eventTabPanelIds = tabmail.tabModes.calendarEvent.tabs.map(tab => tab.panel.id); + let taskTabPanelIds = tabmail.tabModes.calendarTask.tabs.map(tab => tab.panel.id); + for (let id of eventTabPanelIds) { + await closeCalendarEventTab(id); + } + for (let id of taskTabPanelIds) { + await closeCalendarTaskTab(id); + } + Services.prefs.setBoolPref("calendar.item.editInTab", false); + + Assert.equal(tabmail.tabInfo.length, 1, "all tabs closed"); +}); diff --git a/comm/calendar/test/browser/invitations/browser.ini b/comm/calendar/test/browser/invitations/browser.ini new file mode 100644 index 0000000000..7c7aa6af46 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser.ini @@ -0,0 +1,31 @@ +[default] +head = ../head.js head.js +prefs = + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +support-files = data/** + +[browser_attachedPublishEvent.js] +[browser_icsAttachment.js] +skip-if = os == 'win' +[browser_identityPrompt.js] +[browser_imipBar.js] +[browser_imipBarCancel.js] +[browser_imipBarEmail.js] +[browser_imipBarExceptionCancel.js] +[browser_imipBarExceptionOnly.js] +[browser_imipBarExceptions.js] +[browser_imipBarRepeat.js] +[browser_imipBarRepeatCancel.js] +[browser_imipBarRepeatUpdates.js] +[browser_imipBarUpdates.js] +[browser_invitationDisplayNew.js] +[browser_unsupportedFreq.js] diff --git a/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js b/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js new file mode 100644 index 0000000000..af121a8032 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js @@ -0,0 +1,72 @@ +/* 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/. */ + +/** + * Test that attached events - NOT invites - works properly. + * These are attached VCALENDARs that have METHOD:PUBLISH. + */ +"use strict"; + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var gCalendar; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + let receiverAcct = MailServices.accounts.createAccount(); + receiverAcct.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + let receiverIdentity = MailServices.accounts.createIdentity(); + receiverIdentity.email = "john.doe@example.com"; + receiverAcct.addIdentity(receiverIdentity); + gCalendar = CalendarTestUtils.createCalendar("EventTestCal"); + + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(gCalendar); + MailServices.accounts.removeAccount(receiverAcct, true); + }); +}); + +/** + * Test that opening a message containing an event with iTIP method "PUBLISH" + * shows the correct UI. + * The party crashing dialog should not show. + */ +add_task(async function test_event_from_eml() { + let file = new FileUtils.File(getTestFilePath("data/message-non-invite.eml")); + + let win = await openMessageFromFile(file); + let aboutMessage = win.document.getElementById("messageBrowser").contentWindow; + let imipBar = aboutMessage.document.getElementById("imip-bar"); + + await TestUtils.waitForCondition(() => !imipBar.collapsed); + info("Ok, iMIP bar is showing"); + + let imipAddButton = aboutMessage.document.getElementById("imipAddButton"); + Assert.ok(!imipAddButton.hidden, "Add button should show"); + + EventUtils.synthesizeMouseAtCenter(imipAddButton, {}, aboutMessage); + + // Make sure the event got added, without showing the party crashing dialog. + await TestUtils.waitForCondition(async () => { + let event = await gCalendar.getItem("1e5fd4e6-bc52-439c-ac76-40da54f57c77@secure.example.com"); + return event; + }); + + await TestUtils.waitForCondition(() => imipAddButton.hidden, "Add button should hide"); + + let imipDetailsButton = aboutMessage.document.getElementById("imipDetailsButton"); + Assert.ok(!imipDetailsButton.hidden, "Details button should show"); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/calendar/test/browser/invitations/browser_icsAttachment.js b/comm/calendar/test/browser/invitations/browser_icsAttachment.js new file mode 100644 index 0000000000..11bde9144d --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_icsAttachment.js @@ -0,0 +1,71 @@ +/* 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/. */ + +/** + * Test TB can be set as default calendar app. + */ + +/** + * Set TB as default calendar app. + */ +add_setup(function () { + let shellSvc = Cc["@mozilla.org/mail/shell-service;1"].getService(Ci.nsIShellService); + shellSvc.setDefaultClient(false, shellSvc.CALENDAR); + ok(shellSvc.isDefaultClient(false, shellSvc.CALENDAR), "setDefaultClient works"); +}); + +/** + * Test when opening an ics attachment, TB should be shown as an option. + */ +add_task(async function test_ics_attachment() { + let file = new FileUtils.File(getTestFilePath("data/message-containing-event.eml")); + let msgWindow = await openMessageFromFile(file); + let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow; + let promise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + { + async callback(dialogWindow) { + ok(true, "unknownContentType dialog opened"); + let dialogElement = dialogWindow.document.querySelector("dialog"); + let acceptButton = dialogElement.getButton("accept"); + return new Promise(resolve => { + let observer = new MutationObserver(mutationList => { + mutationList.forEach(async mutation => { + if (mutation.attributeName == "disabled" && !acceptButton.disabled) { + is(acceptButton.disabled, false, "Accept button enabled"); + if (AppConstants.platform != "macosx") { + let bundle = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + let name = bundle.GetStringFromName("brandShortName"); + // macOS requires extra step in Finder to set TB as default calendar app. + ok( + dialogWindow.document.getElementById("openHandler").label.includes(name), + `${name} is the default calendar app` + ); + } + + // Should really click acceptButton and test + // calender-ics-file-dialog is opened. But on local, a new TB + // instance is started and this test will fail. + dialogElement.getButton("cancel").click(); + resolve(); + } + }); + }); + observer.observe(acceptButton, { attributes: true }); + }); + }, + } + ); + EventUtils.synthesizeMouseAtCenter( + aboutMessage.document.getElementById("attachmentName"), + {}, + aboutMessage + ); + await promise; + + await BrowserTestUtils.closeWindow(msgWindow); +}); diff --git a/comm/calendar/test/browser/invitations/browser_identityPrompt.js b/comm/calendar/test/browser/invitations/browser_identityPrompt.js new file mode 100644 index 0000000000..e2d6fe3115 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_identityPrompt.js @@ -0,0 +1,144 @@ +/* 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/. */ + +/** + * Tests for the calender-itip-identity dialog. + */ + +"use strict"; + +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/PromiseTestUtils.jsm" +); +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let receiverAcct; +let receiverIdentity; +let gInbox; +let calendar; + +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + MailServices.accounts.removeIncomingServer(receiverAcct.incomingServer, true); + MailServices.accounts.removeAccount(receiverAcct); +}); + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + if (MailServices.accounts.accounts.length == 0) { + MailServices.accounts.createLocalMailAccount(); + } + + let rootFolder = MailServices.accounts.localFoldersServer.rootFolder; + if (!rootFolder.containsChildNamed("Inbox")) { + rootFolder.createSubfolder("Inbox", null); + } + gInbox = rootFolder.getChildNamed("Inbox"); + + receiverAcct = MailServices.accounts.createAccount(); + receiverAcct.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + receiverIdentity = MailServices.accounts.createIdentity(); + receiverIdentity.email = "receiver@example.com"; + receiverAcct.addIdentity(receiverIdentity); + + calendar = CalendarTestUtils.createCalendar("Test"); + + let copyListener = new PromiseTestUtils.PromiseCopyListener(); + MailServices.copy.copyFileMessage( + new FileUtils.File(getTestFilePath("data/meet-meeting-invite.eml")), + gInbox, + null, + false, + 0, + "", + copyListener, + null + ); + await copyListener.promise; +}); + +/** + * Tests that the identity prompt shows when accepting an invitation to an + * event with an identity no calendar is configured to use. + */ +add_task(async function testInvitationIdentityPrompt() { + let tabmail = document.getElementById("tabmail"); + let about3Pane = tabmail.currentAbout3Pane; + about3Pane.displayFolder(gInbox.URI); + about3Pane.threadTree.selectedIndex = 0; + + let dialogPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-itip-identity-dialog.xhtml", + { + async callback(win) { + // Select the identity we want to use. + let menulist = win.document.getElementById("identity-menu"); + for (let i = 0; i < menulist.itemCount; i++) { + let target = menulist.getItemAtIndex(i); + if (target.value == receiverIdentity.fullAddress) { + menulist.selectedIndex = i; + } + } + + win.document.querySelector("dialog").getButton("accept").click(); + }, + } + ); + + // Override this function to intercept the attempt to send the email out. + let sendItemsArgs = []; + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => ({ + scheme: "mailto", + type: "email", + sendItems(receipientArray, item, sender) { + sendItemsArgs = [receipientArray, item, sender]; + return true; + }, + }); + + let aboutMessage = tabmail.currentAboutMessage; + let acceptButton = aboutMessage.document.getElementById("imipAcceptButton"); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(acceptButton), + "waiting for accept button to become visible" + ); + EventUtils.synthesizeMouseAtCenter(acceptButton, {}, aboutMessage); + await dialogPromise; + + let event; + await TestUtils.waitForCondition(async () => { + event = await calendar.getItem("65m17hsdolmotv3kvmrtg40ont@google.com"); + return event && sendItemsArgs.length; + }); + + // Restore this function. + cal.itip.getImipTransport = getImipTransport; + + let id = `mailto:${receiverIdentity.email}`; + Assert.ok(event, "event was added to the calendar successfully"); + Assert.ok(event.getAttendeeById(id), "selected identity was added to the attendee list"); + Assert.equal( + event.getProperty("X-MOZ-INVITED-ATTENDEE"), + id, + "X-MOZ-INVITED-ATTENDEE is set to the selected identity" + ); + + let [recipientArray, , sender] = sendItemsArgs; + Assert.equal(recipientArray.length, 1, "one recipient for the reply"); + Assert.equal(recipientArray[0].id, "mailto:example@gmail.com", "recipient is event organizer"); + Assert.equal(sender.id, id, "sender is the identity selected"); +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBar.js b/comm/calendar/test/browser/invitations/browser_imipBar.js new file mode 100644 index 0000000000..c9a21a6d2b --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBar.js @@ -0,0 +1,199 @@ +/* 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/. */ + +/** + * Tests for receiving event invitations via the imip-bar. + */ +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalItipDefaultEmailTransport } = ChromeUtils.import( + "resource:///modules/CalItipEmailTransport.jsm" +); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests accepting an invitation and sending a response. + */ +add_task(async function testAcceptWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + await clickAction(win, "imipAcceptButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "ACCEPTED", + }, + event + ); + + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting an invitation and sending a response. + */ +add_task(async function testTentativeWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + await clickAction(win, "imipTentativeButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "TENTATIVE", + }, + event + ); + + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining an invitation and sending a response. + */ +add_task(async function testDeclineWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + await clickAction(win, "imipDeclineButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "DECLINED", + }, + event + ); + + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests accepting an invitation without sending a response. + */ +add_task(async function testAcceptWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "ACCEPTED", + noReply: true, + }, + event + ); + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting an invitation without sending a response. + */ +add_task(async function testTentativeWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "TENTATIVE", + noReply: true, + }, + event + ); + + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining an invitation without sending a response. + */ +add_task(async function testDeclineWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "DECLINED", + noReply: true, + }, + event + ); + + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarCancel.js new file mode 100644 index 0000000000..3cde7d4656 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarCancel.js @@ -0,0 +1,129 @@ +/* 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/. */ + +/** + * Tests for processing cancellations via the imip-bar. + */ + +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests accepting a cancellation to an already accepted event. + */ +add_task(async function testCancelAccepted() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + transport, + calendar, + event, + }); +}); + +/** + * Tests accepting a cancellation to tentatively accepted event. + */ +add_task(async function testCancelTentative() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + transport, + calendar, + event, + }); +}); + +/** + * Tests accepting a cancellation to an already declined event. + */ +add_task(async function testCancelDeclined() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + transport, + calendar, + event, + }); +}); + +/** + * Tests the handling of a cancellation when the event was not processed + * previously. + */ +add_task(async function testUnprocessedCancel() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/cancel-single-event.eml")); + let win = await openImipMessage(invite); + + // There should be no buttons present because there is no action to take. + // Note: the imip-bar message "This message contains an event that has already been processed" is + // misleading. + for (let button of [...win.document.querySelectorAll("#imip-view-toolbar > toolbarbutton")]) { + Assert.ok(button.hidden, `${button.id} is hidden`); + } + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarEmail.js b/comm/calendar/test/browser/invitations/browser_imipBarEmail.js new file mode 100644 index 0000000000..a3816b65dd --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarEmail.js @@ -0,0 +1,168 @@ +/* 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/. */ + +/** + * Test that the IMIP bar behaves properly for eml files with invites. + */ + +/* eslint-disable @microsoft/sdl/no-insecure-url */ + +function getFileFromChromeURL(leafName) { + let ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry); + + let url = Services.io.newURI(getRootDirectory(gTestPath) + leafName); + info(url.spec); + let fileURL = ChromeRegistry.convertChromeURL(url).QueryInterface(Ci.nsIFileURL); + return fileURL.file; +} + +/** + * Test that when opening a message containing a Teams meeting invite + * works as it should. + */ +add_task(async function test_event_from_eml() { + let file = getFileFromChromeURL("data/teams-meeting-invite.eml"); + + let msgWindow = await openMessageFromFile(file); + let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow; + + await TestUtils.waitForCondition( + () => !aboutMessage.document.getElementById("imip-bar").collapsed + ); + info("Ok, iMIP bar is showing"); + + // The contentDocument has both the imipHTMLDetails HTML part generated by us, + // and the regular HTML part generated by the sender (the server). + let links = [ + ...msgWindow.content.document.getElementById("imipHTMLDetails").querySelectorAll("a"), + ]; + + Assert.equal(links.length, 3, "The 3 links should show"); + + // Check the links and their text + Assert.equal( + links[0].href, + "https://teams.microsoft.com/l/meetup-join/19%3ameeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thread.v2/0?context=%7b%22Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d", + "link0 href" + ); + Assert.equal( + links[0].textContent, + "<https://teams.microsoft.com/l/meetup-join/19%3ameeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thread.v2/0?context=%7b%22Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d>", + "link0 textContent" + ); + + Assert.equal(links[1].href, "https://aka.ms/JoinTeamsMeeting", "link1 href"); + Assert.equal(links[1].textContent, "<https://aka.ms/JoinTeamsMeeting>", "link1 textContent"); + + Assert.equal( + links[2].href, + "https://teams.microsoft.com/meetingOptions/?organizerId=14464d09-ceb8-458c-a61c-717f1e5c66c5&tenantId=2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=19_meeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=0&language=fi-FI", + "link2 href" + ); + Assert.equal( + links[2].textContent, + "<https://teams.microsoft.com/meetingOptions/?organizerId=14464d09-ceb8-458c-a61c-717f1e5c66c5&tenantId=2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=19_meeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=0&language=fi-FI>", + "link2 textContent" + ); + + await BrowserTestUtils.closeWindow(msgWindow); + + Assert.ok(true, "test_event_from_eml test ran to completion"); +}); + +/** + * Test that when opening a message containing a Meet meeting invite + * works as it should. + */ +add_task(async function test_event_from_eml() { + let file = getFileFromChromeURL("data/meet-meeting-invite.eml"); + + let msgWindow = await openMessageFromFile(file); + let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow; + + await TestUtils.waitForCondition( + () => !aboutMessage.document.getElementById("imip-bar").collapsed + ); + info("Ok, iMIP bar is showing"); + + // The contentDocument has both the imipHTMLDetails HTML part generated by us, + // and the regular HTML part generated by the sender (the server). + let links = [ + ...msgWindow.content.document.getElementById("imipHTMLDetails").querySelectorAll("a"), + ]; + + Assert.equal(links.length, 4, "The 4 links should show"); + + // Check the links and their text + Assert.equal(links[0].href, "mailto:foo@example.com", "link0 href"); + Assert.equal(links[0].textContent, "<foo@example.com>", "link0 textContent"); + + Assert.equal(links[1].href, "http://example.com/?foo=bar", "link1 href"); + Assert.equal(links[1].textContent, "http://example.com?foo=bar", "link1 textContent"); + + Assert.equal(links[2].href, "https://meet.google.com/pyb-ndcu-hhc", "link1 href"); + Assert.equal(links[2].textContent, "https://meet.google.com/pyb-ndcu-hhc", "link1 textContent"); + + Assert.equal( + links[3].href, + "https://calendar.google.com/calendar/event?action=VIEW&eid=NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&tok=MjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=Europe%2FHelsinki&hl=sv&es=1", + "link2 href" + ); + Assert.equal( + links[3].textContent, + "https://calendar.google.com/calendar/event?action=VIEW&eid=NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&tok=MjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=Europe%2FHelsinki&hl=sv&es=1", + "link2 textContent" + ); + + await BrowserTestUtils.closeWindow(msgWindow); + + Assert.ok(true, "test_event_from_eml test ran to completion"); +}); + +/** + * Test that when opening a message containing an outlook invite with "empty" + * content works as it should. + */ +add_task(async function test_outlook_event_from_eml() { + let file = getFileFromChromeURL("data/outlook-test-invite.eml"); + + let msgWindow = await openMessageFromFile(file); + let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow; + + await TestUtils.waitForCondition( + () => !aboutMessage.document.getElementById("imip-bar").collapsed + ); + info("Ok, iMIP bar is showing"); + + let details = msgWindow.content.document.getElementById("imipHTMLDetails"); + + Assert.equal( + details.getAttribute("open"), + "open", + "Details should be expanded when the message doesn't include good details" + ); + + await BrowserTestUtils.closeWindow(msgWindow); + + Assert.ok(true, "test_outlook_event_from_eml test ran to completion"); +}); + +/** + * Test that when opening a message containing an event, the IMIP bar shows. + */ +add_task(async function test_event_from_eml() { + let file = getFileFromChromeURL("data/message-containing-event.eml"); + + let msgWindow = await openMessageFromFile(file); + let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow; + + await TestUtils.waitForCondition( + () => !aboutMessage.document.getElementById("imip-bar").collapsed + ); + info("Ok, iMIP bar is showing"); + + await BrowserTestUtils.closeWindow(msgWindow); + + Assert.ok(true, "test_event_from_eml test ran to completion"); +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js new file mode 100644 index 0000000000..7800e742ca --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js @@ -0,0 +1,137 @@ +/* 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/. */ + +/** + * Tests for processing cancellations to recurring event exceptions. + */ + +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + requestLongerTimeout(3); + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests cancelling an exception works. + */ +add_task(async function testCancelException() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + await doCancelExceptionTest({ + calendar, + transport, + identity, + partStat, + recurrenceId: "20220317T110000Z", + isRecurring: true, + }); + } +}); + +/** + * Tests cancelling an event with only an exception processed works. + */ +add_task(async function testCancelExceptionOnly() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + let win = await openImipMessage( + new FileUtils.File(getTestFilePath("data/exception-major.eml")) + ); + await clickAction(win, actionIds.single.button[partStat]); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + calendar, + event, + transport, + identity, + }); + } +}); + +/** + * Tests processing a cancellation for a recurring event works when only an + * exception was processed previously. + */ +add_task(async function testCancelSeriesWithExceptionOnly() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + let win = await openImipMessage( + new FileUtils.File(getTestFilePath("data/exception-major.eml")) + ); + await clickMenuAction( + win, + actionIds.single.button[partStat], + actionIds.single.noReply[partStat] + ); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item; + await BrowserTestUtils.closeWindow(win); + + let cancel = new FileUtils.File(getTestFilePath("data/cancel-repeat-event.eml")); + let cancelWin = await openImipMessage(cancel); + let aboutMessage = cancelWin.document.getElementById("messageBrowser").contentWindow; + + let deleteButton = aboutMessage.document.getElementById("imipDeleteButton"); + Assert.ok(!deleteButton.hidden, `#${deleteButton.id} button shown`); + EventUtils.synthesizeMouseAtCenter(deleteButton, {}, aboutMessage); + await BrowserTestUtils.closeWindow(cancelWin); + await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 5, 1); + Assert.ok(!(await calendar.getItem(event.id)), "event was deleted"); + + Assert.equal( + transport.sentItems.length, + 0, + "itip subsystem did not attempt to send a response" + ); + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); + } +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js new file mode 100644 index 0000000000..88ad0b3c41 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js @@ -0,0 +1,262 @@ +/* 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/. */ + +/** + * Tests for receiving an invitation exception but the original event was not + * processed first. + */ +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + requestLongerTimeout(5); + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests accepting a minor exception and sending a response. + */ +add_task(async function testMinorAcceptWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml"))); + await clickAction(win, "imipAcceptButton"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "ACCEPTED", + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting a minor exception and sending a response. + */ +add_task(async function testMinorTentativeWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml"))); + await clickAction(win, "imipTentativeButton"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "TENTATIVE", + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining a minor exception and sending a response. + */ +add_task(async function testMinorDeclineWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml"))); + await clickAction(win, "imipDeclineButton"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "DECLINED", + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests accepting a minor exception without sending a response. + */ +add_task(async function testMinorAcceptWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml"))); + await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "ACCEPTED", + noReply: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting a minor exception without sending a response. + */ +add_task(async function testMinorTentativeWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml"))); + await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "TENTATIVE", + noReply: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining a minor exception without sending a response. + */ +add_task(async function testMinorDeclineWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml"))); + await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "DECLINED", + noReply: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests accepting a major exception and sending a response. + */ +add_task(async function testMajorAcceptWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml"))); + await clickAction(win, "imipAcceptButton"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "ACCEPTED", + isMajor: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting a major exception and sending a response. + */ +add_task(async function testMajorTentativeWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml"))); + await clickAction(win, "imipTentativeButton"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "TENTATIVE", + isMajor: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining a major exception and sending a response. + */ +add_task(async function testMajorDeclineWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml"))); + await clickAction(win, "imipDeclineButton"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "DECLINED", + isMajor: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests accepting a major exception without sending a response. + */ +add_task(async function testMajorAcceptWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml"))); + await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "ACCEPTED", + noReply: true, + isMajor: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting a major exception without sending a response. + */ +add_task(async function testMajorTentativeWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml"))); + await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "TENTATIVE", + noReply: true, + isMajor: true, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining a major exception without sending a response. + */ +add_task(async function testMajorDeclineWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml"))); + await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend"); + await doExceptionOnlyTest({ + calendar, + transport, + identity, + partStat: "DECLINED", + noReply: true, + isMajor: true, + }); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js new file mode 100644 index 0000000000..2cdf18ed59 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js @@ -0,0 +1,288 @@ +/* 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/. */ + +/** + * Tests for handling exceptions to recurring event invitations via the imip-bar. + */ + +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + requestLongerTimeout(5); + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests a minor update exception to an already accepted recurring event. + */ +add_task(async function testMinorUpdateExceptionToAccepted() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorExceptionTest({ + transport, + calendar, + partStat: "ACCEPTED", + }); +}); + +/** + * Tests a minor update exception to an already tentatively accepted recurring + * event. + */ +add_task(async function testMinorUpdateExceptionToTentative() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorExceptionTest({ + transport, + calendar, + partStat: "TENTATIVE", + }); +}); + +/** + * Tests a minor update exception to an already declined recurring declined + * event. + */ +add_task(async function testMinorUpdateExceptionToDeclined() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorExceptionTest({ + transport, + calendar, + partStat: "DECLINED", + }); +}); + +/** + * Tests a major update exception to an already accepted event. + */ +add_task(async function testMajorExceptionToAcceptedWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorExceptionTest({ + transport, + identity, + calendar, + partStat, + }); + } +}); + +/** + * Tests a major update exception to an already tentatively accepted event. + */ +add_task(async function testMajorExceptionToTentativeWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorExceptionTest({ + transport, + identity, + calendar, + partStat, + }); + } +}); + +/** + * Tests a major update exception to an already declined event. + */ +add_task(async function testMajorExceptionToDeclinedWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorExceptionTest({ + transport, + identity, + calendar, + isRecurring: true, + partStat, + }); + } +}); + +/** + * Tests a major update exception to an already accepted event without sending + * a reply. + */ +add_task(async function testMajorExecptionToAcceptedWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickMenuAction( + win, + "imipAcceptRecurrencesButton", + "imipAcceptRecurrencesButton_AcceptDontSend" + ); + + await BrowserTestUtils.closeWindow(win); + await doMajorExceptionTest({ + transport, + calendar, + isRecurring: true, + partStat, + noReply: true, + }); + } +}); + +/** + * Tests a major update exception to an already tentatively accepted event + * without sending a reply. + */ +add_task(async function testMajorUpdateToTentativeWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickMenuAction( + win, + "imipTentativeRecurrencesButton", + "imipTentativeRecurrencesButton_TentativeDontSend" + ); + + await BrowserTestUtils.closeWindow(win); + await doMajorExceptionTest({ + transport, + calendar, + isRecurring: true, + partStat, + noReply: true, + }); + } +}); + +/** + * Tests a major update exception to a declined event without sending a reply. + */ +add_task(async function testMajorUpdateToDeclinedWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickMenuAction( + win, + "imipDeclineRecurrencesButton", + "imipDeclineRecurrencesButton_DeclineDontSend" + ); + + await BrowserTestUtils.closeWindow(win); + await doMajorExceptionTest({ + transport, + calendar, + isRecurring: true, + partStat, + noReply: true, + }); + } +}); + +/** + * Tests a major update exception to an event where the participation status + * is still "NEEDS-ACTION". Here we want to ensure action is only taken on the + * target exception date and not the other dates. + */ +add_task(async function testMajorUpdateToNeedsAction() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + + // Extract the event from the .eml file and manually add it to the calendar. + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let srcText = await IOUtils.readUTF8(invite.path); + let ics = srcText.match( + /--00000000000080f3da05db4aef59[\S\s]+--00000000000080f3da05db4aef59/g + )[0]; + ics = ics.split("--00000000000080f3da05db4aef59").join(""); + ics = ics.replaceAll(/Content-(Type|Transfer-Encoding)?: .*/g, ""); + + let event = new CalEvent(ics); + + // This will not be set because we manually added the event. + event.setProperty("x-moz-received-dtstamp", "20220316T191602Z"); + + await calendar.addItem(event); + await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1).item; + await doMajorExceptionTest({ + transport, + identity, + calendar, + isRecurring: true, + partStat, + }); + } +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js new file mode 100644 index 0000000000..c14ff2c0a5 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js @@ -0,0 +1,218 @@ +/* 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/. */ + +/** + * Tests for receiving recurring event invitations via the imip-bar. + */ +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests accepting an invitation to a recurring event and sending a response. + */ +add_task(async function testAcceptRecurringWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipAcceptRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + isRecurring: true, + partStat: "ACCEPTED", + }, + event + ); + + await calendar.deleteItem(event.parentItem); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting an invitation to a recurring event and sending a + * response. + */ +add_task(async function testTentativeRecurringWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipTentativeRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + isRecurring: true, + partStat: "TENTATIVE", + }, + event + ); + + await calendar.deleteItem(event.parentItem); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining an invitation to a recurring event and sending a response. + */ +add_task(async function testDeclineRecurringWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipDeclineRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + + await doImipBarActionTest( + { + calendar, + transport, + identity, + isRecurring: true, + partStat: "DECLINED", + }, + event + ); + + await calendar.deleteItem(event.parentItem); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests accepting an invitation to a recurring event without sending a response. + */ +add_task(async function testAcceptRecurringWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickMenuAction( + win, + "imipAcceptRecurrencesButton", + "imipAcceptRecurrencesButton_AcceptDontSend" + ); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + isRecurring: true, + partStat: "ACCEPTED", + noReply: true, + }, + event + ); + + await calendar.deleteItem(event.parentItem); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting an invitation to a recurring event without sending + * a response. + */ +add_task(async function testTentativeRecurringWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickMenuAction( + win, + "imipTentativeRecurrencesButton", + "imipTentativeRecurrencesButton_TentativeDontSend" + ); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + isRecurring: true, + partStat: "TENTATIVE", + noReply: true, + }, + event + ); + + await calendar.deleteItem(event.parentItem); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining an invitation to a recurring event without sending a response. + */ +add_task(async function testDeclineRecurrencesWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickMenuAction( + win, + "imipDeclineRecurrencesButton", + "imipDeclineRecurrencesButton_DeclineDontSend" + ); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + isRecurring: true, + partStat: "DECLINED", + noReply: true, + }, + event + ); + + await calendar.deleteItem(event.parentItem); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js new file mode 100644 index 0000000000..1ab50cc739 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js @@ -0,0 +1,186 @@ +/* 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/. */ + +/** + * Tests for processing cancellations to recurring invitations via the imip-bar. + */ +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + requestLongerTimeout(5); + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests accepting a cancellation to an already accepted recurring event. + */ +add_task(async function testCancelAcceptedRecurring() { + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipAcceptRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + calendar, + event, + transport, + isRecurring: true, + }); +}); + +/** + * Tests accepting a cancellation to an already tentatively accepted event. + */ +add_task(async function testCancelTentativeRecurring() { + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipTentativeRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + calendar, + event, + transport, + identity, + isRecurring: true, + }); +}); + +/** + * Tests accepting a cancellation to an already declined recurring event. + */ +add_task(async function testCancelDeclinedRecurring() { + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipDeclineRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + calendar, + event, + transport, + identity, + isRecurring: true, + }); +}); + +/** + * Tests accepting a cancellation to a single occurrence of an already accepted + * recurring event. + */ +add_task(async function testCancelAcceptedOccurrence() { + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipAcceptRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + calendar, + event, + transport, + isRecurring: true, + recurrenceId: "20220317T110000Z", + }); + await calendar.deleteItem(event.parentItem); +}); + +/** + * Tests accepting a cancellation to a single occurrence of an already tentatively + * accepted event. + */ +add_task(async function testCancelTentativeOccurrence() { + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipTentativeRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + calendar, + event, + transport, + identity, + isRecurring: true, + recurrenceId: "20220317T110000Z", + }); + await calendar.deleteItem(event.parentItem); +}); + +/** + * Tests accepting a cancellation to a single occurrence of an already declined + * recurring event. + */ +add_task(async function testCancelDeclinedOccurrence() { + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml"))); + await clickAction(win, "imipDeclineRecurrencesButton"); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await BrowserTestUtils.closeWindow(win); + await doCancelTest({ + calendar, + event, + transport, + identity, + isRecurring: true, + recurrenceId: "20220317T110000Z", + }); + await calendar.deleteItem(event.parentItem); +}); + +/** + * Tests the handling of a cancellation when the event was not processed + * previously. + */ +add_task(async function testUnprocessedCancel() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/cancel-repeat-event.eml")); + let win = await openImipMessage(invite); + for (let button of [...win.document.querySelectorAll("#imip-view-toolbar > toolbarbutton")]) { + Assert.ok(button.hidden, `${button.id} is hidden`); + } + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js new file mode 100644 index 0000000000..7f0d16f627 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js @@ -0,0 +1,247 @@ +/* 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/. */ + +/** + * Tests for receiving minor and major updates to recurring event invitations + * via the imip-bar. + */ + +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + requestLongerTimeout(5); + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests a minor update to an already accepted event. + */ +add_task(async function testMinorUpdateToAccepted() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorUpdateTest({ + transport, + calendar, + isRecurring: true, + partStat: "ACCEPTED", + }); +}); + +/** + * Tests a minor update to an already tentatively accepted event. + */ +add_task(async function testMinorUpdateToTentative() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorUpdateTest({ + transport, + calendar, + isRecurring: true, + partStat: "TENTATIVE", + }); +}); + +/** + * Tests a minor update to an already declined event. + */ +add_task(async function testMinorUpdateToDeclined() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorUpdateTest({ transport, calendar, isRecurring: true, invite, partStat: "DECLINED" }); +}); + +/** + * Tests a major update to an already accepted event. + */ +add_task(async function testMajorUpdateToAcceptedWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + identity, + calendar, + isRecurring: true, + partStat, + }); + } +}); + +/** + * Tests a major update to an already tentatively accepted event. + */ +add_task(async function testMajorUpdateToTentativeWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + identity, + calendar, + isRecurring: true, + partStat, + }); + } +}); + +/** + * Tests a major update to an already declined event. + */ +add_task(async function testMajorUpdateToDeclinedWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineRecurrencesButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + identity, + calendar, + isRecurring: true, + partStat, + }); + } +}); + +/** + * Tests a major update to an already accepted event without replying to the + * update. + */ +add_task(async function testMajorUpdateToAcceptedWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickMenuAction( + win, + "imipAcceptRecurrencesButton", + "imipAcceptRecurrencesButton_AcceptDontSend" + ); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + calendar, + isRecurring: true, + partStat, + noReply: true, + }); + } +}); + +/** + * Tests a major update to an already tentatively accepted event without replying + * to the update. + */ +add_task(async function testMajorUpdateToTentativeWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickMenuAction( + win, + "imipTentativeRecurrencesButton", + "imipTentativeRecurrencesButton_TentativeDontSend" + ); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + calendar, + isRecurring: true, + partStat, + noReply: true, + }); + } +}); + +/** + * Tests a major update to an already declined event. + */ +add_task(async function testMajorUpdateToDeclinedWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickMenuAction( + win, + "imipDeclineRecurrencesButton", + "imipDeclineRecurrencesButton_DeclineDontSend" + ); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + calendar, + isRecurring: true, + partStat, + noReply: true, + }); + } +}); diff --git a/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js b/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js new file mode 100644 index 0000000000..d0f5018e89 --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js @@ -0,0 +1,223 @@ +/* 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/. */ + +/** + * Tests for receiving minor and major updates to invitations via the imip-bar. + */ + +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + requestLongerTimeout(5); + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Tests a minor update to an already accepted event. + */ +add_task(async function testMinorUpdateToAccepted() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorUpdateTest({ + transport, + calendar, + partStat: "ACCEPTED", + }); +}); + +/** + * Tests a minor update to an already tentatively accepted event. + */ +add_task(async function testMinorUpdateToTentative() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorUpdateTest({ transport, calendar, invite, partStat: "TENTATIVE" }); +}); + +/** + * Tests a minor update to an already declined event. + */ +add_task(async function testMinorUpdateToDeclined() { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineButton"); + + await BrowserTestUtils.closeWindow(win); + await doMinorUpdateTest({ transport, calendar, invite, partStat: "DECLINED" }); +}); + +/** + * Tests a major update to an already accepted event. + */ +add_task(async function testMajorUpdateToAcceptedWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + identity, + calendar, + partStat, + }); + } +}); + +/** + * Tests a major update to an already tentatively accepted event. + */ +add_task(async function testMajorUpdateToTentativeWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + identity, + calendar, + partStat, + }); + } +}); + +/** + * Tests a major update to an already declined event. + */ +add_task(async function testMajorUpdateToDeclinedWithResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + identity, + calendar, + partStat, + }); + } +}); + +/** + * Tests a major update to an already accepted event without replying to the + * update. + */ +add_task(async function testMajorUpdateToAcceptedWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipAcceptButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + calendar, + partStat, + noReply: true, + }); + } +}); + +/** + * Tests a major update to an already tentatively accepted event without replying + * to the update. + */ +add_task(async function testMajorUpdateToTentativeWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipTentativeButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + calendar, + partStat, + noReply: true, + }); + } +}); + +/** + * Tests a major update to an already declined event. + */ +add_task(async function testMajorUpdateToDeclinedWithoutResponse() { + for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) { + transport.reset(); + let invite = new FileUtils.File(getTestFilePath("data/single-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, "imipDeclineButton"); + + await BrowserTestUtils.closeWindow(win); + await doMajorUpdateTest({ + transport, + calendar, + partStat, + noReply: true, + }); + } +}); diff --git a/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js b/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js new file mode 100644 index 0000000000..a7b3f833de --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js @@ -0,0 +1,257 @@ +/* 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/. */ + +/** + * Tests for the invitation panel display with new events. + */ +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalItipDefaultEmailTransport } = ChromeUtils.import( + "resource:///modules/CalItipEmailTransport.jsm" +); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let identity; +let calendar; +let transport; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + transport = new EmailTransport(account, identity); + + let getImipTransport = cal.itip.getImipTransport; + cal.itip.getImipTransport = () => transport; + + let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ).wrappedJSObject; + let markDeleted = deleteMgr.markDeleted; + deleteMgr.markDeleted = () => {}; + + Services.prefs.setBoolPref("calendar.itip.newInvitationDisplay", true); + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + cal.itip.getImipTransport = getImipTransport; + deleteMgr.markDeleted = markDeleted; + CalendarTestUtils.removeCalendar(calendar); + Services.prefs.setBoolPref("calendar.itip.newInvitationDisplay", false); + }); +}); + +/** + * Tests the invitation panel shows the correct data when loaded with a new + * invitation. + */ +add_task(async function testShowPanelData() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + let panel = win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"); + + if (panel.ownerDocument.hasPendingL10nMutations) { + await BrowserTestUtils.waitForEvent(panel.ownerDocument, "L10nMutationsFinished"); + } + + let notification = panel.shadowRoot.querySelector("notification-message"); + compareShownPanelValues(notification.shadowRoot, { + ".notification-message": "You have been invited to this event.", + ".notification-button-container > button": "More", + }); + + compareShownPanelValues(panel.shadowRoot, { + "#title": "Single Event", + "#location": "Somewhere", + "#partStatTotal": "3 participants", + '[data-l10n-id="calendar-invitation-panel-partstat-accepted"]': "1 yes", + '[data-l10n-id="calendar-invitation-panel-partstat-needs-action"]': "2 pending", + "#attendees li:nth-of-type(1)": "Sender <sender@example.com>", + "#attendees li:nth-of-type(2)": "Receiver <receiver@example.com>", + "#attendees li:nth-of-type(3)": "Other <other@example.com>", + "#description": "An event invitation.", + }); + + Assert.ok(!panel.shadowRoot.querySelector("#actionButtons").hidden, "action buttons shown"); + for (let indicator of [ + ...panel.shadowRoot.querySelectorAll("calendar-invitation-change-indicator"), + ]) { + Assert.ok(indicator.hidden, `${indicator.id} is hidden`); + } + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests accepting an invitation and sending a response. + */ +add_task(async function testAcceptWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + let panel = win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"); + + await clickPanelAction(panel, "acceptButton"); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "ACCEPTED", + }, + event + ); + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting an invitation and sending a response. + */ +add_task(async function testTentativeWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + let panel = win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"); + + await clickPanelAction(panel, "tentativeButton"); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "TENTATIVE", + }, + event + ); + + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining an invitation and sending a response. + */ +add_task(async function testDeclineWithResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + let panel = win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"); + + await clickPanelAction(panel, "declineButton"); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "DECLINED", + }, + event + ); + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests accepting an invitation without sending a response. + */ +add_task(async function testAcceptWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + let panel = win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"); + + await clickPanelAction(panel, "acceptButton", false); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "ACCEPTED", + noSend: true, + }, + event + ); + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests tentatively accepting an invitation without sending a response. + */ +add_task(async function testTentativeWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + let panel = win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"); + + await clickPanelAction(panel, "tentativeButton", false); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "TENTATIVE", + noSend: true, + }, + event + ); + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests declining an invitation without sending a response. + */ +add_task(async function testDeclineWithoutResponse() { + transport.reset(); + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml"))); + let panel = win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"); + + await clickPanelAction(panel, "declineButton", false); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + await doImipBarActionTest( + { + calendar, + transport, + identity, + partStat: "DECLINED", + noSend: true, + }, + event + ); + await calendar.deleteItem(event); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js b/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js new file mode 100644 index 0000000000..2d05ed66dc --- /dev/null +++ b/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js @@ -0,0 +1,107 @@ +/* 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/. */ + +/** + * Tests for ensuring the application does not hang after processing an + * unsupported FREQ value. + */ +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +let calendar; + +/** + * Initialize account, identity and calendar. + */ +add_setup(async function () { + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + "receiver", + "example.com", + "imap" + ); + + let identity = MailServices.accounts.createIdentity(); + identity.email = "receiver@example.com"; + account.addIdentity(identity); + + await CalendarTestUtils.setCalendarView(window, "month"); + window.goToDate(cal.createDateTime("20220316T191602Z")); + + calendar = CalendarTestUtils.createCalendar("Test"); + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, true); + CalendarTestUtils.removeCalendar(calendar); + }); +}); + +/** + * Runs the test using the provided FREQ value. + * + * @param {string} freq Either "SECONDLY" or "MINUTELY" + */ +async function doFreqTest(freq) { + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let srcText = await IOUtils.readUTF8(invite.path); + let tmpFile = FileTestUtils.getTempFile(`${freq}.eml`); + + srcText = srcText.replace(/RRULE:.*/g, `RRULE:FREQ=${freq}`); + srcText = srcText.replace(/UID:.*/g, `UID:${freq}`); + await IOUtils.writeUTF8(tmpFile.path, srcText); + + let win = await openImipMessage(tmpFile); + await clickMenuAction( + win, + "imipAcceptRecurrencesButton", + "imipAcceptRecurrencesButton_AcceptDontSend" + ); + + // Give the view time to refresh and create any occurrences. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 5000)); + await BrowserTestUtils.closeWindow(win); + + let dayBoxItems = document.querySelectorAll("calendar-month-day-box-item"); + Assert.equal(dayBoxItems.length, 1, "only one occurrence displayed"); + + let [dayBox] = dayBoxItems; + let { item } = dayBox; + Assert.equal(item.title, "Repeat Event"); + Assert.equal(item.startDate.icalString, "20220316T110000Z"); + + let summaryDialog = await CalendarTestUtils.viewItem(window, dayBox); + Assert.equal( + summaryDialog.document.querySelector(".repeat-details").textContent, + "Repeat details unknown", + "repeat details not shown" + ); + + await BrowserTestUtils.closeWindow(summaryDialog); + await calendar.deleteItem(item.parentItem); + await TestUtils.waitForCondition( + () => document.querySelectorAll("calendar-month-day-box-item").length == 0 + ); +} + +/** + * Tests accepting an invitation using the FREQ=SECONDLY value does not render + * the application unusable. + */ +add_task(async function testSecondly() { + return doFreqTest("SECONDLY"); +}); + +/** + * Tests accepting an invitation using the FREQ=MINUTELY value does not render + * the application unusable. + */ +add_task(async function testMinutely() { + return doFreqTest("MINUTELY"); +}); diff --git a/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml b/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml new file mode 100644 index 0000000000..03f298525b --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml @@ -0,0 +1,49 @@ +MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Event @ Daily from 2pm to 3pm 3 times (AST) (receiver@example.com)
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=CANCEL
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:CANCEL
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:1
+STATUS:CANCELLED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/cancel-single-event.eml b/comm/calendar/test/browser/invitations/data/cancel-single-event.eml new file mode 100644 index 0000000000..afb4edb99d --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/cancel-single-event.eml @@ -0,0 +1,78 @@ +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582" +Date: Wed, 16 Mar 2022 15:16:02 -0400 +To: receiver@example.com +Subject: Cancellation: Single Event @ Wed, Mar 16 2022 11:00 AST +From: Sender <sender@example.com> + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762582 +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Length: 227 +Content-Transfer-Encoding: binary +Content-Type: text/plain; charset="utf-8" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +Single Event + +When: + Wed, Mar 16 2022 + 11:00 - 12:00 AST +Where: + Somewhere + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Transfer-Encoding: quoted-printable +Content-Type: text/calendar; charset="utf-8"; method=CANCEL +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:CANCEL +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:02e79b96 +SEQUENCE:1 +DTSTAMP:20220317T191602Z +CREATED:20220316T191532Z +DTSTART:20220316T110000Z +DTEND:20220316T113000Z +DURATION:PT1H +PRIORITY:0 +SUMMARY:Single Event +DESCRIPTION:An event invitation. +LOCATION:Somewhere +STATUS:CANCELLED +TRANSP:OPAQUE +CLASS:PUBLIC +ORGANIZER;CN=3DSender; + EMAIL=3Dsender@example.com:mailto:sender@example.com +ATTENDEE;CN=3DSender; + EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DACCEPTED:mailto:sender@example.com +ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION:mailto:receiver@example.com +ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION:mailto:other@example.com +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-P1D +DESCRIPTION:This is an event reminder +END:VALARM +END:VEVENT +END:VCALENDAR + +--_----------=_1647458162153312762583-- diff --git a/comm/calendar/test/browser/invitations/data/exception-major.eml b/comm/calendar/test/browser/invitations/data/exception-major.eml new file mode 100644 index 0000000000..07f48e64bd --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/exception-major.eml @@ -0,0 +1,49 @@ +MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Exception Major
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220317T050000Z
+DTEND:20220317T053000Z
+RECURRENCE-ID:20220317T110000Z
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/exception-minor.eml b/comm/calendar/test/browser/invitations/data/exception-minor.eml new file mode 100644 index 0000000000..7cc38d29d3 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/exception-minor.eml @@ -0,0 +1,49 @@ +MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Exception Minor
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220317T110000Z
+DTEND:20220317T113000Z
+RECURRENCE-ID:20220317T110000Z
+DTSTAMP:20220318T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Exception location
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Exception title
+DESCRIPTION:Exception description
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Exception description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml b/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml new file mode 100644 index 0000000000..8587cd803a --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml @@ -0,0 +1,384 @@ +Sender: Google Kalender <calendar-notification@google.com>
+Message-ID: <0000000000008c6d7005be1c767c@google.com>
+Date: Mon, 22 Mar 2021 09:12:20 +0000
+Subject: Meet invite (HTML)
+From: example@gmail.com
+To: homer@example.com
+Content-Type: multipart/mixed; boundary="0000000000008c6d6205be1c767e"
+Return-Path: example@gmail.com
+MIME-Version: 1.0
+
+--0000000000008c6d6205be1c767e
+Content-Type: multipart/alternative; boundary="0000000000008c6d6005be1c767c"
+
+--0000000000008c6d6005be1c767c
+Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
+Content-Transfer-Encoding: base64
+
+RHUgaGFyIGJsaXZpdCBpbmJqdWRlbiB0aWxsIGbDtmxqYW5kZSBow6RuZGVsc2UuCgpUaXRlbDog
+TWVlZWVldCBtZSBIVE1MClRoaXMgaXMgYSB0ZXN0LiBCb2xkLiBJdGFsaWMuJm5ic3A7V2lsbCBk
+aXNjdXNzIGFkZHJlc3MgZm9yIGVtYWlsICAKJmx0O2Zvb0BleGFtcGxlLmNvbSZndDsgYW5kIGh0
+dHA6Ly9leGFtcGxlLmNvbT9mb289YmFyLgpOw6RyOiBtw6VuIGRlbiAyMiBtYXJzIDIwMjEgMTE6
+MzBhbSDigJMgMTI6MzBwbSDDlnN0ZXVyb3BlaXNrIHRpZCAtIEhlbHNpbmdmb3JzCgpBbnNsdXRu
+aW5nc2luZm86IEFuc2x1dCB0aWxsIEdvb2dsZSBNZWV0Cmh0dHBzOi8vbWVldC5nb29nbGUuY29t
+L3B5Yi1uZGN1LWhoYz9ocz0yMjQKCkthbGVuZGVyOiBob21lckBleGFtcGxlLmNvbQpWZW06CiAg
+ICAgKiBleGFtcGxlQGdtYWlsLmNvbeKAkyBvcmdhbmlzYXTDtnIKICAgICAqIGhvbWVyQGV4YW1w
+bGUuY29tCgpJbmZvcm1hdGlvbiBvbSBow6RuZGVsc2VuOiAgCmh0dHBzOi8vY2FsZW5kYXIuZ29v
+Z2xlLmNvbS9jYWxlbmRhci9ldmVudD9hY3Rpb249VklFVyZlaWQ9TmpWdE1UZG9jMlJ2YkcxdmRI
+WXphM1p0Y25Sbk5EQnZiblFnYldGbmJuVnpMbTFsYkdsdVFHaDFkQzVtYVEmdG9rPU1qRWpZbVZ5
+ZEdGMGFHVmliM1JBWjIxaGFXd3VZMjl0WlRnMk5HRmpZbU5qWVdFMU1qVmxaV0ptWTJVelltUm1N
+REF5TldVME1Ea3pOREF4WmpSaFpnJmN0ej1FdXJvcGUlMkZIZWxzaW5raSZobD1zdiZlcz0wCgpJ
+bmJqdWRhbiBmcsOlbiBHb29nbGUgS2FsZW5kZXI6IGh0dHBzOi8vY2FsZW5kYXIuZ29vZ2xlLmNv
+bS9jYWxlbmRhci8KCkRldHRhIGUtcG9zdG1lZGRlbGFuZGUgaGFyIHNraWNrYXRzIHRpbGwga29u
+dG90IGhvbWVyQGV4YW1wbGUuY29tICAKZWZ0ZXJzb20gZHUgw6RyIGRlbHRhZ2FyZSB2aWQgZGVu
+bmEgaMOkbmRlbHNlLgoKT20gZHUgaW50ZSB2aWxsIGbDpSB1cHBkYXRlcmluZ2FyIG9tIGRlbm5h
+IGjDpG5kZWxzZSBpIGZyYW10aWRlbiBrYW4gZHUgdGFja2EgIApuZWogdGlsbCBkZW5uYSBow6Ru
+ZGVsc2UuIER1IGthbiDDpHZlbiByZWdpc3RyZXJhIGRpZyBmw7ZyIGF0dCBmw6UgZXR0ICAKR29v
+Z2xlLWtvbnRvIHDDpSBodHRwczovL2NhbGVuZGFyLmdvb2dsZS5jb20vY2FsZW5kYXIvIG9jaCBr
+b250cm9sbGVyYSAgCmF2aXNlcmluZ3NpbnN0w6RsbG5pbmdhcm5hIGbDtnIgaGVsYSBrYWxlbmRl
+cm4uCgpPbSBkdSB2aWRhcmViZWZvcmRyYXIgZGVuIGjDpHIgaW5ianVkYW4ga2FuIGRldCBnw7Zy
+YSBkZXQgbcO2amxpZ3QgZsO2ciBhbGxhICAKbW90dGFnYXJlIGF0dCBza2lja2EgZXR0IHN2YXIg
+dGlsbCBvcmdhbmlzYXTDtnJlbiBvY2ggbMOkZ2dhcyB0aWxsIHDDpSAgCmfDpHN0bGlzdGFuLCBi
+anVkYSBpbiBhbmRyYSBvYXZzZXR0IGRlcmFzIGVnZW4gaW5ianVkbmluZ3NzdGF0dXMgZWxsZXIg
+IAptb2RpZmllcmEgZGl0dCBPU0EuIEzDpHMgbWVyIHDDpSAgCmh0dHBzOi8vc3VwcG9ydC5nb29n
+bGUuY29tL2NhbGVuZGFyL2Fuc3dlci8zNzEzNSNmb3J3YXJkaW5nCg==
+--0000000000008c6d6005be1c767c
+Content-Type: text/html; charset="UTF-8"
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+<head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
+</head>
+<body>
+<span itemscope=3D"" itemtype=3D"http://schema.org/InformAction"><span styl=
+e=3D"display:none" itemprop=3D"about" itemscope=3D"" itemtype=3D"http://sch=
+ema.org/Person">
+<meta itemprop=3D"description" content=3D"Inbjudan fr=C3=A5n example@gm=
+ail.com">
+</span><span itemprop=3D"object" itemscope=3D"" itemtype=3D"http://schema.o=
+rg/Event">
+<div style=3D"">
+<table cellspacing=3D"0" cellpadding=3D"8" border=3D"0" summary=3D"" style=
+=3D"width:100%;font-family:Arial,Sans-serif;border:1px Solid #ccc;border-wi=
+dth:1px 2px 2px 1px;background-color:#fff;">
+<tbody>
+<tr>
+<td>
+<meta itemprop=3D"eventStatus" content=3D"http://schema.org/EventScheduled"=
+>
+<h4 style=3D"padding:6px 0;margin:0 0 4px 0;font-family:Arial,Sans-serif;fo=
+nt-size:13px;line-height:1.4;border:1px Solid #fff;background:#fff;color:#0=
+90;font-weight:normal">
+<strong>Du har blivit inbjuden till f=C3=B6ljande h=C3=A4ndelse.</strong></=
+h4>
+<div style=3D"padding:2px"><span itemprop=3D"publisher" itemscope=3D"" item=
+type=3D"http://schema.org/Organization">
+<meta itemprop=3D"name" content=3D"Google Calendar">
+</span>
+<meta itemprop=3D"eventId/googleCalendar" content=3D"65m17hsdolmotv3kvmrtg4=
+0ont">
+<h3 style=3D"padding:0 0 6px 0;margin:0;font-family:Arial,Sans-serif;font-s=
+ize:16px;font-weight:bold;color:#222">
+<span itemprop=3D"name">Meeeeet me HTML</span></h3>
+<table style=3D"display:inline-table" cellpadding=3D"0" cellspacing=3D"0" b=
+order=3D"0" summary=3D"Uppgifter om h=C3=A4ndelse">
+<tbody>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">N=C3=A4r</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px"><time itemprop=3D"startDate" datetime=3D"20=
+210322T093000Z"></time><time itemprop=3D"endDate" datetime=3D"20210322T1030=
+00Z"></time>m=C3=A5n den 22 mars 2021 11:30am =E2=80=93 12:30pm
+<span style=3D"color:#888">=C3=96steuropeisk tid - Helsingfors</span></div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 4px 0;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Anslutningsinfo</i></div>
+</td>
+<td style=3D"padding-bottom:4px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">Anslut till Google Meet</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px">
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">
+<div style=3D"text-indent:-1px"><span itemprop=3D"potentialaction" itemscop=
+e=3D"" itemtype=3D"http://schema.org/JoinAction"><span itemprop=3D"name" co=
+ntent=3D"meet.google.com/pyb-ndcu-hhc"><span itemprop=3D"target" itemscope=
+=3D"" itemtype=3D"http://schema.org/EntryPoint"><span itemprop=3D"url" cont=
+ent=3D"https://meet.google.com/pyb-ndcu-hhc?hs=3D224"><span itemprop=3D"htt=
+pMethod" content=3D"GET"><a href=3D"https://meet.google.com/pyb-ndcu-hhc?hs=
+=3D224" style=3D"color:#20c;white-space:nowrap" target=3D"_blank">meet.goog=
+le.com/pyb-ndcu-hhc</a></span></span></span></span></span>
+</div>
+</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Kalender</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">homer@example.com</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Vem</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<table cellspacing=3D"0" cellpadding=3D"0">
+<tbody>
+<tr>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222;width:10px">
+<div style=3D"text-indent:-1px"><span style=3D"font-family:Courier New,mono=
+space">=E2=80=A2</span></div>
+</td>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222">
+<div style=3D"text-indent:-1px">
+<div>
+<div style=3D"margin:0 0 0.3em 0"><span itemprop=3D"attendee" itemscope=3D"=
+" itemtype=3D"http://schema.org/Person"><span itemprop=3D"name" class=3D"no=
+translate">example@gmail.com</span>
+<meta itemprop=3D"email" content=3D"example@gmail.com">
+</span><span itemprop=3D"organizer" itemscope=3D"" itemtype=3D"http://schem=
+a.org/Person">
+<meta itemprop=3D"name" content=3D"example@gmail.com">
+<meta itemprop=3D"email" content=3D"example@gmail.com">
+</span><span style=3D"font-size:11px;color:#888">=E2=80=93 organisat=C3=B6r=
+</span></div>
+</div>
+</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222;width:10px">
+<div style=3D"text-indent:-1px"><span style=3D"font-family:Courier New,mono=
+space">=E2=80=A2</span></div>
+</td>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222">
+<div style=3D"text-indent:-1px">
+<div>
+<div style=3D"margin:0 0 0.3em 0"><span itemprop=3D"attendee" itemscope=3D"=
+" itemtype=3D"http://schema.org/Person"><span itemprop=3D"name" class=3D"no=
+translate">homer@example.com</span>
+<meta itemprop=3D"email" content=3D"homer@example.com">
+</span></div>
+</div>
+</div>
+</td>
+</tr>
+</tbody>
+</table>
+</td>
+</tr>
+</tbody>
+</table>
+<div style=3D"float:right;font-weight:bold;font-size:13px"><a href=3D"https=
+://calendar.google.com/calendar/event?action=3DVIEW&eid=3DNjVtMTdoc2Rvb=
+G1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&tok=3DMjEjYmVydGF0aGV=
+ib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&=
+;ctz=3DEurope%2FHelsinki&hl=3Dsv&es=3D0" style=3D"color:#20c;white-=
+space:nowrap" itemprop=3D"url">mer
+ information =C2=BB</a><br>
+</div>
+<div style=3D"padding-bottom:15px;font-family:Arial,Sans-serif;font-size:13=
+px;color:#222;white-space:pre-wrap!important;white-space:-moz-pre-wrap!impo=
+rtant;white-space:-pre-wrap!important;white-space:-o-pre-wrap!important;whi=
+te-space:pre;word-wrap:break-word">
+<span>This is a test. <b>Bold</b>. <i>Italic</i>. <br>
+<br>
+Will discuss address for email <<a href=3D"mailto:foo@example.com" targe=
+t=3D"_blank">foo@example.com</a>> and
+<a href=3D"https://www.google.com/url?q=3Dhttp%3A%2F%2Fexample.com%3Ffoo%3D=
+bar&sa=3DD&ust=3D1616836340813000&usg=3DAOvVaw04gjO0O3Bf1tJs9vs=
+BMj3x" target=3D"_blank">
+http://example.com?foo=3Dbar</a>.</span>
+<meta itemprop=3D"description" content=3D"This is a test. Bold. Italic.&nbs=
+p;Will discuss address for email <foo@example.com> and http://example=
+.com?foo=3Dbar.">
+</div>
+</div>
+<p style=3D"color:#222;font-size:13px;margin:0"><span style=3D"color:#888">=
+Ska du delta (homer@example.com)?
+</span><wbr><strong><span itemprop=3D"potentialaction" itemscope=3D"" itemt=
+ype=3D"http://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/Y=
+es">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&rst=3D1&tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=3DEurope%2FHelsinki&=
+;hl=3Dsv&es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Ja</a></span></span><span style=3D"margin:0 0.4em;font-weight:normal">
+ - </span><span itemprop=3D"potentialaction" itemscope=3D"" itemtype=3D"htt=
+p://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/M=
+aybe">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&rst=3D3&tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=3DEurope%2FHelsinki&=
+;hl=3Dsv&es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Kanske</a></span></span><span style=3D"margin:0 0.4em;font-weight:normal=
+">
+ - </span><span itemprop=3D"potentialaction" itemscope=3D"" itemtype=3D"htt=
+p://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/N=
+o">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&rst=3D2&tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=3DEurope%2FHelsinki&=
+;hl=3Dsv&es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Nej</a></span></span></strong>
+<wbr><a href=3D"https://calendar.google.com/calendar/event?action=3DVIEW&am=
+p;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&=
+tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyN=
+WU0MDkzNDAxZjRhZg&ctz=3DEurope%2FHelsinki&hl=3Dsv&es=3D0" style=
+=3D"color:#20c;white-space:nowrap" itemprop=3D"url">fler
+ alternativ =C2=BB</a></p>
+</td>
+</tr>
+<tr>
+<td style=3D"background-color:#f6f6f6;color:#888;border-top:1px Solid #ccc;=
+font-family:Arial,Sans-serif;font-size:11px">
+<p>Inbjudan fr=C3=A5n <a href=3D"https://calendar.google.com/calendar/" tar=
+get=3D"_blank" style=3D"">
+Google Kalender</a></p>
+<p>Detta e-postmeddelande har skickats till kontot homer@example.com efte=
+rsom du =C3=A4r deltagare vid denna h=C3=A4ndelse.</p>
+<p>Om du inte vill f=C3=A5 uppdateringar om denna h=C3=A4ndelse i framtiden=
+ kan du tacka nej till denna h=C3=A4ndelse. Du kan =C3=A4ven registrera dig=
+ f=C3=B6r att f=C3=A5 ett Google-konto p=C3=A5 https://calendar.google.com/=
+calendar/ och kontrollera aviseringsinst=C3=A4llningarna f=C3=B6r hela kale=
+ndern.</p>
+<p>Om du vidarebefordrar den h=C3=A4r inbjudan kan det g=C3=B6ra det m=C3=
+=B6jligt f=C3=B6r alla mottagare att skicka ett svar till organisat=C3=B6re=
+n och l=C3=A4ggas till p=C3=A5 g=C3=A4stlistan, bjuda in andra oavsett dera=
+s egen inbjudningsstatus eller modifiera ditt OSA.
+<a href=3D"https://support.google.com/calendar/answer/37135#forwarding">L=
+=C3=A4s mer</a>.</p>
+</td>
+</tr>
+</tbody>
+</table>
+</div>
+</span></span>
+</body>
+</html>
+
+--0000000000008c6d6005be1c767c
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: quoted-printable
+
+BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20210322T093000Z
+DTEND:20210322T103000Z
+DTSTAMP:20210322T091220Z
+ORGANIZER;CN=3Dexample@gmail.com:mailto:example@gmail.com
+UID:65m17hsdolmotv3kvmrtg40ont@google.com
+ATTENDEE;CUTYPE=3DINDIVIDUAL;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DACCEPTED;RSV=
+P=3DTRUE
+ ;CN=3Dexample@gmail.com;X-NUM-GUESTS=3D0:mailto:example@gmail.com
+ATTENDEE;CUTYPE=3DINDIVIDUAL;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION=
+;RSVP=3D
+ TRUE;CN=3Dhomer@example.com;X-NUM-GUESTS=3D0:mailto:homer@example.com
+X-MICROSOFT-CDO-OWNERAPPTID:-410050292
+CREATED:20210322T091220Z
+DESCRIPTION:This is a test. <b>Bold</b>. <i>Italic</i>. \;<br><br>Will=
+=20
+ discuss address for email <\;foo@example.com>\; and http://example.com=
+?
+ foo=3Dbar.\n\n-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:=
+~:~
+ :~:~:~:~:~:~:~:~::~:~::-\n=C3=84ndra inte det h=C3=A4r avsnittet i beskriv=
+ningen.\n\n
+ Den h=C3=A4r h=C3=A4ndelsen har ett videosamtal.\nG=C3=A5 med: https://mee=
+t.google.com/pyb
+ -ndcu-hhc\n\nVisa din h=C3=A4ndelse p=C3=A5 https://calendar.google.com/ca=
+lendar/even
+ t?action=3DVIEW&eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGlu=
+QGh1d
+ C5maQ&tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYm=
+RmM
+ DAyNWU0MDkzNDAxZjRhZg&ctz=3DEurope%2FHelsinki&hl=3Dsv&es=3D1.\n-::~:~::~:~=
+:~:~:~:
+ ~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
+LAST-MODIFIED:20210322T091220Z
+LOCATION:
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Meeeeet me HTML
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+
+--0000000000008c6d6005be1c767c--
+
+--0000000000008c6d6205be1c767e
+Content-Type: application/ics; name="invite.ics"
+Content-Disposition: attachment; filename="invite.ics"
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSClBST0RJRDotLy9Hb29nbGUgSW5jLy9Hb29nbGUgQ2FsZW5kYXIgNzAu
+OTA1NC8vRU4KVkVSU0lPTjoyLjAKQ0FMU0NBTEU6R1JFR09SSUFOCk1FVEhPRDpSRVFVRVNUCkJF
+R0lOOlZFVkVOVApEVFNUQVJUOjIwMjEwMzIyVDA5MzAwMFoKRFRFTkQ6MjAyMTAzMjJUMTAzMDAw
+WgpEVFNUQU1QOjIwMjEwMzIyVDA5MTIyMFoKT1JHQU5JWkVSO0NOPWV4YW1wbGVAZ21haWwuY29t
+Om1haWx0bzpleGFtcGxlQGdtYWlsLmNvbQpVSUQ6NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnRA
+Z29vZ2xlLmNvbQpBVFRFTkRFRTtDVVRZUEU9SU5ESVZJRFVBTDtST0xFPVJFUS1QQVJUSUNJUEFO
+VDtQQVJUU1RBVD1BQ0NFUFRFRDtSU1ZQPVRSVUUKIDtDTj1leGFtcGxlQGdtYWlsLmNvbTtYLU5V
+TS1HVUVTVFM9MDptYWlsdG86ZXhhbXBsZUBnbWFpbC5jb20KQVRURU5ERUU7Q1VUWVBFPUlORElW
+SURVQUw7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMtQUNUSU9OO1JTVlA9CiBU
+UlVFO0NOPWhvbWVyQGV4YW1wbGUuY29tO1gtTlVNLUdVRVNUUz0wOm1haWx0bzpob21lckBleGFt
+cGxlLmNvbQpYLU1JQ1JPU09GVC1DRE8tT1dORVJBUFBUSUQ6LTQxMDA1MDI5MgpDUkVBVEVEOjIw
+MjEwMzIyVDA5MTIyMFoKREVTQ1JJUFRJT046VGhpcyBpcyBhIHRlc3QuIDxiPkJvbGQ8L2I+LiA8
+aT5JdGFsaWM8L2k+LiZuYnNwXDs8YnI+PGJyPldpbGwgCiBkaXNjdXNzIGFkZHJlc3MgZm9yIGVt
+YWlsICZsdFw7Zm9vQGV4YW1wbGUuY29tJmd0XDsgYW5kIGh0dHA6Ly9leGFtcGxlLmNvbT8KIGZv
+bz1iYXIuXG5cbi06On46fjo6fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+
+On46fjp+On46fjp+On46fgogOn46fjp+On46fjp+On46fjo6fjp+OjotXG7DhG5kcmEgaW50ZSBk
+ZXQgaMOkciBhdnNuaXR0ZXQgaSBiZXNrcml2bmluZ2VuLlxuXG4KIERlbiBow6RyIGjDpG5kZWxz
+ZW4gaGFyIGV0dCB2aWRlb3NhbXRhbC5cbkfDpSBtZWQ6IGh0dHBzOi8vbWVldC5nb29nbGUuY29t
+L3B5YgogLW5kY3UtaGhjXG5cblZpc2EgZGluIGjDpG5kZWxzZSBww6UgaHR0cHM6Ly9jYWxlbmRh
+ci5nb29nbGUuY29tL2NhbGVuZGFyL2V2ZW4KIHQ/YWN0aW9uPVZJRVcmZWlkPU5qVnRNVGRvYzJS
+dmJHMXZkSFl6YTNadGNuUm5OREJ2Ym5RZ2JXRm5iblZ6TG0xbGJHbHVRR2gxZAogQzVtYVEmdG9r
+PU1qRWpZbVZ5ZEdGMGFHVmliM1JBWjIxaGFXd3VZMjl0WlRnMk5HRmpZbU5qWVdFMU1qVmxaV0pt
+WTJVelltUm1NCiBEQXlOV1UwTURrek5EQXhaalJoWmcmY3R6PUV1cm9wZSUyRkhlbHNpbmtpJmhs
+PXN2JmVzPTEuXG4tOjp+On46On46fjp+On46fjoKIH46fjp+On46fjp+On46fjp+On46fjp+On46
+fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46On46fjo6LQpMQVNULU1PRElGSUVE
+OjIwMjEwMzIyVDA5MTIyMFoKTE9DQVRJT046ClNFUVVFTkNFOjAKU1RBVFVTOkNPTkZJUk1FRApT
+VU1NQVJZOk1lZWVlZXQgbWUgSFRNTApUUkFOU1A6T1BBUVVFCkVORDpWRVZFTlQKRU5EOlZDQUxF
+TkRBUgo=
+
+--0000000000008c6d6205be1c767e--
diff --git a/comm/calendar/test/browser/invitations/data/message-containing-event.eml b/comm/calendar/test/browser/invitations/data/message-containing-event.eml new file mode 100644 index 0000000000..d27c2976db --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/message-containing-event.eml @@ -0,0 +1,44 @@ +From: ExampleStore <noreply@example.com> +Date: Wed, 24 Aug 2016 16:40:06 -0400 +Subject: ExampleStore - booking 01.09.2016 @ 09.25 - 09.50 +Content-Type: multipart/mixed; + boundary="_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35" +To: <foo@example.com> +Message-ID: <df0f52ae-d3dc-4b89-bc3e-67fc4f6e8552@example.com> +MIME-Version: 1.0 + +--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35 +Content-Type: multipart/alternative; + boundary="_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35" + +--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35 +Content-Type: text/plain; charset="UTF-8" + +Remember your booking @ 09.25 + +--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35 +Content-Type: text/html; charset="UTF-8" + +<html> +<body> +<p>You have a booking for 9.25</p> +</body> +</html> + +--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35-- + +--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35 +Content-Type: TeXt/CaLeNdAr; method=PUBLISH; charset=UTF-8 +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="booking.ics" + +QkVHSU46VkNBTEVOREFSDQpWRVJTSU9OOjIuMA0KTUVUSE9EOlBVQkxJU0gNClBST0RJRDpleGFt +cGxlLmNvbQ0KQkVHSU46VkVWRU5UDQpEVFNUQVJUOjIwMTYwOTAxVDA2MjUwMFoNCkRURU5EOjIw +MTYwOTAxVDA2NTAwMFoNCkRUU1RBTVA6MjAxNjA4MjRUMjA0MDAwWg0KVUlEOjIwMTYwODI0VDIw +NDAwMFotNTY4ODYwMjgwQGV4YW1wbGUuY29tDQpTVU1NQVJZOkhhaXJjdXQNCk9SR0FOSVpFUjpt +YWlsdG86c29tZW9uZUBleGFtcGxlLmNvbQ0KREVTQ1JJUFRJT046SGFpcmN1dCtzdHlsaW5nDQpM +T0NBVElPTjpTb21ld2hlcmUNClRSQU5TUDpPUEFRVUUNClNFUVVFTkNFOjANCkNMQVNTOlBVQkxJ +Qw0KQkVHSU46VkFMQVJNDQpUUklHR0VSOi1QVDYwTQ0KQUNUSU9OOkFVRElPDQpERVNDUklQVElP +TjpSZW1pbmRlcg0KRU5EOlZBTEFSTQ0KRU5EOlZFVkVOVA0KRU5EOlZDQUxFTkRBUg== + +--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35-- diff --git a/comm/calendar/test/browser/invitations/data/message-non-invite.eml b/comm/calendar/test/browser/invitations/data/message-non-invite.eml new file mode 100644 index 0000000000..cf391f445a --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/message-non-invite.eml @@ -0,0 +1,115 @@ +Date: Sun, 28 Nov 2021 21:39:31 +0000 +From: Jane <noreply@example.com> +To: <john.doe@example.com> +Message-ID: <1074020157.32201638135571450.JavaMail.root@hki-example-prod-app-004> +Subject: We're having a party - you're NOT invited +Content-Type: multipart/mixed; + boundary="----=_Part_6440_2094089067.1638135571440" +MIME-Version: 1.0 + +------=_Part_6440_2094089067.1638135571440 +Content-Type: multipart/related; + boundary="----=_Part_6441_499243807.1638135571440" + +------=_Part_6441_499243807.1638135571440 +Content-Type: text/plain; charset="UTF-8" + +Hey, we're having a party! You're not invited ;) + +------=_Part_6441_499243807.1638135571440-- + +------=_Part_6440_2094089067.1638135571440 +Content-Type: text/calendar; charset="utf-8"; name="event.ics" +Content-Transfer-Encoding: quoted-printable +Content-Disposition: attachment; filename="event.ics" + +BEGIN:VCALENDAR +PRODID:-//EXAMPLE:COM//iCal4j 1.0.5.2//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VTIMEZONE +TZID:Europe/Helsinki +TZURL:http://tzurl.org/zoneinfo/Europe/Helsinki +X-LIC-LOCATION:Europe/Helsinki +BEGIN:DAYLIGHT +TZOFFSETFROM:+0200 +TZOFFSETTO:+0300 +TZNAME:EEST +DTSTART:19830327T030000 +RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0300 +TZOFFSETTO:+0200 +TZNAME:EET +DTSTART:19961027T040000 +RRULE:FREQ=3DYEARLY;BYMONTH=3D10;BYDAY=3D-1SU +END:STANDARD +BEGIN:STANDARD +TZOFFSETFROM:+013952 +TZOFFSETTO:+013952 +TZNAME:HMT +DTSTART:18780531T000000 +RDATE:18780531T000000 +END:STANDARD +BEGIN:STANDARD +TZOFFSETFROM:+013952 +TZOFFSETTO:+0200 +TZNAME:EET +DTSTART:19210501T000000 +RDATE:19210501T000000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETFROM:+0200 +TZOFFSETTO:+0300 +TZNAME:EEST +DTSTART:19420403T000000 +RDATE:19420403T000000 +RDATE:19810329T020000 +RDATE:19820328T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0300 +TZOFFSETTO:+0200 +TZNAME:EET +DTSTART:19421003T000000 +RDATE:19421003T000000 +RDATE:19810927T030000 +RDATE:19820926T030000 +RDATE:19830925T040000 +RDATE:19840930T040000 +RDATE:19850929T040000 +RDATE:19860928T040000 +RDATE:19870927T040000 +RDATE:19880925T040000 +RDATE:19890924T040000 +RDATE:19900930T040000 +RDATE:19910929T040000 +RDATE:19920927T040000 +RDATE:19930926T040000 +RDATE:19940925T040000 +RDATE:19950924T040000 +END:STANDARD +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0200 +TZNAME:EET +DTSTART:19830101T000000 +RDATE:19830101T000000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20211128T213931Z +DTSTART;TZID=3DEurope/Helsinki:20211129T105500 +DTEND;TZID=3DEurope/Helsinki:20211129T110000 +SUMMARY:Party at John's house\, Helsinki +ORGANIZER;CN=3DJANE:mailto:noreply@example.com +UID:1e5fd4e6-bc52-439c-ac76-40da54f57c77@secure.example.com +SEQUENCE:3 +STATUS:CONFIRMED +LAST-MODIFIED:20211128T213931Z +END:VEVENT +END:VCALENDAR + +------=_Part_6440_2094089067.1638135571440-- diff --git a/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml b/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml new file mode 100644 index 0000000000..de07a9b873 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml @@ -0,0 +1,102 @@ +From: Marge <marge@example.org>
+To: Homer <homer@example.org>
+Subject: Testaus
+Thread-Topic: Testaus
+Thread-Index: AdfdgAAehFDfsPyXTommZqRYgeMqiQAABo4Q
+Date: Fri, 19 Nov 2021 20:00:31 +0000
+Message-ID: <HE1P190MB0540579ABA18FCE320901B31E39C9@HE1P190MB0540.EURP190.PROD.OUTLOOK.COM>
+Accept-Language: en-US
+Content-Language: en-US
+Content-Type: multipart/alternative;
+ boundary="_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_"
+MIME-Version: 1.0
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+<html xmlns:v=3D"urn:schemas-microsoft-com:vml" xmlns:o=3D"urn:schemas-micr=
+osoft-com:office:office" xmlns:w=3D"urn:schemas-microsoft-com:office:word" =
+xmlns:m=3D"http://schemas.microsoft.com/office/2004/12/omml" xmlns=3D"http:=
+//www.w3.org/TR/REC-html40"><head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Diso-8859-=
+1">
+<meta name=3D"Generator" content=3D"Microsoft Word 15 (filtered medium)">
+<style><!--
+/* Font Definitions */
+@font-face
+ {font-family:"Cambria Math";
+ panose-1:2 4 5 3 5 4 6 3 2 4;}
+@font-face
+ {font-family:Calibri;
+ panose-1:2 15 5 2 2 2 4 3 2 4;}
+/* Style Definitions */
+p.MsoNormal, li.MsoNormal, div.MsoNormal
+ {margin:0cm;
+ font-size:11.0pt;
+ font-family:"Calibri",sans-serif;
+ mso-fareast-language:EN-US;}
+span.EmailStyle18
+ {mso-style-type:personal-compose;
+ font-family:"Calibri",sans-serif;
+ color:windowtext;}
+.MsoChpDefault
+ {mso-style-type:export-only;
+ font-size:10.0pt;}
+@page WordSection1
+ {size:612.0pt 792.0pt;
+ margin:70.85pt 2.0cm 70.85pt 2.0cm;}
+div.WordSection1
+ {page:WordSection1;}
+--></style><!--[if gte mso 9]><xml>
+<o:shapedefaults v:ext=3D"edit" spidmax=3D"1026" />
+</xml><![endif]--><!--[if gte mso 9]><xml>
+<o:shapelayout v:ext=3D"edit">
+<o:idmap v:ext=3D"edit" data=3D"1" />
+</o:shapelayout></xml><![endif]-->
+</head>
+<body lang=3D"FI" link=3D"#0563C1" vlink=3D"#954F72" style=3D"word-wrap:bre=
+ak-word">
+<div class=3D"WordSection1">
+<p class=3D"MsoNormal"><o:p> </o:p></p>
+</div>
+</body>
+</html>
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/calendar; charset="utf-8"; method=REQUEST
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSCk1FVEhPRDpSRVFVRVNUClBST0RJRDpNaWNyb3NvZnQgRXhjaGFuZ2Ug
+U2VydmVyIDIwMTAKVkVSU0lPTjoyLjAKQkVHSU46VlRJTUVaT05FClRaSUQ6RkxFIFN0YW5kYXJk
+IFRpbWUKQkVHSU46U1RBTkRBUkQKRFRTVEFSVDoxNjAxMDEwMVQwNDAwMDAKVFpPRkZTRVRGUk9N
+OiswMzAwClRaT0ZGU0VUVE86KzAyMDAKUlJVTEU6RlJFUT1ZRUFSTFk7SU5URVJWQUw9MTtCWURB
+WT0tMVNVO0JZTU9OVEg9MTAKRU5EOlNUQU5EQVJECkJFR0lOOkRBWUxJR0hUCkRUU1RBUlQ6MTYw
+MTAxMDFUMDMwMDAwClRaT0ZGU0VURlJPTTorMDIwMApUWk9GRlNFVFRPOiswMzAwClJSVUxFOkZS
+RVE9WUVBUkxZO0lOVEVSVkFMPTE7QllEQVk9LTFTVTtCWU1PTlRIPTMKRU5EOkRBWUxJR0hUCkVO
+RDpWVElNRVpPTkUKQkVHSU46VkVWRU5UCk9SR0FOSVpFUjtDTj1NYXJnZTptYWlsdG86bWFyZ2VA
+ZXhhbXBsZS5vcmcKQVRURU5ERUU7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMt
+QUNUSU9OO1JTVlA9VFJVRTtDTj1Ib20KIGVyOm1haWx0bzpob21lckBleGFtcGxlLm9yZwpERVND
+UklQVElPTjtMQU5HVUFHRT1lbi1VUzpcbgpVSUQ6MDMwMDAwMDA4MjAwRTAwMDc0QzVCNzEwMUE4
+MkUwMDgwMDAwMDAwMDYwQjYwOEQwOTBEREQ3MDEwMDAwMDAwMDAwMDAwMDAKIDAxMDAwMDAwMDRC
+RTBDRkZBNTRCQ0Y2NEU5NTZFMzQxNDMzNjJDM0MwClNVTU1BUlk7TEFOR1VBR0U9ZW4tVVM6VGVz
+dGF1cwpEVFNUQVJUO1RaSUQ9RkxFIFN0YW5kYXJkIFRpbWU6MjAyMTExMjdUMDkwMDAwCkRURU5E
+O1RaSUQ9RkxFIFN0YW5kYXJkIFRpbWU6MjAyMTExMjdUMDkzMDAwCkNMQVNTOlBVQkxJQwpQUklP
+UklUWTo1CkRUU1RBTVA6MjAyMTExMTlUMjAwMDI5WgpUUkFOU1A6T1BBUVVFClNUQVRVUzpDT05G
+SVJNRUQKU0VRVUVOQ0U6MApMT0NBVElPTjtMQU5HVUFHRT1lbi1VUzoKWC1NSUNST1NPRlQtQ0RP
+LUFQUFQtU0VRVUVOQ0U6MApYLU1JQ1JPU09GVC1DRE8tT1dORVJBUFBUSUQ6LTcyMDEyODAyNwpY
+LU1JQ1JPU09GVC1DRE8tQlVTWVNUQVRVUzpURU5UQVRJVkUKWC1NSUNST1NPRlQtQ0RPLUlOVEVO
+REVEU1RBVFVTOkJVU1kKWC1NSUNST1NPRlQtQ0RPLUFMTERBWUVWRU5UOkZBTFNFClgtTUlDUk9T
+T0ZULUNETy1JTVBPUlRBTkNFOjEKWC1NSUNST1NPRlQtQ0RPLUlOU1RUWVBFOjAKWC1NSUNST1NP
+RlQtRE9OT1RGT1JXQVJETUVFVElORzpGQUxTRQpYLU1JQ1JPU09GVC1ESVNBTExPVy1DT1VOVEVS
+OkZBTFNFClgtTUlDUk9TT0ZULUxPQ0FUSU9OUzpbXQpCRUdJTjpWQUxBUk0KREVTQ1JJUFRJT046
+UkVNSU5ERVIKVFJJR0dFUjtSRUxBVEVEPVNUQVJUOi1QVDE1TQpBQ1RJT046RElTUExBWQpFTkQ6
+VkFMQVJNCkVORDpWRVZFTlQKRU5EOlZDQUxFTkRBUgo=
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-event.eml b/comm/calendar/test/browser/invitations/data/repeat-event.eml new file mode 100644 index 0000000000..9247e6575b --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/repeat-event.eml @@ -0,0 +1,49 @@ +MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Event @ Daily from 2pm to 3pm 3 times (AST) (receiver@example.com)
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-update-major.eml b/comm/calendar/test/browser/invitations/data/repeat-update-major.eml new file mode 100644 index 0000000000..61fe9f5022 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/repeat-update-major.eml @@ -0,0 +1,49 @@ +MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Repeat Update Major
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T050000Z
+DTEND:20220316T053000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml b/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml new file mode 100644 index 0000000000..a6ad357553 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml @@ -0,0 +1,49 @@ +MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Update Minor
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220318T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Updated location
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Updated Event
+DESCRIPTION:Updated description.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Updated description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/single-event.eml b/comm/calendar/test/browser/invitations/data/single-event.eml new file mode 100644 index 0000000000..14418c2c79 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/single-event.eml @@ -0,0 +1,78 @@ +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582" +Date: Wed, 16 Mar 2022 15:16:02 -0400 +To: receiver@example.com +Subject: Invitation: Single Event @ Wed, Mar 16 2022 11:00 AST +From: Sender <sender@example.com> + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762582 +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Length: 227 +Content-Transfer-Encoding: binary +Content-Type: text/plain; charset="utf-8" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +Single Event + +When: + Wed, Mar 16 2022 + 11:00 - 12:00 AST +Where: + Somewhere + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Transfer-Encoding: quoted-printable +Content-Type: text/calendar; charset="utf-8"; method="REQUEST" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:REQUEST +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:02e79b96 +SEQUENCE:0 +DTSTAMP:20220316T191602Z +CREATED:20220316T191532Z +DTSTART:20220316T110000Z +DTEND:20220316T113000Z +DURATION:PT1H +PRIORITY:0 +SUMMARY:Single Event +DESCRIPTION:An event invitation. +LOCATION:Somewhere +STATUS:CONFIRMED +TRANSP:OPAQUE +CLASS:PUBLIC +ORGANIZER;CN=3DSender; + EMAIL=3Dsender@example.com:mailto:sender@example.com +ATTENDEE;CN=3DSender; + EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com +ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com +ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-P1D +DESCRIPTION:This is an event reminder +END:VALARM +END:VEVENT +END:VCALENDAR + +--_----------=_1647458162153312762583-- diff --git a/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml b/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml new file mode 100644 index 0000000000..777486ec87 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml @@ -0,0 +1,167 @@ +From: Marge <marge@example.com>
+To: bart@example.com, homer@example.com
+Subject: Teams meeting
+Thread-Topic: Teams meeting
+Thread-Index: AdbIy2RnFnEYrGmq80aB3RiaEcOS6w==
+Date: Wed, 2 Dec 2020 16:52:34 +0000
+Message-ID: <HE1PR0802MB228346BE1576FEAB8A7F32328EF30@HE1PR0802MB2283.eurprd08.prod.outlook.com>
+Accept-Language: fi-FI, en-US
+Content-Language: en-US
+X-MS-Has-Attach:
+X-MS-TNEF-Correlator:
+Content-Type: multipart/alternative;
+ boundary="_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_"
+X-Spam-Flag: No
+Return-Path: marge@example.com
+MIME-Version: 1.0
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+___________________________________________________________________________=
+_____
+Microsoft Teams -kokous
+Liity tietokoneella tai mobiilisovelluksella
+Liity kokoukseen napsauttamalla t=E4t=E4<https://teams.microsoft.com/l/meet=
+up-join/19%3ameeting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thr=
+ead.v2/0?context=3D%7b%33Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%2=
+2%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d>
+Lis=E4tietoja<https://aka.ms/JoinTeamsMeeting> | Kokousasetukset<https://te=
+ams.microsoft.com/meetingOptions/?organizerId=3D14464d09-ceb8-458c-a61c-717=
+f1e5c66c5&tenantId=3D2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=3D19_mee=
+ting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=
+=3D0&language=3Dfi-FI>
+___________________________________________________________________________=
+_____
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+<head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Diso-8859-=
+1">
+</head>
+<body>
+<div><br>
+<br>
+<br>
+<div style=3D"width:100%;height: 20px;"><span style=3D"white-space:nowrap;c=
+olor:#5F5F5F;opacity:.36;">________________________________________________=
+________________________________</span>
+</div>
+<div class=3D"me-email-text" style=3D"color:#252424;font-family:'Segoe UI',=
+'Helvetica Neue',Helvetica,Arial,sans-serif;">
+<div style=3D"margin-top: 24px; margin-bottom: 20px;"><span style=3D"font-s=
+ize: 24px; color:#252424">Microsoft Teams -kokous</span>
+</div>
+<div style=3D"margin-bottom: 20px;">
+<div style=3D"margin-top: 0px; margin-bottom: 0px; font-weight: bold"><span=
+ style=3D"font-size: 14px; color:#252424">Liity tietokoneella tai mobiiliso=
+velluksella</span>
+</div>
+<a class=3D"me-email-headline" style=3D"font-size: 14px;font-family:'Segoe =
+UI Semibold','Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif;text-de=
+coration: underline;color: #6264a7;" href=3D"https://teams.microsoft.com/l/=
+meetup-join/19%3ameeting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%4=
+0thread.v2/0?context=3D%7b%33Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a=
+3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d" target=
+=3D"_blank" rel=3D"noreferrer noopener">Liity
+ kokoukseen napsauttamalla t=E4t=E4</a> </div>
+<div style=3D"margin-bottom: 24px;margin-top: 20px;"><a class=3D"me-email-l=
+ink" style=3D"font-size: 14px;text-decoration: underline;color: #6264a7;fon=
+t-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif;" target=3D=
+"_blank" href=3D"https://aka.ms/JoinTeamsMeeting" rel=3D"noreferrer noopene=
+r">Lis=E4tietoja</a>
+ | <a class=3D"me-email-link" style=3D"font-size: 14px;text-decoration: und=
+erline;color: #6264a7;font-family:'Segoe UI','Helvetica Neue',Helvetica,Ari=
+al,sans-serif;" target=3D"_blank" href=3D"https://teams.microsoft.com/meeti=
+ngOptions/?organizerId=3D14464d09-ceb8-458c-a61c-717f1e5c66c5&tenantId=
+=3D2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=3D19_meeting_MGU5NmI2Z=
+GYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=3D0&lan=
+guage=3Dfi-FI" rel=3D"noreferrer noopener">
+Kokousasetukset</a> </div>
+</div>
+<div style=3D"font-size: 14px; margin-bottom: 4px;font-family:'Segoe UI','H=
+elvetica Neue',Helvetica,Arial,sans-serif;">
+</div>
+<div style=3D"font-size: 12px;"></div>
+<div style=3D"width:100%;height: 20px;"><span style=3D"white-space:nowrap;c=
+olor:#5F5F5F;opacity:.36;">________________________________________________=
+________________________________</span>
+</div>
+</div>
+</body>
+</html>
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/calendar; charset="utf-8"; method=REQUEST
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSCk1FVEhPRDpSRVFVRVNUClBST0RJRDpNaWNyb3NvZnQgRXhjaGFuZ2Ug
+U2VydmVyIDIwMTAKVkVSU0lPTjoyLjAKQkVHSU46VlRJTUVaT05FClRaSUQ6RkxFIFN0YW5kYXJk
+IFRpbWUKQkVHSU46U1RBTkRBUkQKRFRTVEFSVDoxNjAxMDEwMVQwNDAwMDAKVFpPRkZTRVRGUk9N
+OiswMzAwClRaT0ZGU0VUVE86KzAyMDAKUlJVTEU6RlJFUT1ZRUFSTFk7SU5URVJWQUw9MTtCWURB
+WT0tMVNVO0JZTU9OVEg9MTAKRU5EOlNUQU5EQVJECkJFR0lOOkRBWUxJR0hUCkRUU1RBUlQ6MTYw
+MTAxMDFUMDMwMDAwClRaT0ZGU0VURlJPTTorMDIwMApUWk9GRlNFVFRPOiswMzAwClJSVUxFOkZS
+RVE9WUVBUkxZO0lOVEVSVkFMPTE7QllEQVk9LTFTVTtCWU1PTlRIPTMKRU5EOkRBWUxJR0hUCkVO
+RDpWVElNRVpPTkUKQkVHSU46VkVWRU5UCk9SR0FOSVpFUjtDTj1NYXJnZTptYWlsdG86bWFyZ2VA
+ZXhhbXBsZS5jb20KQVRURU5ERUU7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMt
+QUNUSU9OO1JTVlA9VFJVRTtDTj1iYXJ0QGUKIGV4YW1wbGUuY29tOm1haWx0bzpiYXJ0QGV4YW1w
+bGUuY29tCkFUVEVOREVFO1JPTEU9UkVRLVBBUlRJQ0lQQU5UO1BBUlRTVEFUPU5FRURTLUFDVElP
+TjtSU1ZQPVRSVUU7Q049aG9tZXJAZXgKIGFtcGxlLmNvbTptYWlsdG86aG9tZXJAZXhhbXBsZS5j
+b20KCkRFU0NSSVBUSU9OO0xBTkdVQUdFPWVuLVVTOlxuXG5cbl9fX19fX19fX19fX19fX19fX19f
+X19fX19fX19fX19fX19fX19fX19fXwogX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19f
+X19fX19cbk1pY3Jvc29mdCBUZWFtcyAta29rb3VzXG5MaWl0eSB0aWUKIHRva29uZWVsbGEgdGFp
+IG1vYmlpbGlzb3ZlbGx1a3NlbGxhXG5MaWl0eSBrb2tvdWtzZWVuIG5hcHNhdXR0YW1hbGxhIHTD
+pHQKIMOkPGh0dHBzOi8vdGVhbXMubWljcm9zb2Z0LmNvbS9sL21lZXR1cC1qb2luLzE5JTNhbWVl
+dGluZ19NR1U1Tm1JMlpHWXRPV1ptCiBPQzAwWTJabUxXSmxPVEl0TmpVeE5qQTVZalV5WVRZeSU0
+MHRocmVhZC52Mi8wP2NvbnRleHQ9JTdiJTIyVGlkJTIyJTNhJTIyMgogZmQwYzFjNS0yOGUxLTQw
+YzQtOWYwZC1hMDM2M2NhODBhM2MlMjIlMmMlMjJPaWQlMjIlM2ElMjIxNDQ2NGQwOS1jZWI4LTQ1
+OGMKIC1hNjFjLTcxN2YxZTVjNjZjNSUyMiU3ZD5cbkxpc8OkdGlldG9qYTxodHRwczovL2FrYS5t
+cy9Kb2luVGVhbXNNZWV0aW5nPiB8CiAgS29rb3VzYXNldHVrc2V0PGh0dHBzOi8vdGVhbXMubWlj
+cm9zb2Z0LmNvbS9tZWV0aW5nT3B0aW9ucy8/b3JnYW5pemVySWQ9MQogNDQ2NGQwOS1jZWI4LTQ1
+OGMtYTYxYy03MTdmMWU1YzY2YzUmdGVuYW50SWQ9MmZkMGMxYzUtMjhlMS00MGM0LTlmMGQtYTAz
+NjMKIGNhODBhM2MmdGhyZWFkSWQ9MTlfbWVldGluZ19NR1U1Tm1JMlpHWXRPV1ptT0MwMFkyWm1M
+V0psT1RJdE5qVXhOakE1WWpVeVlUCiBZeUB0aHJlYWQudjImbWVzc2FnZUlkPTAmbGFuZ3VhZ2U9
+ZmktRkk+XG5fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fXwogX19fX19fX19fX19fX19f
+X19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fXG4KVUlEOjA1NjAwMDAwODIwMEUwMDA3
+NEM1QjcxMDFBODJFMDA4MDAwMDAwMDAxQUY4NEM2NENCQzhENjAxMDAwMDAwMDAwMDAwMDAwCiAw
+MTAwMDAwMDA0MDNCNUFDMTBBMEVCNDQ0QTk0N0QyQjQ5OUE0Qjk4QwpTVU1NQVJZO0xBTkdVQUdF
+PWVuLVVTOlRlYW1zIG1lZXRpbmcKRFRTVEFSVDtUWklEPUZMRSBTdGFuZGFyZCBUaW1lOjIwMjAx
+MjAyVDE5MDAwMApEVEVORDtUWklEPUZMRSBTdGFuZGFyZCBUaW1lOjIwMjAxMjAyVDIxMDAwMApD
+TEFTUzpQVUJMSUMKUFJJT1JJVFk6NQpEVFNUQU1QOjIwMjAxMjAyVDE2NTEzNFoKVFJBTlNQOk9Q
+QVFVRQpTVEFUVVM6Q09ORklSTUVEClNFUVVFTkNFOjAKTE9DQVRJT047TEFOR1VBR0U9ZW4tVVM6
+ClgtTUlDUk9TT0ZULUNETy1BUFBULVNFUVVFTkNFOjAKWC1NSUNST1NPRlQtQ0RPLU9XTkVSQVBQ
+VElEOjIxMTg5MTUzNTQKWC1NSUNST1NPRlQtQ0RPLUJVU1lTVEFUVVM6VEVOVEFUSVZFClgtTUlD
+Uk9TT0ZULUNETy1JTlRFTkRFRFNUQVRVUzpCVVNZClgtTUlDUk9TT0ZULUNETy1BTExEQVlFVkVO
+VDpGQUxTRQpYLU1JQ1JPU09GVC1DRE8tSU1QT1JUQU5DRToxClgtTUlDUk9TT0ZULUNETy1JTlNU
+VFlQRTowClgtTUlDUk9TT0ZULVNLWVBFVEVBTVNNRUVUSU5HVVJMOmh0dHBzOi8vdGVhbXMubWlj
+cm9zb2Z0LmNvbS9sL21lZXR1cC1qb2luLwogMTklM2FtZWV0aW5nX01HVTVObUkyWkdZdE9XWm1P
+QzAwWTJabUxXSmxPVEl0TmpVeE5qQTVZalV5WVRZeSU0MHRocmVhZC52Mi8KIDA/Y29udGV4dD0l
+N2IlMjJUaWQlMjIlM2ElMjIyZmQwYzFjNS0xOGUxLTQwYzQtOWYwZC1hMDM2M2NhODBhM2MlMjIl
+MmMlMjJPCiBpZCUyMiUzYSUyMjE0NDY0ZDA5LWNlYjgtNDU4Yy1hNjFjLTcxN2YxZTVjNjZjNSUy
+MiU3ZApYLU1JQ1JPU09GVC1TQ0hFRFVMSU5HU0VSVklDRVVQREFURVVSTDpodHRwczovL3NjaGVk
+dWxlci50ZWFtcy5taWNyb3NvZnQuY28KIG0vdGVhbXMvMmZkMGMxYzUtMjhlMS00MGM0LTlmMGQt
+YWIzNjNjYTgwYTNjLzE0NDY0ZDA5LWNlYjgtNDU4Yy1hNjFjLTcxN2YxCiBlNWM2NmM1LzE5X21l
+ZXRpbmdfTUdVNU5tSTJaR1l0T1dabU9DMDBZMlptTFdKbE9USXROalV4TmpBNVlqVXlZVFl5QHRo
+cmVhZAogLnYyLzAKWC1NSUNST1NPRlQtU0tZUEVURUFNU1BST1BFUlRJRVM6eyJjaWQiOiIxOTpt
+ZWV0aW5nX01HVTVObUkyWkdZdE9XWm1PQzAwWTJaCiBtTFdKbE9USXROalV4TmpBNVlqVXlZVFl5
+QHRocmVhZC52MiJcLCJyaWQiOjBcLCJtaWQiOjBcLCJ1aWQiOm51bGxcLCJwcml2YQogdGUiOnRy
+dWVcLCJ0eXBlIjowfQpYLU1JQ1JPU09GVC1PTkxJTkVNRUVUSU5HQ09ORkxJTks6Y29uZjpzaXA6
+bWFyZ2VAZXhhbXBsZS5jb21cO2dydXVcO29wCiBhcXVlPWFwcDpjb25mOmZvY3VzOmlkOnRlYW1z
+OjI6MCExOTptZWV0aW5nX01HVTVNbUkyWkdZdE9XWm1PQzAwWTJabUxXSmxPVAogSXROalV4TmpB
+NVlqVXlZVFl5LXRocmVhZC52MiExNDQ2NGQwOWNlYjg0NThjYTYxYzcxN2YxZTVjNjZjNSEyZmQw
+YzFjNTI4ZTEKIDQwYzQ5ZjBkYTAzNjNjYTgwYTNjClgtTUlDUk9TT0ZULU9OTElORU1FRVRJTkdJ
+TkZPUk1BVElPTjp7Ik9ubGluZU1lZXRpbmdDaGFubmVsSWQiOm51bGxcLCJPbmxpbgogZU1lZXRp
+bmdQcm92aWRlciI6M30KWC1NSUNST1NPRlQtRE9OT1RGT1JXQVJETUVFVElORzpGQUxTRQpYLU1J
+Q1JPU09GVC1ESVNBTExPVy1DT1VOVEVSOkZBTFNFClgtTUlDUk9TT0ZULUxPQ0FUSU9OUzpbXQpC
+RUdJTjpWQUxBUk0KREVTQ1JJUFRJT046UkVNSU5ERVIKVFJJR0dFUjtSRUxBVEVEPVNUQVJUOi1Q
+VDE1TQpBQ1RJT046RElTUExBWQpFTkQ6VkFMQVJNCkVORDpWRVZFTlQKRU5EOlZDQUxFTkRBUgo=
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_--
diff --git a/comm/calendar/test/browser/invitations/data/update-major.eml b/comm/calendar/test/browser/invitations/data/update-major.eml new file mode 100644 index 0000000000..04754798b2 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/update-major.eml @@ -0,0 +1,78 @@ +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582" +Date: Wed, 16 Mar 2022 15:16:02 -0400 +To: receiver@example.com +Subject: Update Major +From: Sender <sender@example.com> + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762582 +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Length: 227 +Content-Transfer-Encoding: binary +Content-Type: text/plain; charset="utf-8" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +Single Event + +When: + Wed, Mar 16 2022 + 11:00 - 12:00 AST +Where: + Somewhere + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Transfer-Encoding: quoted-printable +Content-Type: text/calendar; charset="utf-8"; method="REQUEST" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:REQUEST +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:02e79b96 +SEQUENCE:2 +DTSTAMP:20220316T191602Z +CREATED:20220316T191532Z +DTSTART:20220316T050000Z +DTEND:20220316T053000Z +DURATION:PT1H +PRIORITY:0 +SUMMARY:Single Event +DESCRIPTION:An event invitation. +LOCATION:Somewhere +STATUS:CONFIRMED +TRANSP:OPAQUE +CLASS:PUBLIC +ORGANIZER;CN=3DSender; + EMAIL=3Dsender@example.com:mailto:sender@example.com +ATTENDEE;CN=3DSender; + EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com +ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com +ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-P1D +DESCRIPTION:This is an event reminder +END:VALARM +END:VEVENT +END:VCALENDAR + +--_----------=_1647458162153312762583-- diff --git a/comm/calendar/test/browser/invitations/data/update-minor.eml b/comm/calendar/test/browser/invitations/data/update-minor.eml new file mode 100644 index 0000000000..afeb8e9ba0 --- /dev/null +++ b/comm/calendar/test/browser/invitations/data/update-minor.eml @@ -0,0 +1,78 @@ +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582" +Date: Wed, 16 Mar 2022 15:16:02 -0400 +To: receiver@example.com +Subject: Update Minor +From: Sender <sender@example.com> + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762582 +MIME-Version: 1.0 +Content-Transfer-Encoding: binary +Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +This is a multi-part message in MIME format. + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Length: 227 +Content-Transfer-Encoding: binary +Content-Type: text/plain; charset="utf-8" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +Single Event + +When: + Wed, Mar 16 2022 + 11:00 - 12:00 AST +Where: + Somewhere + +--_----------=_1647458162153312762583 +MIME-Version: 1.0 +Content-Disposition: inline +Content-Transfer-Encoding: quoted-printable +Content-Type: text/calendar; charset="utf-8"; method="REQUEST" +Date: Wed, 16 Mar 2022 15:16:02 -0400 + +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:REQUEST +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:02e79b96 +SEQUENCE:0 +DTSTAMP:20220318T191602Z +CREATED:20220316T191532Z +DTSTART:20220316T110000Z +DTEND:20220316T113000Z +DURATION:PT1H +PRIORITY:0 +SUMMARY:Updated Event +DESCRIPTION:Updated description. +LOCATION:Updated location +STATUS:CONFIRMED +TRANSP:OPAQUE +CLASS:PUBLIC +ORGANIZER;CN=3DSender; + EMAIL=3Dsender@example.com:mailto:sender@example.com +ATTENDEE;CN=3DSender; + EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com +ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com +ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL; + PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-P1D +DESCRIPTION:Updated description. +END:VALARM +END:VEVENT +END:VCALENDAR + +--_----------=_1647458162153312762583-- diff --git a/comm/calendar/test/browser/invitations/head.js b/comm/calendar/test/browser/invitations/head.js new file mode 100644 index 0000000000..24835c3021 --- /dev/null +++ b/comm/calendar/test/browser/invitations/head.js @@ -0,0 +1,942 @@ +/* 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/. */ + +/** + * Common functions for the imip-bar tests. + * + * Note that these tests are heavily tied to the .eml files found in the data + * folder. + */ + +"use strict"; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalItipDefaultEmailTransport } = ChromeUtils.import( + "resource:///modules/CalItipEmailTransport.jsm" +); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +registerCleanupFunction(async () => { + // Some tests that open new windows don't return focus to the main window + // in a way that satisfies mochitest, and the test times out. + Services.focus.focusedWindow = window; + document.body.focus(); +}); + +class EmailTransport extends CalItipDefaultEmailTransport { + sentItems = []; + + sentMsgs = []; + + getMsgSend() { + let { sentMsgs } = this; + return { + sendMessageFile( + userIdentity, + accountKey, + composeFields, + messageFile, + deleteSendFileOnCompletion, + digest, + deliverMode, + msgToReplace, + listener, + statusFeedback, + smtpPassword + ) { + sentMsgs.push({ + userIdentity, + accountKey, + composeFields, + messageFile, + deleteSendFileOnCompletion, + digest, + deliverMode, + msgToReplace, + listener, + statusFeedback, + smtpPassword, + }); + }, + }; + } + + sendItems(recipients, itipItem, fromAttendee) { + this.sentItems.push({ recipients, itipItem, fromAttendee }); + return super.sendItems(recipients, itipItem, fromAttendee); + } + + reset() { + this.sentItems = []; + this.sentMsgs = []; + } +} + +async function openMessageFromFile(file) { + let fileURL = Services.io + .newFileURI(file) + .mutate() + .setQuery("type=application/x-message-display") + .finalize(); + + let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + window.openDialog( + "chrome://messenger/content/messageWindow.xhtml", + "_blank", + "all,chrome,dialog=no,status,toolbar", + fileURL + ); + let win = await winPromise; + await BrowserTestUtils.waitForEvent(win, "MsgLoaded"); + await TestUtils.waitForCondition(() => Services.focus.activeWindow == win); + return win; +} + +/** + * Opens an iMIP message file and waits for the imip-bar to appear. + * + * @param {nsIFile} file + * @returns {Window} + */ +async function openImipMessage(file) { + let win = await openMessageFromFile(file); + let aboutMessage = win.document.getElementById("messageBrowser").contentWindow; + let imipBar = aboutMessage.document.getElementById("imip-bar"); + await TestUtils.waitForCondition(() => !imipBar.collapsed, "imip-bar shown"); + + if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) { + // CalInvitationDisplay.show() does some async activities before the panel is added. + await TestUtils.waitForCondition( + () => + win.document + .getElementById("messageBrowser") + .contentDocument.querySelector("calendar-invitation-panel"), + "calendar-invitation-panel shown" + ); + } + return win; +} + +/** + * Clicks on one of the imip-bar action buttons. + * + * @param {Window} win + * @param {string} id + */ +async function clickAction(win, id) { + let aboutMessage = win.document.getElementById("messageBrowser").contentWindow; + let action = aboutMessage.document.getElementById(id); + await TestUtils.waitForCondition(() => !action.hidden, `button "#${id}" shown`); + + EventUtils.synthesizeMouseAtCenter(action, {}, aboutMessage); + await TestUtils.waitForCondition(() => action.hidden, `button "#${id}" hidden`); +} + +/** + * Clicks on one of the imip-bar actions from a dropdown menu. + * + * @param {Window} win The window the imip message is opened in. + * @param {string} buttonId The id of the <toolbarbutton> containing the menu. + * @param {string} actionId The id of the menu item to click. + */ +async function clickMenuAction(win, buttonId, actionId) { + let aboutMessage = win.document.getElementById("messageBrowser").contentWindow; + let actionButton = aboutMessage.document.getElementById(buttonId); + await TestUtils.waitForCondition(() => !actionButton.hidden, `"${buttonId}" shown`); + + let actionMenu = actionButton.querySelector("menupopup"); + let menuShown = BrowserTestUtils.waitForEvent(actionMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(actionButton.querySelector("dropmarker"), {}, aboutMessage); + await menuShown; + actionMenu.activateItem(aboutMessage.document.getElementById(actionId)); + await TestUtils.waitForCondition(() => actionButton.hidden, `action menu "#${buttonId}" hidden`); +} + +const unpromotedProps = ["location", "description", "sequence", "x-moz-received-dtstamp"]; + +/** + * An object where the keys are paths/selectors and the values are the values + * we expect to encounter. + * + * @typedef {object} Comparable + */ + +/** + * Compares the paths specified in the expected object against the provided + * actual object. + * + * @param {object} actual This is expected to be a calIEvent or calIAttendee but + * can also be an array of both etc. + * @param {Comparable} expected + */ +function compareProperties(actual, expected, prefix = "") { + Assert.equal(typeof actual, "object", `${prefix || "provided value"} is an object`); + for (let [key, value] of Object.entries(expected)) { + if (key.includes(".")) { + let keys = key.split("."); + let head = keys[0]; + let tail = keys.slice(1).join("."); + compareProperties(actual[head], { [tail]: value }, [prefix, head].filter(k => k).join(".")); + continue; + } + + let path = [prefix, key].filter(k => k).join("."); + let actualValue = unpromotedProps.includes(key) ? actual.getProperty(key) : actual[key]; + Assert.equal(actualValue, value, `property "${path}" is "${value}"`); + } +} + +/** + * Compares the text contents of the selectors specified on the inviatation panel + * to the expected value for each. + * + * @param {ShadowRoot} root The invitation panel's ShadowRoot instance. + * @param {Comparable} expected + */ +function compareShownPanelValues(root, expected) { + for (let [key, value] of Object.entries(expected)) { + value = Array.isArray(value) ? value.join("") : value; + Assert.equal( + root.querySelector(key).textContent.trim(), + value, + `property "${key}" is "${value}"` + ); + } +} + +/** + * Clicks on one of the invitation panel action buttons. + * + * @param {Window} panel + * @param {string} id + * @param {boolean} sendResponse + */ +async function clickPanelAction(panel, id, sendResponse = true) { + let promise = BrowserTestUtils.promiseAlertDialogOpen(sendResponse ? "accept" : "cancel"); + let button = panel.shadowRoot.getElementById(id); + EventUtils.synthesizeMouseAtCenter(button, {}, panel.ownerGlobal); + await promise; + await BrowserTestUtils.waitForEvent(panel.ownerGlobal, "onItipItemActionFinished"); +} + +/** + * Tests that an attempt to reply to the organizer of the event with the correct + * details occurred. + * + * @param {EmailTransport} transport + * @param {nsIdentity} identity + * @param {string} partStat + */ +async function doReplyTest(transport, identity, partStat) { + info("Verifying the attempt to send a response uses the correct data"); + Assert.equal(transport.sentItems.length, 1, "itip subsystem attempted to send a response"); + compareProperties(transport.sentItems[0], { + "recipients.0.id": "mailto:sender@example.com", + "itipItem.responseMethod": "REPLY", + "fromAttendee.id": "mailto:receiver@example.com", + "fromAttendee.participationStatus": partStat, + }); + + // The itipItem is used to generate the iTIP data in the message body. + info("Verifying the reply calItipItem attendee list"); + let replyItem = transport.sentItems[0].itipItem.getItemList()[0]; + let replyAttendees = replyItem.getAttendees(); + Assert.equal(replyAttendees.length, 1, "reply has one attendee"); + compareProperties(replyAttendees[0], { + id: "mailto:receiver@example.com", + participationStatus: partStat, + }); + + info("Verifying the call to the message subsystem"); + Assert.equal(transport.sentMsgs.length, 1, "transport sent 1 message"); + compareProperties(transport.sentMsgs[0], { + userIdentity: identity, + "composeFields.from": "receiver@example.com", + "composeFields.to": "Sender <sender@example.com>", + }); + Assert.ok(transport.sentMsgs[0].messageFile.exists(), "message file was created"); +} + +/** + * @typedef {object} ImipBarActionTestConf + * + * @property {calICalendar} calendar The calendar used for the test. + * @property {calIItipTranport} transport The transport used for the test. + * @property {nsIIdentity} identity The identity expected to be used to + * send the reply. + * @property {boolean} isRecurring Indicates whether to treat the event as a + * recurring event or not. + * @property {string} partStat The participationStatus of the receiving user to + * expect. + * @property {boolean} noReply If true, do not expect an attempt to send a reply. + * @property {boolean} noSend If true, expect the reply attempt to stop after the + * user is prompted. + * @property {boolean} isMajor For update tests indicates if the changes expected + * are major or minor. + */ + +/** + * Test the properties of an event created from the imip-bar and optionally, the + * attempt to send a reply. + * + * @param {ImipBarActionTestConf} conf + * @param {calIEvent|calIEvent[]} item + */ +async function doImipBarActionTest(conf, event) { + let { calendar, transport, identity, partStat, isRecurring, noReply, noSend } = conf; + let events = [event]; + let startDates = ["20220316T110000Z"]; + let endDates = ["20220316T113000Z"]; + + if (isRecurring) { + startDates = [...startDates, "20220317T110000Z", "20220318T110000Z"]; + endDates = [...endDates, "20220317T113000Z", "20220318T113000Z"]; + events = event.parentItem.recurrenceInfo.getOccurrences( + cal.createDateTime("19700101"), + cal.createDateTime("30000101"), + Infinity + ); + Assert.equal(events.length, 3, "reccurring event has 3 occurrences"); + } + + info("Verifying relevant properties of each event occurrence"); + for (let [index, occurrence] of events.entries()) { + compareProperties(occurrence, { + id: "02e79b96", + title: isRecurring ? "Repeat Event" : "Single Event", + "calendar.name": calendar.name, + ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}), + "startDate.icalString": startDates[index], + "endDate.icalString": endDates[index], + description: "An event invitation.", + location: "Somewhere", + sequence: "0", + "x-moz-received-dtstamp": "20220316T191602Z", + "organizer.id": "mailto:sender@example.com", + status: "CONFIRMED", + }); + + // Alarms should be ignored. + Assert.equal( + occurrence.getAlarms().length, + 0, + `${isRecurring ? "occurrence" : "event"} has no reminders` + ); + + info("Verifying attendee list and participation status"); + let attendees = occurrence.getAttendees(); + compareProperties(attendees, { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.participationStatus": partStat, + "1.id": "mailto:receiver@example.com", + "2.id": "mailto:other@example.com", + "2.participationStatus": "NEEDS-ACTION", + }); + } + + if (noReply) { + Assert.equal( + transport.sentItems.length, + 0, + "itip subsystem did not attempt to send a response" + ); + } + if (noReply || noSend) { + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); + return; + } + await doReplyTest(transport, identity, partStat); +} + +/** + * Tests the recognition and application of a minor update to an existing event. + * An update is considered minor if the SEQUENCE property has not changed but + * the DTSTAMP has. + * + * @param {ImipBarActionTestConf} conf + */ +async function doMinorUpdateTest(conf) { + let { transport, calendar, partStat, isRecurring } = conf; + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + let prevEventIcs = event.icalString; + + transport.reset(); + + let updatePath = isRecurring ? "data/repeat-update-minor.eml" : "data/update-minor.eml"; + let win = await openImipMessage(new FileUtils.File(getTestFilePath(updatePath))); + let aboutMessage = win.document.getElementById("messageBrowser").contentWindow; + let updateButton = aboutMessage.document.getElementById("imipUpdateButton"); + Assert.ok(!updateButton.hidden, `#${updateButton.id} button shown`); + EventUtils.synthesizeMouseAtCenter(updateButton, {}, aboutMessage); + + await TestUtils.waitForCondition(async () => { + event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + return event.icalString != prevEventIcs; + }, "event updated"); + + await BrowserTestUtils.closeWindow(win); + + let events = [event]; + let startDates = ["20220316T110000Z"]; + let endDates = ["20220316T113000Z"]; + if (isRecurring) { + startDates = [...startDates, "20220317T110000Z", "20220318T110000Z"]; + endDates = [...endDates, "20220317T113000Z", "20220318T113000Z"]; + events = event.recurrenceInfo.getOccurrences( + cal.createDateTime("19700101"), + cal.createDateTime("30000101"), + Infinity + ); + Assert.equal(events.length, 3, "reccurring event has 3 occurrences"); + } + + info("Verifying relevant properties of each event occurrence"); + for (let [index, occurrence] of events.entries()) { + compareProperties(occurrence, { + id: "02e79b96", + title: "Updated Event", + "calendar.name": calendar.name, + ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}), + "startDate.icalString": startDates[index], + "endDate.icalString": endDates[index], + description: "Updated description.", + location: "Updated location", + sequence: "0", + "x-moz-received-dtstamp": "20220318T191602Z", + "organizer.id": "mailto:sender@example.com", + status: "CONFIRMED", + }); + + // Note: It seems we do not keep the order of the attendees list for updates. + let attendees = occurrence.getAttendees(); + compareProperties(attendees, { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.id": "mailto:other@example.com", + "1.participationStatus": "NEEDS-ACTION", + "2.participationStatus": partStat, + "2.id": "mailto:receiver@example.com", + }); + } + + Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response"); + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); + await calendar.deleteItem(event); +} + +const actionIds = { + single: { + button: { + ACCEPTED: "imipAcceptButton", + TENTATIVE: "imipTentativeButton", + DECLINED: "imipDeclineButton", + }, + noReply: { + ACCEPTED: "imipAcceptButton_AcceptDontSend", + TENTATIVE: "imipTentativeButton_TentativeDontSend", + DECLINED: "imipDeclineButton_DeclineDontSend", + }, + }, + recurring: { + button: { + ACCEPTED: "imipAcceptRecurrencesButton", + TENTATIVE: "imipTentativeRecurrencesButton", + DECLINED: "imipDeclineRecurrencesButton", + }, + noReply: { + ACCEPTED: "imipAcceptRecurrencesButton_AcceptDontSend", + TENTATIVE: "imipTentativeRecurrencesButton_TentativeDontSend", + DECLINED: "imipDeclineRecurrencesButton_DeclineDontSend", + }, + }, +}; + +/** + * Tests the recognition and application of a major update to an existing event. + * An update is considered major if the SEQUENCE property has changed. For major + * updates, the imip-bar prompts the user to re-confirm their attendance. + * + * @param {ImipBarActionTestConf} conf + */ +async function doMajorUpdateTest(conf) { + let { transport, identity, calendar, partStat, isRecurring, noReply } = conf; + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + let prevEventIcs = event.icalString; + + transport.reset(); + + let updatePath = isRecurring ? "data/repeat-update-major.eml" : "data/update-major.eml"; + let win = await openImipMessage(new FileUtils.File(getTestFilePath(updatePath))); + let actions = isRecurring ? actionIds.recurring : actionIds.single; + if (noReply) { + let { button, noReply } = actions; + await clickMenuAction(win, button[partStat], noReply[partStat]); + } else { + await clickAction(win, actions.button[partStat]); + } + + await TestUtils.waitForCondition(async () => { + event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + return event.icalString != prevEventIcs; + }, "event updated"); + + await BrowserTestUtils.closeWindow(win); + + if (noReply) { + Assert.equal( + transport.sentItems.length, + 0, + "itip subsystem did not attempt to send a response" + ); + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); + } else { + await doReplyTest(transport, identity, partStat); + } + + let events = [event]; + let startDates = ["20220316T050000Z"]; + let endDates = ["20220316T053000Z"]; + if (isRecurring) { + startDates = [...startDates, "20220317T050000Z", "20220318T050000Z"]; + endDates = [...endDates, "20220317T053000Z", "20220318T053000Z"]; + events = event.recurrenceInfo.getOccurrences( + cal.createDateTime("19700101"), + cal.createDateTime("30000101"), + Infinity + ); + Assert.equal(events.length, 3, "reccurring event has 3 occurrences"); + } + + for (let [index, occurrence] of events.entries()) { + compareProperties(occurrence, { + id: "02e79b96", + title: isRecurring ? "Repeat Event" : "Single Event", + "calendar.name": calendar.name, + ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}), + "startDate.icalString": startDates[index], + "endDate.icalString": endDates[index], + description: "An event invitation.", + location: "Somewhere", + sequence: "2", + "x-moz-received-dtstamp": "20220316T191602Z", + "organizer.id": "mailto:sender@example.com", + status: "CONFIRMED", + }); + + let attendees = occurrence.getAttendees(); + compareProperties(attendees, { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.id": "mailto:other@example.com", + "1.participationStatus": "NEEDS-ACTION", + "2.participationStatus": partStat, + "2.id": "mailto:receiver@example.com", + }); + } + await calendar.deleteItem(event); +} + +/** + * Tests the recognition and application of a minor update exception to an + * existing recurring event. + * + * @param {ImipBarActionTestConf} conf + */ +async function doMinorExceptionTest(conf) { + let { transport, calendar, partStat } = conf; + let recurrenceId = cal.createDateTime("20220317T110000Z"); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + let originalProps = { + id: "02e79b96", + "recurrenceId.icalString": "20220317T110000Z", + title: event.title, + "calendar.name": calendar.name, + "startDate.icalString": event.startDate.icalString, + "endDate.icalString": event.endDate.icalString, + description: event.getProperty("DESCRIPTION"), + location: event.getProperty("LOCATION"), + sequence: "0", + "x-moz-received-dtstamp": event.getProperty("x-moz-received-dtstamp"), + "organizer.id": "mailto:sender@example.com", + status: "CONFIRMED", + }; + + Assert.ok( + !event.recurrenceInfo.getExceptionFor(recurrenceId), + `no exception exists for ${recurrenceId}` + ); + + transport.reset(); + + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml"))); + let aboutMessage = win.document.getElementById("messageBrowser").contentWindow; + let updateButton = aboutMessage.document.getElementById("imipUpdateButton"); + Assert.ok(!updateButton.hidden, `#${updateButton.id} button shown`); + EventUtils.synthesizeMouseAtCenter(updateButton, {}, aboutMessage); + + let exception; + await TestUtils.waitForCondition(async () => { + event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + exception = event.recurrenceInfo.getExceptionFor(recurrenceId); + return exception; + }, "event exception applied"); + + await BrowserTestUtils.closeWindow(win); + + Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response"); + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); + + info("Verifying relevant properties of the exception"); + compareProperties(exception, { + id: "02e79b96", + "recurrenceId.icalString": "20220317T110000Z", + title: "Exception title", + "calendar.name": calendar.name, + "startDate.icalString": "20220317T110000Z", + "endDate.icalString": "20220317T113000Z", + description: "Exception description", + location: "Exception location", + sequence: "0", + "x-moz-received-dtstamp": "20220318T191602Z", + "organizer.id": "mailto:sender@example.com", + status: "CONFIRMED", + }); + + compareProperties(exception.getAttendees(), { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.id": "mailto:other@example.com", + "1.participationStatus": "NEEDS-ACTION", + "2.id": "mailto:receiver@example.com", + "2.participationStatus": partStat, + }); + + let occurrences = event.recurrenceInfo.getOccurrences( + cal.createDateTime("19700101"), + cal.createDateTime("30000101"), + Infinity + ); + Assert.equal(occurrences.length, 3, "reccurring event still has 3 occurrences"); + + info("Verifying relevant properties of the other occurrences"); + + let startDates = ["20220316T110000Z", "20220317T110000Z", "20220318T110000Z"]; + let endDates = ["20220316T113000Z", "20220317T113000Z", "20220318T113000Z"]; + for (let [index, occurrence] of occurrences.entries()) { + if (occurrence.startDate.compare(recurrenceId) == 0) { + continue; + } + compareProperties(occurrence, { + ...originalProps, + "recurrenceId.icalString": startDates[index], + "startDate.icalString": startDates[index], + "endDate.icalString": endDates[index], + }); + + let attendees = occurrence.getAttendees(); + compareProperties(attendees, { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.id": "mailto:receiver@example.com", + "1.participationStatus": partStat, + "2.id": "mailto:other@example.com", + "2.participationStatus": "NEEDS-ACTION", + }); + } + + await calendar.deleteItem(event); +} + +/** + * Tests the recognition and application of a major update exception to an + * existing recurring event. + * + * @param {ImipBarActionTestConf} conf + */ +async function doMajorExceptionTest(conf) { + let { transport, identity, calendar, partStat, noReply } = conf; + let recurrenceId = cal.createDateTime("20220317T110000Z"); + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + let originalProps = { + id: "02e79b96", + "recurrenceId.icalString": "20220317T110000Z", + title: event.title, + "calendar.name": calendar.name, + "startDate.icalString": event.startDate.icalString, + "endDate.icalString": event.endDate.icalString, + description: event.getProperty("DESCRIPTION"), + location: event.getProperty("LOCATION"), + sequence: "0", + "x-moz-received-dtstamp": event.getProperty("x-moz-received-dtstamp"), + "organizer.id": "mailto:sender@example.com", + status: "CONFIRMED", + }; + let originalPartStat = event + .getAttendees() + .find(att => att.id == "mailto:receiver@example.com").participationStatus; + + Assert.ok( + !event.recurrenceInfo.getExceptionFor(recurrenceId), + `no exception exists for ${recurrenceId}` + ); + + transport.reset(); + + let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml"))); + if (noReply) { + let { button, noReply } = actionIds.single; + await clickMenuAction(win, button[partStat], noReply[partStat]); + } else { + await clickAction(win, actionIds.single.button[partStat]); + } + + let exception; + await TestUtils.waitForCondition(async () => { + event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + exception = event.recurrenceInfo.getExceptionFor(recurrenceId); + return exception; + }, "event exception applied"); + + await BrowserTestUtils.closeWindow(win); + + if (noReply) { + Assert.equal( + transport.sentItems.length, + 0, + "itip subsystem did not attempt to send a response" + ); + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); + } else { + await doReplyTest(transport, identity, partStat); + } + + info("Verifying relevant properties of the exception"); + + compareProperties(exception, { + ...originalProps, + "startDate.icalString": "20220317T050000Z", + "endDate.icalString": "20220317T053000Z", + sequence: "2", + }); + + compareProperties(exception.getAttendees(), { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.id": "mailto:other@example.com", + "1.participationStatus": "NEEDS-ACTION", + "2.id": "mailto:receiver@example.com", + "2.participationStatus": partStat, + }); + + let occurrences = event.recurrenceInfo.getOccurrences( + cal.createDateTime("19700101"), + cal.createDateTime("30000101"), + Infinity + ); + Assert.equal(occurrences.length, 3, "reccurring event still has 3 occurrences"); + + info("Verifying relevant properties of the other occurrences"); + + let startDates = ["20220316T110000Z", "20220317T110000Z", "20220318T110000Z"]; + let endDates = ["20220316T113000Z", "20220317T113000Z", "20220318T113000Z"]; + for (let [index, occurrence] of occurrences.entries()) { + if (occurrence.startDate.icalString == "20220317T050000Z") { + continue; + } + compareProperties(occurrence, { + ...originalProps, + "recurrenceId.icalString": startDates[index], + "startDate.icalString": startDates[index], + "endDate.icalString": endDates[index], + }); + + let attendees = occurrence.getAttendees(); + compareProperties(attendees, { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.id": "mailto:receiver@example.com", + "1.participationStatus": originalPartStat, + "2.id": "mailto:other@example.com", + "2.participationStatus": "NEEDS-ACTION", + }); + } + + await calendar.deleteItem(event); +} + +/** + * Test the properties of an event created from a minor or major exception where + * we have not added the original event and optionally, the attempt to send a + * reply. + * + * @param {ImipBarActionTestConf} conf + */ +async function doExceptionOnlyTest(conf) { + let { calendar, transport, identity, partStat, noReply, isMajor } = conf; + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item; + + // Exceptions are still created as recurring events. + Assert.ok(event != event.parentItem, "event created is a recurring event"); + let occurrences = event.parentItem.recurrenceInfo.getOccurrences( + cal.createDateTime("10000101"), + cal.createDateTime("30000101"), + Infinity + ); + Assert.equal(occurrences.length, 1, "parent item only has one occurrence"); + Assert.ok(occurrences[0] == event, "occurrence is the event exception"); + + info("Verifying relevant properties of the event"); + compareProperties(event, { + id: "02e79b96", + title: isMajor ? event.title : "Exception title", + "calendar.name": calendar.name, + "recurrenceId.icalString": "20220317T110000Z", + "startDate.icalString": isMajor ? "20220317T050000Z" : "20220317T110000Z", + "endDate.icalString": isMajor ? "20220317T053000Z" : "20220317T113000Z", + description: isMajor ? event.getProperty("DESCRIPTION") : "Exception description", + location: isMajor ? event.getProperty("LOCATION") : "Exception location", + sequence: isMajor ? "2" : "0", + "x-moz-received-dtstamp": isMajor + ? event.getProperty("x-moz-received-dtstamp") + : "20220318T191602Z", + "organizer.id": "mailto:sender@example.com", + status: "CONFIRMED", + }); + + // Alarms should be ignored. + Assert.equal(event.getAlarms().length, 0, "event has no reminders"); + + info("Verifying attendee list and participation status"); + let attendees = event.getAttendees(); + compareProperties(attendees, { + "0.id": "mailto:sender@example.com", + "0.participationStatus": "ACCEPTED", + "1.participationStatus": partStat, + "1.id": "mailto:receiver@example.com", + "2.id": "mailto:other@example.com", + "2.participationStatus": "NEEDS-ACTION", + }); + + if (noReply) { + Assert.equal( + transport.sentItems.length, + 0, + "itip subsystem did not attempt to send a response" + ); + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); + } else { + await doReplyTest(transport, identity, partStat); + } + await calendar.deleteItem(event.parentItem); +} + +/** + * Tests the recognition and application of a cancellation to an existing event. + * + * @param {ImipBarActionTestConf} conf + */ +async function doCancelTest({ transport, calendar, isRecurring, event, recurrenceId }) { + transport.reset(); + + let eventId = event.id; + if (isRecurring) { + // wait for the other occurrences to appear. + await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1); + await CalendarTestUtils.monthView.waitForItemAt(window, 3, 6, 1); + } + + let cancellationPath = isRecurring + ? "data/cancel-repeat-event.eml" + : "data/cancel-single-event.eml"; + + let cancelMsgFile = new FileUtils.File(getTestFilePath(cancellationPath)); + if (recurrenceId) { + let srcTxt = await IOUtils.readUTF8(cancelMsgFile.path); + srcTxt = srcTxt.replaceAll(/RRULE:.+/g, `RECURRENCE-ID:${recurrenceId}`); + srcTxt = srcTxt.replaceAll(/SEQUENCE:.+/g, "SEQUENCE:3"); + cancelMsgFile = FileTestUtils.getTempFile("cancel-occurrence.eml"); + await IOUtils.writeUTF8(cancelMsgFile.path, srcTxt); + } + + let win = await openImipMessage(cancelMsgFile); + let aboutMessage = win.document.getElementById("messageBrowser").contentWindow; + let deleteButton = aboutMessage.document.getElementById("imipDeleteButton"); + Assert.ok(!deleteButton.hidden, `#${deleteButton.id} button shown`); + EventUtils.synthesizeMouseAtCenter(deleteButton, {}, aboutMessage); + + if (isRecurring && recurrenceId) { + // Expects a single occurrence to be cancelled. + + let occurrences; + await TestUtils.waitForCondition(async () => { + let { parentItem } = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item; + occurrences = parentItem.recurrenceInfo.getOccurrences( + cal.createDateTime("19700101"), + cal.createDateTime("30000101"), + Infinity + ); + return occurrences.length == 2; + }, "occurrence was deleted"); + + Assert.ok( + occurrences.every(occ => occ.recurrenceId && occ.recurrenceId != recurrenceId), + `occurrence "${recurrenceId}" removed` + ); + Assert.ok(!!(await calendar.getItem(eventId)), "event was not deleted"); + } else { + await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 4, 1); + + if (isRecurring) { + await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 5, 1); + await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 6, 1); + } + + await TestUtils.waitForCondition(async () => { + let result = await calendar.getItem(eventId); + return !result; + }, "event was deleted"); + } + + await BrowserTestUtils.closeWindow(win); + Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response"); + Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem"); +} + +/** + * Tests processing of cancellations to exceptions to recurring events. + * + * @param {ImipBarActionTestConf} conf + */ +async function doCancelExceptionTest(conf) { + let { partStat, recurrenceId, calendar } = conf; + let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml")); + let win = await openImipMessage(invite); + await clickAction(win, actionIds.recurring.button[partStat]); + + let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + await BrowserTestUtils.closeWindow(win); + + let update = new FileUtils.File(getTestFilePath("data/exception-major.eml")); + let updateWin = await openImipMessage(update); + await clickAction(updateWin, actionIds.single.button[partStat]); + + let exception; + await TestUtils.waitForCondition(async () => { + event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem; + exception = event.recurrenceInfo.getExceptionFor(cal.createDateTime(recurrenceId)); + return !!exception; + }, "exception applied"); + + await BrowserTestUtils.closeWindow(updateWin); + await doCancelTest({ ...conf, event }); + await calendar.deleteItem(event); +} diff --git a/comm/calendar/test/browser/preferences/browser.ini b/comm/calendar/test/browser/preferences/browser.ini new file mode 100644 index 0000000000..a06213b220 --- /dev/null +++ b/comm/calendar/test/browser/preferences/browser.ini @@ -0,0 +1,16 @@ +[default] +head = head.js +prefs = + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird + +[browser_alarmDefaultValue.js] +[browser_categoryColors.js] diff --git a/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js b/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js new file mode 100644 index 0000000000..b1cde7cf11 --- /dev/null +++ b/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js @@ -0,0 +1,176 @@ +/* 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/. */ + +/** + * Test default alarm settings for events and tasks + */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); +var { cancelItemDialog } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +const DEFVALUE = 43; + +add_task(async function testDefaultAlarms() { + let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory"); + calendar.setProperty("calendar-main-default", true); + registerCleanupFunction(async () => { + CalendarTestUtils.removeCalendar(calendar); + }); + + let localeUnitString = cal.l10n.getCalString("unitDays"); + let unitString = PluralForm.get(DEFVALUE, localeUnitString).replace("#1", DEFVALUE); + let alarmString = (...args) => cal.l10n.getString("calendar-alarms", ...args); + let originStringEvent = alarmString("reminderCustomOriginBeginBeforeEvent"); + let originStringTask = alarmString("reminderCustomOriginBeginBeforeTask"); + let expectedEventReminder = alarmString("reminderCustomTitle", [unitString, originStringEvent]); + let expectedTaskReminder = alarmString("reminderCustomTitle", [unitString, originStringTask]); + + // Configure the preferences. + let { prefsWindow, prefsDocument } = await openNewPrefsTab("paneCalendar", "defaultsnoozelength"); + await handlePrefTab(prefsWindow, prefsDocument); + + // Create New Event. + await CalendarTestUtils.openCalendarTab(window); + + let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(window); + + Assert.equal(iframeDocument.querySelector(".item-alarm").value, "custom"); + let reminderDetails = iframeDocument.querySelector(".reminder-single-alarms-label"); + Assert.equal(reminderDetails.value, expectedEventReminder); + + let reminderDialogPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-event-dialog-reminder.xhtml", + { callback: handleReminderDialog } + ); + EventUtils.synthesizeMouseAtCenter(reminderDetails, {}, iframeWindow); + await reminderDialogPromise; + + let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1"); + cancelItemDialog(dialogWindow); + await promptPromise; + + // Create New Task. + await openTasksTab(); + ({ dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewTask(window)); + + Assert.equal(iframeDocument.querySelector(".item-alarm").value, "custom"); + reminderDetails = iframeDocument.querySelector(".reminder-single-alarms-label"); + Assert.equal(reminderDetails.value, expectedTaskReminder); + + reminderDialogPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://calendar/content/calendar-event-dialog-reminder.xhtml", + { callback: handleReminderDialog } + ); + EventUtils.synthesizeMouseAtCenter(reminderDetails, {}, iframeWindow); + await reminderDialogPromise; + + promptPromise = BrowserTestUtils.promiseAlertDialog("extra1"); + cancelItemDialog(dialogWindow); + await promptPromise; +}); + +async function handlePrefTab(prefsWindow, prefsDocument) { + function menuList(id, value) { + let list = prefsDocument.getElementById(id); + list.scrollIntoView(); + list.click(); + list.querySelector(`menuitem[value="${value}"]`).click(); + } + // Turn on alarms for events and tasks. + menuList("eventdefalarm", "1"); + menuList("tododefalarm", "1"); + + // Selects "days" as a unit. + menuList("tododefalarmunit", "days"); + menuList("eventdefalarmunit", "days"); + + function text(id, value) { + let input = prefsDocument.getElementById(id); + input.scrollIntoView(); + EventUtils.synthesizeMouse(input, 5, 5, {}, prefsWindow); + Assert.equal(prefsDocument.activeElement, input); + EventUtils.synthesizeKey("a", { accelKey: true }, prefsWindow); + EventUtils.sendString(value, prefsWindow); + } + // Sets default alarm length for events to DEFVALUE. + text("eventdefalarmlen", DEFVALUE.toString()); + text("tododefalarmlen", DEFVALUE.toString()); + + Assert.equal(Services.prefs.getIntPref("calendar.alarms.onforevents"), 1); + Assert.equal(Services.prefs.getIntPref("calendar.alarms.eventalarmlen"), DEFVALUE); + Assert.equal(Services.prefs.getStringPref("calendar.alarms.eventalarmunit"), "days"); + Assert.equal(Services.prefs.getIntPref("calendar.alarms.onfortodos"), 1); + Assert.equal(Services.prefs.getIntPref("calendar.alarms.todoalarmlen"), DEFVALUE); + Assert.equal(Services.prefs.getStringPref("calendar.alarms.todoalarmunit"), "days"); +} + +async function handleReminderDialog(remindersWindow) { + await new Promise(remindersWindow.setTimeout); + let remindersDocument = remindersWindow.document; + + let listbox = remindersDocument.getElementById("reminder-listbox"); + Assert.equal(listbox.selectedCount, 1); + Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE); + + EventUtils.synthesizeMouseAtCenter( + remindersDocument.getElementById("reminder-new-button"), + {}, + remindersWindow + ); + Assert.equal(listbox.itemCount, 2); + Assert.equal(listbox.selectedCount, 1); + Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE); + + function text(id, value) { + let input = remindersDocument.getElementById(id); + EventUtils.synthesizeMouse(input, 5, 5, {}, remindersWindow); + Assert.equal(remindersDocument.activeElement, input); + EventUtils.synthesizeKey("a", { accelKey: true }, remindersWindow); + EventUtils.sendString(value, remindersWindow); + } + text("reminder-length", "20"); + Assert.equal(listbox.selectedItem.reminder.offset.days, 20); + + EventUtils.synthesizeMouseAtCenter(listbox, {}, remindersWindow); + EventUtils.synthesizeKey("VK_UP", {}, remindersWindow); + Assert.equal(listbox.selectedIndex, 0); + + Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE); + + remindersDocument.querySelector("dialog").getButton("accept").click(); +} + +async function openTasksTab() { + let tabmail = document.getElementById("tabmail"); + let tasksMode = tabmail.tabModes.tasks; + + if (tasksMode.tabs.length == 1) { + tabmail.selectedTab = tasksMode.tabs[0]; + } else { + let tasksTabButton = document.getElementById("tasksButton"); + EventUtils.synthesizeMouseAtCenter(tasksTabButton, { clickCount: 1 }); + } + + is(tasksMode.tabs.length, 1, "tasks tab is open"); + is(tabmail.selectedTab, tasksMode.tabs[0], "tasks tab is selected"); + + await new Promise(resolve => setTimeout(resolve)); +} + +registerCleanupFunction(function () { + Services.prefs.clearUserPref("calendar.alarms.onforevents"); + Services.prefs.clearUserPref("calendar.alarms.eventalarmlen"); + Services.prefs.clearUserPref("calendar.alarms.eventalarmunit"); + Services.prefs.clearUserPref("calendar.alarms.onfortodos"); + Services.prefs.clearUserPref("calendar.alarms.todoalarmlen"); + Services.prefs.clearUserPref("calendar.alarms.todoalarmunit"); +}); diff --git a/comm/calendar/test/browser/preferences/browser_categoryColors.js b/comm/calendar/test/browser/preferences/browser_categoryColors.js new file mode 100644 index 0000000000..29ad21ce13 --- /dev/null +++ b/comm/calendar/test/browser/preferences/browser_categoryColors.js @@ -0,0 +1,90 @@ +/* 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/calendar/CalendarTestUtils.jsm" +); + +add_task(async function testCategoryColors() { + let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory"); + + registerCleanupFunction(async () => { + CalendarTestUtils.removeCalendar(calendar); + }); + + let { prefsWindow, prefsDocument } = await openNewPrefsTab("paneCalendar", "categorieslist"); + + let listBox = prefsDocument.getElementById("categorieslist"); + Assert.equal(listBox.itemChildren.length, 22); + + for (let item of listBox.itemChildren) { + info(`${item.firstElementChild.value}: ${item.lastElementChild.style.backgroundColor}`); + Assert.ok(item.lastElementChild.style.backgroundColor); + } + + // Edit the name and colour of a built-in category. + + let subDialogPromise = BrowserTestUtils.waitForEvent( + prefsWindow.gSubDialog._dialogStack, + "dialogopen" + ); + + EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow); + Assert.equal(listBox.selectedIndex, 0); + EventUtils.synthesizeMouseAtCenter(prefsDocument.getElementById("editCButton"), {}, prefsWindow); + + await subDialogPromise; + + let subDialogBrowser = prefsWindow.gSubDialog._topDialog._frame; + let subDialogDocument = subDialogBrowser.contentDocument; + subDialogDocument.getElementById("categoryName").value = "ZZZ Mochitest"; + subDialogDocument.getElementById("categoryColor").value = "#00CC00"; + subDialogDocument.body.firstElementChild.getButton("accept").click(); + + let listItem = listBox.itemChildren[listBox.itemCount - 1]; + Assert.equal(listBox.selectedItem, listItem); + Assert.equal(listItem.firstElementChild.value, "ZZZ Mochitest"); + Assert.equal(listItem.lastElementChild.style.backgroundColor, "rgb(0, 204, 0)"); + Assert.equal(Services.prefs.getCharPref("calendar.category.color.zzz_mochitest"), "#00cc00"); + + // Remove the colour of a built-in category. + + subDialogPromise = BrowserTestUtils.waitForEvent( + prefsWindow.gSubDialog._dialogStack, + "dialogopen" + ); + + EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow); + EventUtils.synthesizeKey("VK_HOME", {}, prefsWindow); + Assert.equal(listBox.selectedIndex, 0); + let itemName = listBox.itemChildren[0].firstElementChild.value; + EventUtils.synthesizeMouseAtCenter(prefsDocument.getElementById("editCButton"), {}, prefsWindow); + + await subDialogPromise; + + subDialogBrowser = prefsWindow.gSubDialog._topDialog._frame; + await new Promise(subDialogBrowser.contentWindow.setTimeout); + subDialogDocument = subDialogBrowser.contentDocument; + subDialogDocument.getElementById("useColor").checked = false; + subDialogDocument.body.firstElementChild.getButton("accept").click(); + + listItem = listBox.itemChildren[0]; + Assert.equal(listBox.selectedItem, listItem); + Assert.equal(listItem.firstElementChild.value, itemName); + Assert.equal(listItem.lastElementChild.style.backgroundColor, ""); + Assert.equal(Services.prefs.getCharPref(`calendar.category.color.${itemName.toLowerCase()}`), ""); + + // Remove the added category. + + EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow); + EventUtils.synthesizeKey("VK_END", {}, prefsWindow); + Assert.equal(listBox.selectedIndex, listBox.itemCount - 1); + EventUtils.synthesizeMouseAtCenter( + prefsDocument.getElementById("deleteCButton"), + {}, + prefsWindow + ); +}); diff --git a/comm/calendar/test/browser/preferences/head.js b/comm/calendar/test/browser/preferences/head.js new file mode 100644 index 0000000000..8d6d5d3ab5 --- /dev/null +++ b/comm/calendar/test/browser/preferences/head.js @@ -0,0 +1,64 @@ +/* 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/. */ + +/* globals openPreferencesTab */ + +async function openNewPrefsTab(paneID, scrollPaneTo, otherArgs) { + let tabmail = document.getElementById("tabmail"); + let prefsTabMode = tabmail.tabModes.preferencesTab; + + Assert.equal(prefsTabMode.tabs.length, 0, "Prefs tab is not open"); + + let prefsDocument = await new Promise(resolve => { + Services.obs.addObserver(function documentLoaded(subject) { + if (subject.URL.startsWith("about:preferences")) { + Services.obs.removeObserver(documentLoaded, "chrome-document-loaded"); + resolve(subject); + } + }, "chrome-document-loaded"); + openPreferencesTab(paneID, scrollPaneTo, otherArgs); + }); + Assert.ok(prefsDocument.URL.startsWith("about:preferences"), "Prefs tab is open"); + + prefsDocument = prefsTabMode.tabs[0].browser.contentDocument; + let prefsWindow = prefsDocument.ownerGlobal; + prefsWindow.resizeTo(screen.availWidth, screen.availHeight); + if (paneID) { + await new Promise(resolve => prefsWindow.setTimeout(resolve)); + Assert.equal(prefsWindow.gLastCategory.category, paneID, `Selected pane is ${paneID}`); + } else { + // If we don't wait here for other scripts to run, they + // could be in a bad state if our test closes the tab. + await new Promise(resolve => prefsWindow.setTimeout(resolve)); + } + + registerCleanupOnce(); + + await new Promise(resolve => prefsWindow.setTimeout(resolve)); + if (scrollPaneTo) { + Assert.greater( + prefsDocument.getElementById("preferencesContainer").scrollTop, + 0, + "Prefs page did scroll when it was supposed to" + ); + } + return { prefsDocument, prefsWindow }; +} + +function registerCleanupOnce() { + if (registerCleanupOnce.alreadyRegistered) { + return; + } + registerCleanupFunction(closePrefsTab); + registerCleanupOnce.alreadyRegistered = true; +} + +async function closePrefsTab() { + info("Closing prefs tab"); + let tabmail = document.getElementById("tabmail"); + let prefsTab = tabmail.tabModes.preferencesTab.tabs[0]; + if (prefsTab) { + tabmail.closeTab(prefsTab); + } +} diff --git a/comm/calendar/test/browser/providers/browser.ini b/comm/calendar/test/browser/providers/browser.ini new file mode 100644 index 0000000000..84d6133696 --- /dev/null +++ b/comm/calendar/test/browser/providers/browser.ini @@ -0,0 +1,21 @@ +[default] +head = head.js +prefs = + calendar.item.promptDelete=false + calendar.debug.log=true + calendar.debug.log.verbose=true + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird + +[browser_caldavCalendar_cached.js] +[browser_caldavCalendar_uncached.js] +[browser_icsCalendar_cached.js] +[browser_icsCalendar_uncached.js] +[browser_storageCalendar.js] diff --git a/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js b/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js new file mode 100644 index 0000000000..5b725e4d54 --- /dev/null +++ b/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js @@ -0,0 +1,64 @@ +/* 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/calendar/CalDAVServer.jsm"); + +CalDAVServer.open("bob", "bob"); +if (!Services.logins.findLogins(CalDAVServer.origin, null, "test").length) { + // Save a username and password to the login manager. + let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo); + loginInfo.init(CalDAVServer.origin, null, "test", "bob", "bob", "", ""); + Services.logins.addLogin(loginInfo); +} + +let calendar; +add_setup(async function () { + calendarObserver._onLoadPromise = PromiseUtils.defer(); + calendar = createCalendar("caldav", CalDAVServer.url, true); + await calendarObserver._onLoadPromise.promise; + info("calendar set-up complete"); + + registerCleanupFunction(async () => { + // This test has issues cleaning up, and it breaks all the subsequent tests. + await new Promise(r => setTimeout(r, 1000)); // eslint-disable-line mozilla/no-arbitrary-setTimeout + await CalDAVServer.close(); + Services.logins.removeAllLogins(); + removeCalendar(calendar); + }); +}); + +async function promiseIdle() { + await TestUtils.waitForCondition(() => !calendar.wrappedJSObject.mPendingSync); + await fetch(`${CalDAVServer.origin}/ping`); +} + +add_task(async function testAlarms() { + calendarObserver._batchRequired = true; + await runTestAlarms(calendar); + + // Be sure the calendar has finished deleting the event. + await promiseIdle(); +}); + +add_task(async function testSyncChanges() { + await syncChangesTest.setUp(); + + await CalDAVServer.putItemInternal( + "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics", + syncChangesTest.part1Item + ); + await syncChangesTest.runPart1(); + + await CalDAVServer.putItemInternal( + "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics", + syncChangesTest.part2Item + ); + await syncChangesTest.runPart2(); + + CalDAVServer.deleteItemInternal("ad0850e5-8020-4599-86a4-86c90af4e2cd.ics"); + await syncChangesTest.runPart3(); + + // Be sure the calendar has finished all requests. + await promiseIdle(); +}); diff --git a/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js b/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js new file mode 100644 index 0000000000..7489ae4e09 --- /dev/null +++ b/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js @@ -0,0 +1,61 @@ +/* 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/calendar/CalDAVServer.jsm"); + +CalDAVServer.open("bob", "bob"); +if (!Services.logins.findLogins(CalDAVServer.origin, null, "test").length) { + // Save a username and password to the login manager. + let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo); + loginInfo.init(CalDAVServer.origin, null, "test", "bob", "bob", "", ""); + Services.logins.addLogin(loginInfo); +} + +let calendar; +add_setup(async function () { + calendarObserver._onLoadPromise = PromiseUtils.defer(); + calendar = createCalendar("caldav", CalDAVServer.url, false); + await calendarObserver._onLoadPromise.promise; + info("calendar set-up complete"); + + registerCleanupFunction(async () => { + await CalDAVServer.close(); + Services.logins.removeAllLogins(); + removeCalendar(calendar); + }); +}); + +async function promiseIdle() { + await fetch(`${CalDAVServer.origin}/ping`); +} + +add_task(async function testAlarms() { + calendarObserver._batchRequired = true; + await runTestAlarms(calendar); + + // Be sure the calendar has finished deleting the event. + await promiseIdle(); +}); + +add_task(async function testSyncChanges() { + await syncChangesTest.setUp(); + + await CalDAVServer.putItemInternal( + "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics", + syncChangesTest.part1Item + ); + await syncChangesTest.runPart1(); + + await CalDAVServer.putItemInternal( + "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics", + syncChangesTest.part2Item + ); + await syncChangesTest.runPart2(); + + CalDAVServer.deleteItemInternal("ad0850e5-8020-4599-86a4-86c90af4e2cd.ics"); + await syncChangesTest.runPart3(); + + // Be sure the calendar has finished all requests. + await promiseIdle(); +}); diff --git a/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js b/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js new file mode 100644 index 0000000000..ba788be5b9 --- /dev/null +++ b/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js @@ -0,0 +1,73 @@ +/* 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/calendar/ICSServer.jsm"); + +ICSServer.open("bob", "bob"); +if (!Services.logins.findLogins(ICSServer.origin, null, "test").length) { + // Save a username and password to the login manager. + let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo); + loginInfo.init(ICSServer.origin, null, "test", "bob", "bob", "", ""); + Services.logins.addLogin(loginInfo); +} + +let calendar; +add_setup(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._onLoadPromise = PromiseUtils.defer(); + calendar = createCalendar("ics", ICSServer.url, true); + await calendarObserver._onLoadPromise.promise; + info("calendar set-up complete"); + + registerCleanupFunction(async () => { + await ICSServer.close(); + Services.logins.removeAllLogins(); + removeCalendar(calendar); + }); +}); + +async function promiseIdle() { + await TestUtils.waitForCondition( + () => + calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject._queue.length == 0 && + calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject._isLocked === false + ); + await fetch(`${ICSServer.origin}/ping`); +} + +add_task(async function testAlarms() { + // Remove the next line when fixed. + calendarObserver._batchRequired = false; + await runTestAlarms(calendar); + + // Be sure the calendar has finished deleting the event. + await promiseIdle(); +}).skip(); // Broken. + +add_task(async function testSyncChanges() { + await syncChangesTest.setUp(); + + await ICSServer.putICSInternal(syncChangesTest.part1Item); + await syncChangesTest.runPart1(); + + await ICSServer.putICSInternal(syncChangesTest.part2Item); + await syncChangesTest.runPart2(); + + await ICSServer.putICSInternal( + CalendarTestUtils.dedent` + BEGIN:VCALENDAR + END:VCALENDAR + ` + ); + await syncChangesTest.runPart3(); + + // Be sure the calendar has finished deleting the event. + await promiseIdle(); +}); diff --git a/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js b/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js new file mode 100644 index 0000000000..ef25408dce --- /dev/null +++ b/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js @@ -0,0 +1,64 @@ +/* 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/calendar/ICSServer.jsm"); + +ICSServer.open("bob", "bob"); +if (!Services.logins.findLogins(ICSServer.origin, null, "test").length) { + // Save a username and password to the login manager. + let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo); + loginInfo.init(ICSServer.origin, null, "test", "bob", "bob", "", ""); + Services.logins.addLogin(loginInfo); +} + +let calendar; +add_setup(async function () { + calendarObserver._onLoadPromise = PromiseUtils.defer(); + calendar = createCalendar("ics", ICSServer.url, false); + await calendarObserver._onLoadPromise.promise; + info("calendar set-up complete"); + + registerCleanupFunction(async () => { + await ICSServer.close(); + Services.logins.removeAllLogins(); + removeCalendar(calendar); + }); +}); + +async function promiseIdle() { + await TestUtils.waitForCondition( + () => + calendar.wrappedJSObject._queue.length == 0 && calendar.wrappedJSObject._isLocked === false + ); + await fetch(`${ICSServer.origin}/ping`); +} + +add_task(async function testAlarms() { + calendarObserver._batchRequired = true; + await runTestAlarms(calendar); + + // Be sure the calendar has finished deleting the event. + await promiseIdle(); +}); + +add_task(async function testSyncChanges() { + await syncChangesTest.setUp(); + + await ICSServer.putICSInternal(syncChangesTest.part1Item); + await syncChangesTest.runPart1(); + + await ICSServer.putICSInternal(syncChangesTest.part2Item); + await syncChangesTest.runPart2(); + + await ICSServer.putICSInternal( + CalendarTestUtils.dedent` + BEGIN:VCALENDAR + END:VCALENDAR + ` + ); + await syncChangesTest.runPart3(); + + // Be sure the calendar has finished all requests. + await promiseIdle(); +}); diff --git a/comm/calendar/test/browser/providers/browser_storageCalendar.js b/comm/calendar/test/browser/providers/browser_storageCalendar.js new file mode 100644 index 0000000000..1a9eb6a30c --- /dev/null +++ b/comm/calendar/test/browser/providers/browser_storageCalendar.js @@ -0,0 +1,13 @@ +/* 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/. */ + +let calendar = createCalendar("storage", "moz-storage-calendar://"); +registerCleanupFunction(() => { + removeCalendar(calendar); +}); + +add_task(function testAlarms() { + calendarObserver._batchRequired = false; + return runTestAlarms(calendar); +}); diff --git a/comm/calendar/test/browser/providers/head.js b/comm/calendar/test/browser/providers/head.js new file mode 100644 index 0000000000..bf58302131 --- /dev/null +++ b/comm/calendar/test/browser/providers/head.js @@ -0,0 +1,402 @@ +/* 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/. */ + +SimpleTest.requestCompleteLog(); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); +var { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +let calendarObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + + /* calIObserver */ + + _batchCount: 0, + _batchRequired: true, + onStartBatch(calendar) { + info(`onStartBatch ${calendar?.id} ${++this._batchCount}`); + Assert.equal( + calendar, + this._expectedCalendar, + "onStartBatch should occur on the expected calendar" + ); + }, + onEndBatch(calendar) { + info(`onEndBatch ${calendar?.id} ${this._batchCount--}`); + Assert.equal( + calendar, + this._expectedCalendar, + "onEndBatch should occur on the expected calendar" + ); + }, + onLoad(calendar) { + info(`onLoad ${calendar.id}`); + Assert.equal(calendar, this._expectedCalendar, "onLoad should occur on the expected calendar"); + 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"); + } + }, + onModifyItem(newItem, oldItem) { + info(`onModifyItem ${newItem.calendar.id} ${newItem.id}`); + if (this._batchRequired) { + Assert.equal(this._batchCount, 1, "onModifyItem must occur in a batch"); + } + }, + onDeleteItem(deletedItem) { + info(`onDeleteItem ${deletedItem.calendar.id} ${deletedItem.id}`); + }, + 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 = cal.manager.createCalendar(type, Services.io.newURI(url)); + calendar.name = type + (useCache ? " with cache" : " without cache"); + calendar.id = cal.getUUID(); + calendar.setProperty("cache.enabled", useCache); + calendar.setProperty("calendar-main-default", true); + + cal.manager.registerCalendar(calendar); + calendar = cal.manager.getCalendarById(calendar.id); + calendarObserver._expectedCalendar = calendar; + calendar.addObserver(calendarObserver); + + info(`Created calendar ${calendar.id}`); + return calendar; +} + +/** + * Unregister a calendar. + * + * @param {calICalendar} calendar + */ +function removeCalendar(calendar) { + calendar.removeObserver(calendarObserver); + cal.manager.removeCalendar(calendar); +} + +let alarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService(Ci.calIAlarmService); + +let alarmObserver = { + QueryInterface: ChromeUtils.generateQI(["calIAlarmServiceObserver"]), + + /* calIAlarmServiceObserver */ + + _alarmCount: 0, + onAlarm(item, alarm) { + info("onAlarm"); + this._alarmCount++; + }, + onRemoveAlarmsByItem(item) {}, + onRemoveAlarmsByCalendar(calendar) {}, + onAlarmsLoaded(calendar) {}, +}; +alarmService.addObserver(alarmObserver); +registerCleanupFunction(async () => { + alarmService.removeObserver(alarmObserver); +}); + +/** + * Tests the creation, firing, dismissal, modification and deletion of an event with an alarm. + * Also checks that the number of events in the unifinder is correct at each stage. + * + * Passing this test requires the active calendar to fire notifications in the correct sequence. + */ +async function runTestAlarms() { + let today = cal.dtz.now(); + let start = today.clone(); + start.day++; + start.hour = start.minute = start.second = 0; + let end = start.clone(); + end.hour++; + let repeatUntil = start.clone(); + repeatUntil.day += 15; + + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToToday(window); + Assert.equal(window.unifinderTreeView.rowCount, 0, "unifinder event count"); + + alarmObserver._alarmCount = 0; + + let alarmDialogPromise = BrowserTestUtils.promiseAlertDialog( + undefined, + "chrome://calendar/content/calendar-alarm-dialog.xhtml", + { + async callback(alarmWindow) { + info("Alarm dialog opened"); + let alarmDocument = alarmWindow.document; + + let list = alarmDocument.getElementById("alarm-richlist"); + let items = list.querySelectorAll(`richlistitem[is="calendar-alarm-widget-richlistitem"]`); + await TestUtils.waitForCondition(() => items.length); + Assert.equal(items.length, 1); + + await new Promise(resolve => alarmWindow.setTimeout(resolve, 500)); + + let dismissButton = alarmDocument.querySelector("#alarm-dismiss-all-button"); + EventUtils.synthesizeMouseAtCenter(dismissButton, {}, alarmWindow); + }, + } + ); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window); + await setData(dialogWindow, iframeWindow, { + title: "test event", + startdate: start, + starttime: start, + enddate: end, + endtime: end, + reminder: "2days", + repeat: "weekly", + }); + + await saveAndCloseItemDialog(dialogWindow); + await alarmDialogPromise; + info("Alarm dialog closed"); + + await new Promise(r => setTimeout(r, 2000)); + Assert.equal(window.unifinderTreeView.rowCount, 1, "there should be one event in the unifinder"); + + Assert.equal( + [...Services.wm.getEnumerator("Calendar:AlarmWindow")].length, + 0, + "alarm dialog did not reappear" + ); + Assert.equal(alarmObserver._alarmCount, 1, "only one alarm"); + alarmObserver._alarmCount = 0; + + let eventBox = await CalendarTestUtils.multiweekView.waitForItemAt( + window, + start.weekday == 0 ? 2 : 1, // Sunday's event is next week. + start.weekday + 1, + 1 + ); + Assert.ok(!!eventBox.item.parentItem.alarmLastAck); + + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItemOccurrences(window, eventBox)); + await setData(dialogWindow, iframeWindow, { + title: "modified test event", + repeat: "weekly", + repeatuntil: repeatUntil, + }); + + await saveAndCloseItemDialog(dialogWindow); + + Assert.equal(window.unifinderTreeView.rowCount, 1, "there should be one event in the unifinder"); + + Services.focus.focusedWindow = window; + + await new Promise(resolve => setTimeout(resolve, 2000)); + Assert.equal( + [...Services.wm.getEnumerator("Calendar:AlarmWindow")].length, + 0, + "alarm dialog should not reappear" + ); + Assert.equal(alarmObserver._alarmCount, 0, "there should not be any remaining alarms"); + alarmObserver._alarmCount = 0; + + eventBox = await CalendarTestUtils.multiweekView.waitForItemAt( + window, + start.weekday == 0 ? 2 : 1, // Sunday's event is next week. + start.weekday + 1, + 1 + ); + Assert.ok(!!eventBox.item.parentItem.alarmLastAck); + + EventUtils.synthesizeMouseAtCenter(eventBox, {}, window); + eventBox.focus(); + window.calendarController.onSelectionChanged({ detail: window.currentView().getSelectedItems() }); + await handleDeleteOccurrencePrompt(window, window.currentView(), true); + + await CalendarTestUtils.multiweekView.waitForNoItemAt( + window, + start.weekday == 0 ? 2 : 1, // Sunday's event is next week. + start.weekday + 1, + 1 + ); + Assert.equal(window.unifinderTreeView.rowCount, 0, "there should be no events in the unifinder"); +} + +const syncItem1Name = "holy cow, a new item!"; +const syncItem2Name = "a changed item"; + +let syncChangesTest = { + async setUp() { + await CalendarTestUtils.openCalendarTab(window); + + if (document.getElementById("today-pane-panel").collapsed) { + EventUtils.synthesizeMouseAtCenter( + document.getElementById("calendar-status-todaypane-button"), + {} + ); + } + + if (document.getElementById("agenda-panel").collapsed) { + EventUtils.synthesizeMouseAtCenter(document.getElementById("today-pane-cycler-next"), {}); + } + }, + + get part1Item() { + let today = cal.dtz.now(); + let start = today.clone(); + start.day += 9 - start.weekday; + start.hour = 13; + start.minute = start.second = 0; + let end = start.clone(); + end.hour++; + + return CalendarTestUtils.dedent` + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:ad0850e5-8020-4599-86a4-86c90af4e2cd + SUMMARY:${syncItem1Name} + DTSTART:${start.icalString} + DTEND:${end.icalString} + END:VEVENT + END:VCALENDAR + `; + }, + + async runPart1() { + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToToday(window); + + // Sanity check that we have not already synchronized and that there is no + // existing item. + Assert.ok( + !CalendarTestUtils.multiweekView.getItemAt(window, 2, 3, 1), + "there should be no existing item in the calendar" + ); + + // Synchronize. + EventUtils.synthesizeMouseAtCenter(document.getElementById("refreshCalendar"), {}); + + // Verify that the item we added appears in the calendar view. + let item = await CalendarTestUtils.multiweekView.waitForItemAt(window, 2, 3, 1); + Assert.equal(item.item.title, syncItem1Name, "view should include newly-added item"); + + // Verify that the today pane updates and shows the item we added. + await TestUtils.waitForCondition(() => window.TodayPane.agenda.rowCount == 1); + Assert.equal( + getTodayPaneItemTitle(0), + syncItem1Name, + "today pane should include newly-added item" + ); + Assert.ok( + !window.TodayPane.agenda.rows[0].nextElementSibling, + "there should be no additional items in the today pane" + ); + }, + + get part2Item() { + let today = cal.dtz.now(); + let start = today.clone(); + start.day += 10 - start.weekday; + start.hour = 9; + start.minute = start.second = 0; + let end = start.clone(); + end.hour++; + + return CalendarTestUtils.dedent` + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:ad0850e5-8020-4599-86a4-86c90af4e2cd + SUMMARY:${syncItem2Name} + DTSTART:${start.icalString} + DTEND:${end.icalString} + END:VEVENT + END:VCALENDAR + `; + }, + + async runPart2() { + // Sanity check that we have not already synchronized and that there is no + // existing item. + Assert.ok( + !CalendarTestUtils.multiweekView.getItemAt(window, 2, 4, 1), + "there should be no existing item on the specified day" + ); + + // Synchronize. + EventUtils.synthesizeMouseAtCenter(document.getElementById("refreshCalendar"), {}); + + // Verify that the item has updated in the calendar view. + await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 3, 1); + let item = await CalendarTestUtils.multiweekView.waitForItemAt(window, 2, 4, 1); + Assert.equal(item.item.title, syncItem2Name, "view should show updated item"); + + // Verify that the today pane updates and shows the updated item. + await TestUtils.waitForCondition( + () => window.TodayPane.agenda.rowCount == 1 && getTodayPaneItemTitle(0) != syncItem1Name + ); + Assert.equal(getTodayPaneItemTitle(0), syncItem2Name, "today pane should show updated item"); + Assert.ok( + !window.TodayPane.agenda.rows[0].nextElementSibling, + "there should be no additional items in the today pane" + ); + }, + + async runPart3() { + // Synchronize via the calendar context menu. + await calendarListContextMenu( + document.querySelector("#calendar-list > li:nth-child(2)"), + "list-calendar-context-reload" + ); + + // Verify that the item is removed from the calendar view. + await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 3, 1); + await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 4, 1); + + // Verify that the item is removed from the today pane. + await TestUtils.waitForCondition(() => window.TodayPane.agenda.rowCount == 0); + }, +}; + +function getTodayPaneItemTitle(idx) { + const row = window.TodayPane.agenda.rows[idx]; + return row.querySelector(".agenda-listitem-title").textContent; +} + +async function calendarListContextMenu(target, menuItem) { + await new Promise(r => setTimeout(r)); + window.focus(); + await TestUtils.waitForCondition( + () => Services.focus.focusedWindow == window, + "waiting for window to be focused" + ); + + let contextMenu = document.getElementById("list-calendars-context-menu"); + let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(target, { type: "contextmenu" }); + await shownPromise; + + if (menuItem) { + let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem(document.getElementById(menuItem)); + await hiddenPromise; + } +} diff --git a/comm/calendar/test/browser/recurrence/browser.ini b/comm/calendar/test/browser/recurrence/browser.ini new file mode 100644 index 0000000000..633ed27dde --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser.ini @@ -0,0 +1,23 @@ +[default] +dupe-manifest = +prefs = + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +tags = recurrence + +[browser_annual.js] +[browser_biweekly.js] +[browser_daily.js] +[browser_lastDayOfMonth.js] +[browser_recurrenceNavigation.js] +[browser_weeklyN.js] +[browser_weeklyUntil.js] +[browser_weeklyWithException.js] diff --git a/comm/calendar/test/browser/recurrence/browser_annual.js b/comm/calendar/test/browser/recurrence/browser_annual.js new file mode 100644 index 0000000000..54c7198f05 --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_annual.js @@ -0,0 +1,69 @@ +/* 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 { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils; + +const STARTYEAR = 1950; +const EPOCH = 1970; + +add_task(async function testAnnualRecurrence() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, STARTYEAR, 1, 1); + + // Create yearly recurring all-day event. + let eventBox = dayView.getAllDayHeader(window); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { title: "Event", repeat: "yearly" }); + await saveAndCloseItemDialog(dialogWindow); + await TestUtils.waitForCondition( + () => CalendarTestUtils.dayView.getAllDayItemAt(window, 1), + "recurring all-day event created" + ); + + let checkYears = [STARTYEAR, STARTYEAR + 1, EPOCH - 1, EPOCH, EPOCH + 1]; + for (let year of checkYears) { + await CalendarTestUtils.goToDate(window, year, 1, 1); + let date = new Date(Date.UTC(year, 0, 1)); + let column = date.getUTCDay() + 1; + + // day view + await CalendarTestUtils.setCalendarView(window, "day"); + await dayView.waitForAllDayItemAt(window, 1); + + // week view + await CalendarTestUtils.setCalendarView(window, "week"); + await weekView.waitForAllDayItemAt(window, column, 1); + + // multiweek view + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await multiweekView.waitForItemAt(window, 1, column, 1); + + // month view + await CalendarTestUtils.setCalendarView(window, "month"); + await monthView.waitForItemAt(window, 1, column, 1); + } + + // Delete event. + await CalendarTestUtils.goToDate(window, checkYears[0], 1, 1); + await CalendarTestUtils.setCalendarView(window, "day"); + const box = await dayView.waitForAllDayItemAt(window, 1); + EventUtils.synthesizeMouseAtCenter(box, {}, window); + await handleDeleteOccurrencePrompt(window, box, true); + await TestUtils.waitForCondition(() => !dayView.getAllDayItemAt(window, 1), "No all-day events"); + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/recurrence/browser_biweekly.js b/comm/calendar/test/browser/recurrence/browser_biweekly.js new file mode 100644 index 0000000000..6889822375 --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_biweekly.js @@ -0,0 +1,85 @@ +/* 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 { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils; + +const HOUR = 8; + +add_task(async function testBiweeklyRecurrence() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 31); + + // Create biweekly event. + let eventBox = dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { title: "Event", repeat: "bi.weekly" }); + await saveAndCloseItemDialog(dialogWindow); + + // Check day view. + await CalendarTestUtils.setCalendarView(window, "day"); + for (let i = 0; i < 4; i++) { + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 14); + } + + // Check week view. + await CalendarTestUtils.setCalendarView(window, "week"); + await CalendarTestUtils.goToDate(window, 2009, 1, 31); + + for (let i = 0; i < 4; i++) { + await weekView.waitForEventBoxAt(window, 7, 1); + await CalendarTestUtils.calendarViewForward(window, 2); + } + + // Check multiweek view. + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToDate(window, 2009, 1, 31); + + // Always two occurrences in view, 1st and 3rd or 2nd and 4th week. + for (let i = 0; i < 5; i++) { + await multiweekView.waitForItemAt(window, (i % 2) + 1, 7, 1); + Assert.ok(multiweekView.getItemAt(window, (i % 2) + 3, 7, 1)); + await CalendarTestUtils.calendarViewForward(window, 1); + } + + // Check month view. + await CalendarTestUtils.setCalendarView(window, "month"); + await CalendarTestUtils.goToDate(window, 2009, 1, 31); + + // January + await monthView.waitForItemAt(window, 5, 7, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + + // February + await monthView.waitForItemAt(window, 2, 7, 1); + Assert.ok(monthView.getItemAt(window, 4, 7, 1)); + await CalendarTestUtils.calendarViewForward(window, 1); + + // March + await monthView.waitForItemAt(window, 2, 7, 1); + + let box = monthView.getItemAt(window, 4, 7, 1); + Assert.ok(box); + + // Delete event. + EventUtils.synthesizeMouseAtCenter(box, {}, window); + await handleDeleteOccurrencePrompt(window, box, true); + + await monthView.waitForNoItemAt(window, 4, 7, 1); + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/recurrence/browser_daily.js b/comm/calendar/test/browser/recurrence/browser_daily.js new file mode 100644 index 0000000000..42ffc4c8db --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_daily.js @@ -0,0 +1,162 @@ +/* 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 { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { + calendarViewBackward, + calendarViewForward, + setCalendarView, + dayView, + weekView, + multiweekView, + monthView, +} = CalendarTestUtils; + +const HOUR = 8; +const TITLE = "Event"; + +add_task(async function testDailyRecurrence() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + // Create daily event. + let eventBox = dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { + title: TITLE, + repeat: "daily", + repeatuntil: cal.createDateTime("20090320T000000Z"), + }); + await saveAndCloseItemDialog(dialogWindow); + + // Check day view for 7 days. + for (let day = 1; day <= 7; day++) { + await dayView.waitForEventBoxAt(window, 1); + await calendarViewForward(window, 1); + } + + // Check week view for 2 weeks. + await setCalendarView(window, "week"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + for (let day = 5; day <= 7; day++) { + await weekView.waitForEventBoxAt(window, day, 1); + } + + await calendarViewForward(window, 1); + + for (let day = 1; day <= 7; day++) { + await weekView.waitForEventBoxAt(window, day, 1); + } + + // Check multiweek view for 4 weeks. + await setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + for (let day = 5; day <= 7; day++) { + await multiweekView.waitForItemAt(window, 1, day, 1); + } + + for (let week = 2; week <= 4; week++) { + for (let day = 1; day <= 7; day++) { + await multiweekView.waitForItemAt(window, week, day, 1); + } + } + // Check month view for all 5 weeks. + await setCalendarView(window, "month"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + for (let day = 5; day <= 7; day++) { + await monthView.waitForItemAt(window, 1, day, 1); + } + + for (let week = 2; week <= 5; week++) { + for (let day = 1; day <= 7; day++) { + await monthView.waitForItemAt(window, week, day, 1); + } + } + + // Delete 3rd January occurrence. + let saturday = await monthView.waitForItemAt(window, 1, 7, 1); + EventUtils.synthesizeMouseAtCenter(saturday, {}, window); + await handleDeleteOccurrencePrompt(window, saturday, false); + + // Verify in all views. + await monthView.waitForNoItemAt(window, 1, 7, 1); + + await setCalendarView(window, "multiweek"); + Assert.ok(!multiweekView.getItemAt(window, 1, 7, 1)); + + await setCalendarView(window, "week"); + Assert.ok(!weekView.getEventBoxAt(window, 7, 1)); + + await setCalendarView(window, "day"); + Assert.ok(!dayView.getEventBoxAt(window, 1)); + + // Go to previous day to edit event to occur only on weekdays. + await calendarViewBackward(window, 1); + + ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrencesAt(window, 1)); + await setData(dialogWindow, iframeWindow, { repeat: "every.weekday" }); + await saveAndCloseItemDialog(dialogWindow); + + // Check day view for 7 days. + let dates = [ + [2009, 1, 3], + [2009, 1, 4], + ]; + for (let [y, m, d] of dates) { + await CalendarTestUtils.goToDate(window, y, m, d); + Assert.ok(!dayView.getEventBoxAt(window, 1)); + } + + // Check week view for 2 weeks. + await setCalendarView(window, "week"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + for (let i = 0; i <= 1; i++) { + await weekView.waitForNoEventBoxAt(window, 1, 1); + Assert.ok(!weekView.getEventBoxAt(window, 7, 1)); + await calendarViewForward(window, 1); + } + + // Check multiweek view for 4 weeks. + await setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + for (let i = 1; i <= 4; i++) { + await multiweekView.waitForNoItemAt(window, i, 1, 1); + Assert.ok(!multiweekView.getItemAt(window, i, 7, 1)); + } + + // Check month view for all 5 weeks. + await setCalendarView(window, "month"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + for (let i = 1; i <= 5; i++) { + await monthView.waitForNoItemAt(window, i, 1, 1); + Assert.ok(!monthView.getItemAt(window, i, 7, 1)); + } + + // Delete event. + let day = monthView.getItemAt(window, 1, 5, 1); + EventUtils.synthesizeMouseAtCenter(day, {}, window); + await handleDeleteOccurrencePrompt(window, day, true); + await monthView.waitForNoItemAt(window, 1, 5, 1); + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js b/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js new file mode 100644 index 0000000000..bc7e01556a --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js @@ -0,0 +1,112 @@ +/* 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 { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { menulistSelect } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { setCalendarView, dayView, weekView, multiweekView, monthView } = CalendarTestUtils; + +const HOUR = 8; + +add_task(async function testLastDayOfMonthRecurrence() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2008, 1, 31); // Start with a leap year. + + // Create monthly recurring event. + let eventBox = dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence }); + await saveAndCloseItemDialog(dialogWindow); + + // data tuple: [year, month, day, row in month view] + // note: Month starts here with 1 for January. + let checkingData = [ + [2008, 1, 31, 5], + [2008, 2, 29, 5], + [2008, 3, 31, 6], + [2008, 4, 30, 5], + [2008, 5, 31, 5], + [2008, 6, 30, 5], + [2008, 7, 31, 5], + [2008, 8, 31, 6], + [2008, 9, 30, 5], + [2008, 10, 31, 5], + [2008, 11, 30, 6], + [2008, 12, 31, 5], + [2009, 1, 31, 5], + [2009, 2, 28, 4], + [2009, 3, 31, 5], + ]; + // Check all dates. + for (let [y, m, d, correctRow] of checkingData) { + let date = new Date(Date.UTC(y, m - 1, d)); + let column = date.getUTCDay() + 1; + + await CalendarTestUtils.goToDate(window, y, m, d); + + // day view + await setCalendarView(window, "day"); + await dayView.waitForEventBoxAt(window, 1); + + // week view + await setCalendarView(window, "week"); + await weekView.waitForEventBoxAt(window, column, 1); + + // multiweek view + await setCalendarView(window, "multiweek"); + await multiweekView.waitForItemAt(window, 1, column, 1); + + // month view + await setCalendarView(window, "month"); + await monthView.waitForItemAt(window, correctRow, column, 1); + } + + // Delete event. + await CalendarTestUtils.goToDate( + window, + checkingData[0][0], + checkingData[0][1], + checkingData[0][2] + ); + await setCalendarView(window, "day"); + let box = await dayView.waitForEventBoxAt(window, 1); + EventUtils.synthesizeMouseAtCenter(box, {}, window); + await handleDeleteOccurrencePrompt(window, box, true); + await dayView.waitForNoEventBoxAt(window, 1); + + Assert.ok(true, "Test ran to completion"); +}); + +async function setRecurrence(recurrenceWindow) { + let recurrenceDocument = recurrenceWindow.document; + // monthly + await menulistSelect(recurrenceDocument.getElementById("period-list"), "2"); + + // last day of month + EventUtils.synthesizeMouseAtCenter( + recurrenceDocument.getElementById("montly-period-relative-date-radio"), + {}, + recurrenceWindow + ); + await menulistSelect(recurrenceDocument.getElementById("monthly-ordinal"), "-1"); + await menulistSelect(recurrenceDocument.getElementById("monthly-weekday"), "-1"); + + let button = recurrenceDocument.querySelector("dialog").getButton("accept"); + button.scrollIntoView(); + // Close dialog. + EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow); +} diff --git a/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js b/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js new file mode 100644 index 0000000000..8dfe7287ce --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js @@ -0,0 +1,138 @@ +/* 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 { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const calendar = CalendarTestUtils.createCalendar("Minimonths", "memory"); + +registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); +}); + +add_task(async function testRecurrenceNavigation() { + await CalendarTestUtils.setCalendarView(window, "month"); + + let eventDate = cal.createDateTime("20200201T000001Z"); + window.goToDate(eventDate); + + let newEventBtn = document.querySelector("#sidePanelNewEvent"); + let getEventWin = CalendarTestUtils.waitForEventDialog("edit"); + EventUtils.synthesizeMouseAtCenter(newEventBtn, {}); + + let eventWin = await getEventWin; + let iframe = eventWin.document.querySelector("iframe"); + + let getRepeatWin = BrowserTestUtils.promiseAlertDialogOpen( + "", + "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml", + { + async callback(win) { + let container = await TestUtils.waitForCondition(() => { + return win.document.querySelector("#recurrencePreviewContainer"); + }, `The recurrence container exists`); + + let initialMonth = await TestUtils.waitForCondition(() => { + return container.querySelector(`calendar-minimonth[month="1"][year="2020"]`); + }, `Initial month exists`); + Assert.ok(!initialMonth.hidden, `Initial month is visible on load`); + + let nextButton = container.querySelector("#recurrenceNext"); + Assert.ok(nextButton, `Next button exists`); + nextButton.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(nextButton, {}, win); + + let nextMonth = container.querySelector(`calendar-minimonth[month="2"][year="2020"]`); + Assert.ok(nextMonth, `Next month exists`); + Assert.ok(!nextMonth.hidden, `Next month is visible`); + + let previousButton = container.querySelector("#recurrencePrevious"); + Assert.ok(previousButton, `Previous button exists`); + previousButton.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(previousButton, {}, win); + Assert.ok(!initialMonth.hidden, `Previous month is visible after using previous button`); + + // Check that future dates display + nextButton.scrollIntoView(); + for (let index = 0; index < 5; index++) { + EventUtils.synthesizeMouseAtCenter(nextButton, {}, win); + } + + let futureMonth = await TestUtils.waitForCondition(() => { + return container.querySelector(`calendar-minimonth[month="6"][year="2020"]`); + }, `Future month exist`); + Assert.ok(!futureMonth.hidden, `Future month is visible after using next button`); + + // Ensure the number of minimonths shown is the amount we expect. + let defaultMinimonthCount = "3"; + let actualVisibleMinimonthCount = container.querySelectorAll( + `calendar-minimonth:not([hidden])` + ).length; + Assert.equal( + defaultMinimonthCount, + actualVisibleMinimonthCount, + `Default minimonth visible count matches actual: ${actualVisibleMinimonthCount}` + ); + + // Go back 5 times; we should go back to the initial month. + for (let index = 0; index < 5; index++) { + EventUtils.synthesizeMouseAtCenter(previousButton, {}, win); + } + Assert.ok(!initialMonth.hidden, `Initial month is visible`); + + // Close window at end of tests for this item + await BrowserTestUtils.closeWindow(win); + }, + } + ); + + let repeatMenu = iframe.contentDocument.querySelector("#item-repeat"); + repeatMenu.value = "custom"; + repeatMenu.doCommand(); + await getRepeatWin; + + await BrowserTestUtils.closeWindow(eventWin); +}); + +add_task(async function testRecurrenceCreationOfMonths() { + await CalendarTestUtils.setCalendarView(window, "month"); + + let eventDate = cal.createDateTime("20200101T000001Z"); + window.goToDate(eventDate); + + let newEventBtn = document.querySelector("#sidePanelNewEvent"); + let getEventWin = CalendarTestUtils.waitForEventDialog("edit"); + EventUtils.synthesizeMouseAtCenter(newEventBtn, {}); + + let eventWin = await getEventWin; + let iframe = eventWin.document.querySelector("iframe"); + + let getRepeatWin = BrowserTestUtils.promiseAlertDialogOpen( + "", + "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml", + { + async callback(win) { + let container = win.document.querySelector("#recurrencePreviewContainer"); + let nextButton = container.querySelector("#recurrenceNext"); + nextButton.scrollIntoView(); + for (let index = 0; index < 10; index++) { + EventUtils.synthesizeMouseAtCenter(nextButton, {}, win); + } + + let futureMonth = container.querySelector(`calendar-minimonth[month="10"][year="2020"]`); + Assert.ok(futureMonth, `Dynamically created future month exists`); + Assert.ok(!futureMonth.hidden, `Dynamically created future month is visible`); + + // Close window at end of tests for this item + await BrowserTestUtils.closeWindow(win); + }, + } + ); + + let repeatMenu = iframe.contentDocument.querySelector("#item-repeat"); + repeatMenu.value = "custom"; + repeatMenu.doCommand(); + await getRepeatWin; + + await BrowserTestUtils.closeWindow(eventWin); +}); diff --git a/comm/calendar/test/browser/recurrence/browser_rotated.ini b/comm/calendar/test/browser/recurrence/browser_rotated.ini new file mode 100644 index 0000000000..2385ed9324 --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_rotated.ini @@ -0,0 +1,24 @@ +[default] +head = head.js +dupe-manifest = +prefs = + calendar.item.promptDelete=false + calendar.test.rotateViews=true + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +tags = recurrence-rotated + +[browser_annual.js] +[browser_biweekly.js] +[browser_daily.js] +[browser_lastDayOfMonth.js] +[browser_weeklyN.js] +[browser_weeklyUntil.js] +[browser_weeklyWithException.js] diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyN.js b/comm/calendar/test/browser/recurrence/browser_weeklyN.js new file mode 100644 index 0000000000..e32ab470f1 --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_weeklyN.js @@ -0,0 +1,268 @@ +/* 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 { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils; + +const HOUR = 8; + +/* + * This test is intended to verify that events recurring on a weekly basis are + * correctly created and displayed. The event should recur on multiple days in + * the week, skip days, and be limited to a certain number of recurrences in + * order to verify that these parameters are respected. Deletion should delete + * all event occurrences when appropriate. + */ +add_task(async function testWeeklyNRecurrence() { + async function setRecurrence(recurrenceWindow) { + let recurrenceDocument = recurrenceWindow.document; + + // Select weekly recurrence + await menulistSelect(recurrenceDocument.getElementById("period-list"), "1"); + + let monLabel = cal.l10n.getDateFmtString("day.2.Mmm"); + let tueLabel = cal.l10n.getDateFmtString("day.3.Mmm"); + let wedLabel = cal.l10n.getDateFmtString("day.4.Mmm"); + let friLabel = cal.l10n.getDateFmtString("day.6.Mmm"); + let satLabel = cal.l10n.getDateFmtString("day.7.Mmm"); + + let dayPicker = recurrenceDocument.getElementById("daypicker-weekday"); + + // Selected date is a Monday, so it should already be selected + Assert.ok( + dayPicker.querySelector(`[label="${monLabel}"]`).checked, + "Monday should already be selected" + ); + + // Select Tuesday, Wednesday, Friday, and Saturday as additional days for + // event occurrences + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${tueLabel}"]`), + {}, + recurrenceWindow + ); + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${wedLabel}"]`), + {}, + recurrenceWindow + ); + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${friLabel}"]`), + {}, + recurrenceWindow + ); + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${satLabel}"]`), + {}, + recurrenceWindow + ); + + // Create a total of four events + EventUtils.synthesizeMouseAtCenter( + recurrenceDocument.getElementById("recurrence-range-for"), + {}, + recurrenceWindow + ); + recurrenceDocument.getElementById("repeat-ntimes-count").value = "4"; + + let button = recurrenceDocument.querySelector("dialog").getButton("accept"); + button.scrollIntoView(); + // Close dialog + EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow); + } + + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + + // Create event recurring on a weekly basis + let eventBox = dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence }); + await saveAndCloseItemDialog(dialogWindow); + + // Verify in the day view that events were created for Monday through Wednesday + for (let i = 0; i < 3; i++) { + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + } + + // No event should have been created on Thursday because it was not selected + await dayView.waitForNoEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + + // An event should have been created for Friday because it was selected + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + + // No event should have been created on Saturday due to four event limit + await dayView.waitForNoEventBoxAt(window, 1); + + // Validate event creation and lack of Saturday event in week view + await CalendarTestUtils.setCalendarView(window, "week"); + + for (let i = 2; i < 5; i++) { + await weekView.waitForEventBoxAt(window, i, 1); + } + + // No event Thursday or Saturday, event on Friday + await weekView.waitForNoEventBoxAt(window, 5, 1); + await weekView.waitForEventBoxAt(window, 6, 1); + await weekView.waitForNoEventBoxAt(window, 7, 1); + + // Validate event creation and lack of Saturday event in multiweek view + await CalendarTestUtils.setCalendarView(window, "multiweek"); + + for (let i = 2; i < 5; i++) { + await multiweekView.waitForItemAt(window, 1, i, 1); + } + + // No event Thursday or Saturday, event on Friday + await multiweekView.waitForNoItemAt(window, 1, 5, 1); + await multiweekView.waitForItemAt(window, 1, 6, 1); + await multiweekView.waitForNoItemAt(window, 1, 7, 1); + + // Validate event creation and lack of Saturday event in month view + await CalendarTestUtils.setCalendarView(window, "month"); + + for (let i = 2; i < 5; i++) { + // This should be the second week in the month + await monthView.waitForItemAt(window, 2, i, 1); + } + + // No event Thursday or Saturday, event on Friday + await monthView.waitForNoItemAt(window, 2, 5, 1); + await monthView.waitForItemAt(window, 2, 6, 1); + await monthView.waitForNoItemAt(window, 2, 7, 1); + + // Delete event + let box = await monthView.waitForItemAt(window, 2, 2, 1); + EventUtils.synthesizeMouseAtCenter(box, {}, window); + await handleDeleteOccurrencePrompt(window, box, true); + + // All occurrences should have been deleted + for (let i = 2; i < 5; i++) { + await monthView.waitForNoItemAt(window, 2, i, 1); + } + + await monthView.waitForNoItemAt(window, 2, 6, 1); +}); + +/* + * This test is intended to catch instances in which we aren't correctly setting + * the week start value of recurrences. For example, if the user has set their + * week to start on Saturday, then creates a recurring event running every other + * Saturday, Sunday, and Monday, they expect to see events on the initial + * Saturday, Sunday, Monday, skip a week, repeat. However, week start defaults + * to Monday, so if it is not correctly set, they would see events on the + * initial Saturday and Sunday, nothing on Monday, but an event on the following + * Monday. + */ +add_task(async function testRecurrenceAcrossWeekStart() { + // Sanity check that we're not testing against a default value + const initialWeekStart = Services.prefs.getIntPref("calendar.week.start", 0); + Assert.notEqual(initialWeekStart, 6, "week start should not be Saturday"); + + // Set week start to Saturday + Services.prefs.setIntPref("calendar.week.start", 6); + registerCleanupFunction(() => { + Services.prefs.setIntPref("calendar.week.start", initialWeekStart); + }); + + async function setRecurrence(recurrenceWindow) { + let recurrenceDocument = recurrenceWindow.document; + + // Select weekly recurrence + await menulistSelect(recurrenceDocument.getElementById("period-list"), "1"); + + // Recur every two weeks + recurrenceDocument.getElementById("weekly-weeks").value = "2"; + + let satLabel = cal.l10n.getDateFmtString("day.7.Mmm"); + let sunLabel = cal.l10n.getDateFmtString("day.1.Mmm"); + let monLabel = cal.l10n.getDateFmtString("day.2.Mmm"); + + let dayPicker = recurrenceDocument.getElementById("daypicker-weekday"); + + // Selected date is a Saturday, so it should already be selected + Assert.ok( + dayPicker.querySelector(`[label="${satLabel}"]`).checked, + "Saturday should already be checked" + ); + + // Select Sunday and Monday as additional days for event occurrences + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${sunLabel}"]`), + {}, + recurrenceWindow + ); + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${monLabel}"]`), + {}, + recurrenceWindow + ); + + // Create a total of six events + EventUtils.synthesizeMouseAtCenter( + recurrenceDocument.getElementById("recurrence-range-for"), + {}, + recurrenceWindow + ); + recurrenceDocument.getElementById("repeat-ntimes-count").value = "6"; + + let button = recurrenceDocument.querySelector("dialog").getButton("accept"); + button.scrollIntoView(); + // Close dialog + EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow); + } + + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2022, 10, 15); + + // Create event recurring every other week + let eventBox = dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence }); + await saveAndCloseItemDialog(dialogWindow); + + // Open week view + await CalendarTestUtils.setCalendarView(window, "week"); + + // Verify events created on Saturday, Sunday, Monday of first week + for (let i = 1; i < 4; i++) { + await weekView.waitForEventBoxAt(window, i, 1); + } + + // Verify no events created on Saturday, Sunday, Monday of second week + await CalendarTestUtils.calendarViewForward(window, 1); + + for (let i = 1; i < 4; i++) { + await weekView.waitForNoEventBoxAt(window, i, 1); + } + + // Verify events created on Saturday, Sunday, Monday of third week + await CalendarTestUtils.calendarViewForward(window, 1); + + for (let i = 1; i < 4; i++) { + await weekView.waitForEventBoxAt(window, i, 1); + } +}); diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js b/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js new file mode 100644 index 0000000000..c9780e9428 --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js @@ -0,0 +1,175 @@ +/* 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 { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { formatDate, menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils; + +const ENDDATE = cal.createDateTime("20090126T000000Z"); // Last Monday in month. +const HOUR = 8; + +add_task(async function testWeeklyUntilRecurrence() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); // Monday + + // Create weekly recurring event. + let eventBox = dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence }); + await saveAndCloseItemDialog(dialogWindow); + + // Check day view. + for (let week = 0; week < 3; week++) { + // Monday + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 2); + + // Wednesday + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 2); + + // Friday + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 3); + } + + // Monday, last occurrence + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 2); + + // Wednesday + await dayView.waitForNoEventBoxAt(window, 1); + + // Check week view. + await CalendarTestUtils.setCalendarView(window, "week"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + for (let week = 0; week < 3; week++) { + // Monday + await weekView.waitForEventBoxAt(window, 2, 1); + + // Wednesday + await weekView.waitForEventBoxAt(window, 4, 1); + + // Friday + await weekView.waitForEventBoxAt(window, 6, 1); + + await CalendarTestUtils.calendarViewForward(window, 1); + } + + // Monday, last occurrence + await weekView.waitForEventBoxAt(window, 2, 1); + // Wednesday + await weekView.waitForNoEventBoxAt(window, 4, 1); + + // Check multiweek view. + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + for (let week = 1; week < 4; week++) { + // Monday + await multiweekView.waitForItemAt(window, week, 2, 1); + // Wednesday + await multiweekView.waitForItemAt(window, week, 4, 1); + // Friday + await multiweekView.waitForItemAt(window, week, 6, 1); + } + + // Monday, last occurrence + await multiweekView.waitForItemAt(window, 4, 2, 1); + + // Wednesday + await multiweekView.waitForNoItemAt(window, 4, 4, 1); + + // Check month view. + await CalendarTestUtils.setCalendarView(window, "month"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + // starts on week 2 in month-view + for (let week = 2; week < 5; week++) { + // Monday + await monthView.waitForItemAt(window, week, 2, 1); + // Wednesday + await monthView.waitForItemAt(window, week, 4, 1); + // Friday + await monthView.waitForItemAt(window, week, 6, 1); + } + + // Monday, last occurrence + await monthView.waitForItemAt(window, 5, 2, 1); + + // Wednesday + await monthView.waitForNoItemAt(window, 5, 4, 1); + + // Delete event. + let box = monthView.getItemAt(window, 2, 2, 1); + EventUtils.synthesizeMouseAtCenter(box, {}, window); + await handleDeleteOccurrencePrompt(window, box, true); + await monthView.waitForNoItemAt(window, 2, 2, 1); + + Assert.ok(true, "Test ran to completion"); +}); + +async function setRecurrence(recurrenceWindow) { + let recurrenceDocument = recurrenceWindow.document; + + // weekly + await menulistSelect(recurrenceDocument.getElementById("period-list"), "1"); + + let mon = cal.l10n.getDateFmtString("day.2.Mmm"); + let wed = cal.l10n.getDateFmtString("day.4.Mmm"); + let fri = cal.l10n.getDateFmtString("day.6.Mmm"); + + let dayPicker = recurrenceDocument.getElementById("daypicker-weekday"); + + // Starting from Monday so it should be checked. + Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked"); + // Check Wednesday and Friday too. + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${wed}"]`), + {}, + recurrenceWindow + ); + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${fri}"]`), + {}, + recurrenceWindow + ); + + // Set until date. + EventUtils.synthesizeMouseAtCenter( + recurrenceDocument.getElementById("recurrence-range-until"), + {}, + recurrenceWindow + ); + + // Delete previous date. + let untilInput = recurrenceDocument.getElementById("repeat-until-date"); + untilInput.focus(); + EventUtils.synthesizeKey("a", { accelKey: true }, recurrenceWindow); + untilInput.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, recurrenceWindow); + + let endDateString = formatDate(ENDDATE); + EventUtils.sendString(endDateString, recurrenceWindow); + + // Move focus to ensure the date is selected. + untilInput.focus(); + EventUtils.synthesizeKey("VK_TAB", {}, recurrenceWindow); + + let button = recurrenceDocument.querySelector("dialog").getButton("accept"); + button.scrollIntoView(); + // Close dialog. + EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow); +} diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js b/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js new file mode 100644 index 0000000000..fbe007ea45 --- /dev/null +++ b/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js @@ -0,0 +1,264 @@ +/* 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 { handleDeleteOccurrencePrompt } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); + +var { menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils; + +const HOUR = 8; +const STARTDATE = cal.createDateTime("20090106T000000Z"); +const TITLE = "Event"; + +add_task(async function testWeeklyWithExceptionRecurrence() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + + // Create weekly recurring event. + let eventBox = dayView.getHourBoxAt(window, HOUR); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + await setData(dialogWindow, iframeWindow, { title: TITLE, repeat: setRecurrence }); + await saveAndCloseItemDialog(dialogWindow); + + let eventItem = await dayView.waitForEventBoxAt(window, 1); + let icon = eventItem.querySelector(".item-recurrence-icon"); + Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg"); + Assert.ok(!icon.hidden); + + // Move 5th January occurrence to 6th January. + ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrenceAt(window, 1)); + await setData(dialogWindow, iframeWindow, { + title: TITLE, + startdate: STARTDATE, + enddate: STARTDATE, + }); + await saveAndCloseItemDialog(dialogWindow); + + await CalendarTestUtils.goToDate(window, 2009, 1, 6); + eventItem = await dayView.waitForEventBoxAt(window, 1); + icon = eventItem.querySelector(".item-recurrence-icon"); + Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence-exception.svg"); + + // Change recurrence rule. + await CalendarTestUtils.goToDate(window, 2009, 1, 7); + ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrencesAt(window, 1)); + await setData(dialogWindow, iframeWindow, { + title: "Event", + repeat: changeRecurrence, + }); + await saveAndCloseItemDialog(dialogWindow); + + // Check two weeks. + // day view + await CalendarTestUtils.setCalendarView(window, "day"); + + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + await dayView.waitForNoEventBoxAt(window, 1); + + await CalendarTestUtils.calendarViewForward(window, 1); + + // Assert exactly two. + Assert.ok(!!(await dayView.waitForEventBoxAt(window, 1))); + Assert.ok(!!(await dayView.waitForEventBoxAt(window, 2))); + + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForNoEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForNoEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForNoEventBoxAt(window, 1); + + // next week + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForNoEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForEventBoxAt(window, 1); + await CalendarTestUtils.calendarViewForward(window, 1); + await dayView.waitForNoEventBoxAt(window, 1); + + // week view + await CalendarTestUtils.setCalendarView(window, "week"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + + // Assert exactly two on Tuesday. + Assert.ok(!!(await weekView.waitForEventBoxAt(window, 3, 1))); + Assert.ok(!!(await weekView.waitForEventBoxAt(window, 3, 2))); + + // Wait for the last occurrence because this appears last. + eventItem = await weekView.waitForEventBoxAt(window, 6, 1); + icon = eventItem.querySelector(".item-recurrence-icon"); + Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg"); + Assert.ok(!icon.hidden); + + Assert.ok(!weekView.getEventBoxAt(window, 1, 1)); + Assert.ok(!weekView.getEventBoxAt(window, 2, 1)); + Assert.ok(!!weekView.getEventBoxAt(window, 4, 1)); + Assert.ok(!weekView.getEventBoxAt(window, 5, 1)); + Assert.ok(!weekView.getEventBoxAt(window, 7, 1)); + + await CalendarTestUtils.calendarViewForward(window, 1); + await weekView.waitForEventBoxAt(window, 6, 1); + Assert.ok(!weekView.getEventBoxAt(window, 1, 1)); + Assert.ok(!!weekView.getEventBoxAt(window, 2, 1)); + Assert.ok(!!weekView.getEventBoxAt(window, 3, 1)); + Assert.ok(!!weekView.getEventBoxAt(window, 4, 1)); + Assert.ok(!weekView.getEventBoxAt(window, 5, 1)); + Assert.ok(!weekView.getEventBoxAt(window, 7, 1)); + + // multiweek view + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToDate(window, 2009, 1, 5); + // Wait for the first items, then check the ones not to be present. + // Assert exactly two. + await multiweekView.waitForItemAt(window, 1, 3, 1, 1); + Assert.ok(multiweekView.getItemAt(window, 1, 3, 2, 1)); + Assert.ok(!multiweekView.getItemAt(window, 1, 3, 3, 1)); + // Then check no item on the 5th. + Assert.ok(!multiweekView.getItemAt(window, 1, 2, 1)); + Assert.ok(multiweekView.getItemAt(window, 1, 4, 1)); + Assert.ok(!multiweekView.getItemAt(window, 1, 5, 1)); + Assert.ok(multiweekView.getItemAt(window, 1, 6, 1)); + Assert.ok(!multiweekView.getItemAt(window, 1, 7, 1)); + + Assert.ok(!multiweekView.getItemAt(window, 2, 1, 1)); + Assert.ok(multiweekView.getItemAt(window, 2, 2, 1)); + Assert.ok(multiweekView.getItemAt(window, 2, 3, 1)); + Assert.ok(multiweekView.getItemAt(window, 2, 4, 1)); + Assert.ok(!multiweekView.getItemAt(window, 2, 5, 1)); + Assert.ok(multiweekView.getItemAt(window, 2, 6, 1)); + Assert.ok(!multiweekView.getItemAt(window, 2, 7, 1)); + + eventItem = multiweekView.getItemAt(window, 2, 4, 1); + icon = eventItem.querySelector(".item-recurrence-icon"); + Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg"); + Assert.ok(!icon.hidden); + + // month view + await CalendarTestUtils.setCalendarView(window, "month"); + // Wait for the first items, then check the ones not to be present. + // Assert exactly two. + // start on the second week + await monthView.waitForItemAt(window, 2, 3, 1); + Assert.ok(monthView.getItemAt(window, 2, 3, 2)); + Assert.ok(!monthView.getItemAt(window, 2, 3, 3)); + // Then check no item on the 5th. + Assert.ok(!monthView.getItemAt(window, 2, 2, 1)); + Assert.ok(monthView.getItemAt(window, 2, 4, 1)); + Assert.ok(!monthView.getItemAt(window, 2, 5, 1)); + Assert.ok(monthView.getItemAt(window, 2, 6, 1)); + Assert.ok(!monthView.getItemAt(window, 2, 7, 1)); + + Assert.ok(!monthView.getItemAt(window, 3, 1, 1)); + Assert.ok(monthView.getItemAt(window, 3, 2, 1)); + Assert.ok(monthView.getItemAt(window, 3, 3, 1)); + Assert.ok(monthView.getItemAt(window, 3, 4, 1)); + Assert.ok(!monthView.getItemAt(window, 3, 5, 1)); + Assert.ok(monthView.getItemAt(window, 3, 6, 1)); + Assert.ok(!monthView.getItemAt(window, 3, 7, 1)); + + eventItem = monthView.getItemAt(window, 3, 6, 1); + icon = eventItem.querySelector(".item-recurrence-icon"); + Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg"); + Assert.ok(!icon.hidden); + + // Delete event. + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 12); + eventBox = await dayView.waitForEventBoxAt(window, 1); + EventUtils.synthesizeMouseAtCenter(eventBox, {}, window); + await handleDeleteOccurrencePrompt(window, eventBox, true); + await dayView.waitForNoEventBoxAt(window, 1); + + Assert.ok(true, "Test ran to completion"); +}); + +async function setRecurrence(recurrenceWindow) { + let recurrenceDocument = recurrenceWindow.document; + + // weekly + await menulistSelect(recurrenceDocument.getElementById("period-list"), "1"); + + let mon = cal.l10n.getDateFmtString("day.2.Mmm"); + let wed = cal.l10n.getDateFmtString("day.4.Mmm"); + let fri = cal.l10n.getDateFmtString("day.6.Mmm"); + + let dayPicker = recurrenceDocument.getElementById("daypicker-weekday"); + + // Starting from Monday so it should be checked. + Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked"); + + // Check Wednesday and Friday too. + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${wed}"]`), + {}, + recurrenceWindow + ); + Assert.ok(dayPicker.querySelector(`[label="${wed}"]`).checked, "wed checked"); + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${fri}"]`), + {}, + recurrenceWindow + ); + Assert.ok(dayPicker.querySelector(`[label="${fri}"]`).checked, "fri checked"); + + let button = recurrenceDocument.querySelector("dialog").getButton("accept"); + button.scrollIntoView(); + // Close dialog. + EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow); +} + +async function changeRecurrence(recurrenceWindow) { + let recurrenceDocument = recurrenceWindow.document; + + // weekly + await menulistSelect(recurrenceDocument.getElementById("period-list"), "1"); + + let mon = cal.l10n.getDateFmtString("day.2.Mmm"); + let tue = cal.l10n.getDateFmtString("day.3.Mmm"); + let wed = cal.l10n.getDateFmtString("day.4.Mmm"); + let fri = cal.l10n.getDateFmtString("day.6.Mmm"); + + let dayPicker = recurrenceDocument.getElementById("daypicker-weekday"); + + // Check old rule. + // Starting from Monday so it should be checked. + Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked"); + Assert.ok(dayPicker.querySelector(`[label="${wed}"]`).checked, "wed checked"); + Assert.ok(dayPicker.querySelector(`[label="${fri}"]`).checked, "fri checked"); + + // Check Tuesday. + EventUtils.synthesizeMouseAtCenter( + dayPicker.querySelector(`[label="${tue}"]`), + {}, + recurrenceWindow + ); + Assert.ok(dayPicker.querySelector(`[label="${tue}"]`).checked, "tue checked"); + + let button = recurrenceDocument.querySelector("dialog").getButton("accept"); + button.scrollIntoView(); + // Close dialog. + EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow); +} diff --git a/comm/calendar/test/browser/recurrence/head.js b/comm/calendar/test/browser/recurrence/head.js new file mode 100644 index 0000000000..efcee250b6 --- /dev/null +++ b/comm/calendar/test/browser/recurrence/head.js @@ -0,0 +1,26 @@ +/* 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/. */ + +// The tests in this folder frequently take too long. Give them more time. +requestLongerTimeout(2); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +/* globals toggleOrientation */ + +let isRotated = + document.getElementById("calendar_toggle_orientation_command").getAttribute("checked") == "true"; +let shouldBeRotated = Services.prefs.getBoolPref("calendar.test.rotateViews", false); + +if (isRotated != shouldBeRotated) { + toggleOrientation(); +} + +const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window); + +registerCleanupFunction(async () => { + await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState); +}); diff --git a/comm/calendar/test/browser/timezones/browser.ini b/comm/calendar/test/browser/timezones/browser.ini new file mode 100644 index 0000000000..2fe2a0b4ea --- /dev/null +++ b/comm/calendar/test/browser/timezones/browser.ini @@ -0,0 +1,17 @@ +[default] +prefs = + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.week.start=0 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +support-files = data/** + +[browser_minimonth.js] +[browser_timezones.js] +skip-if = debug # Takes way too long, bug 1746973. diff --git a/comm/calendar/test/browser/timezones/browser_minimonth.js b/comm/calendar/test/browser/timezones/browser_minimonth.js new file mode 100644 index 0000000000..eba6bb2485 --- /dev/null +++ b/comm/calendar/test/browser/timezones/browser_minimonth.js @@ -0,0 +1,215 @@ +/* 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/. */ + +/** + * Tests the minimonth widget in a range of time zones. It will fail if the + * widget loses time zone awareness. + */ + +/* eslint-disable no-restricted-syntax */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +add_setup(async function () { + await CalendarTestUtils.openCalendarTab(window); +}); + +registerCleanupFunction(async function () { + await CalendarTestUtils.closeCalendarTab(window); + Services.prefs.setStringPref("calendar.timezone.local", "UTC"); +}); + +async function subtest() { + let zone = cal.dtz.defaultTimezone; + info(`Running test in ${zone.tzid}`); + + // Set the minimonth to display August 2016. + let minimonth = document.getElementById("calMinimonth"); + minimonth.showMonth(new Date(2016, 7, 15)); + + Assert.deepEqual( + [...minimonth.dayBoxes.keys()], + [ + "2016-07-31", + "2016-08-01", + "2016-08-02", + "2016-08-03", + "2016-08-04", + "2016-08-05", + "2016-08-06", + "2016-08-07", + "2016-08-08", + "2016-08-09", + "2016-08-10", + "2016-08-11", + "2016-08-12", + "2016-08-13", + "2016-08-14", + "2016-08-15", + "2016-08-16", + "2016-08-17", + "2016-08-18", + "2016-08-19", + "2016-08-20", + "2016-08-21", + "2016-08-22", + "2016-08-23", + "2016-08-24", + "2016-08-25", + "2016-08-26", + "2016-08-27", + "2016-08-28", + "2016-08-29", + "2016-08-30", + "2016-08-31", + "2016-09-01", + "2016-09-02", + "2016-09-03", + "2016-09-04", + "2016-09-05", + "2016-09-06", + "2016-09-07", + "2016-09-08", + "2016-09-09", + "2016-09-10", + ], + "day boxes are stored with the correct keys" + ); + + function check(date, row, column) { + if (date instanceof Date) { + info(date); + } else { + info(`${date} ${date.timezone.tzid}`); + } + if (row && column) { + Assert.equal(minimonth.getBoxForDate(date), minimonth.mCalBox.rows[row].cells[column]); + } else { + Assert.equal(minimonth.getBoxForDate(date), null); + } + } + + let dateWithZone = cal.createDateTime(); + + // Dates without timezones or the local timezone. + + // All of these represent the 1st of August. + check(new Date(2016, 7, 1), 1, 2); + check(new Date(2016, 7, 1, 9, 0, 0), 1, 2); + check(new Date(2016, 7, 1, 22, 0, 0), 1, 2); + + check(cal.createDateTime("20160801"), 1, 2); + check(cal.createDateTime("20160801T030000"), 1, 2); + check(cal.createDateTime("20160801T210000"), 1, 2); + + dateWithZone.resetTo(2016, 7, 1, 3, 0, 0, zone); + check(dateWithZone, 1, 2); + dateWithZone.resetTo(2016, 7, 1, 21, 0, 0, zone); + check(dateWithZone, 1, 2); + + // All of these represent the 31st of August. + check(new Date(2016, 7, 31), 5, 4); + check(new Date(2016, 7, 31, 9, 0, 0), 5, 4); + check(new Date(2016, 7, 31, 22, 0, 0), 5, 4); + + check(cal.createDateTime("20160831"), 5, 4); + check(cal.createDateTime("20160831T030000"), 5, 4); + check(cal.createDateTime("20160831T210000"), 5, 4); + + dateWithZone.resetTo(2016, 7, 31, 3, 0, 0, zone); + check(dateWithZone, 5, 4); + dateWithZone.resetTo(2016, 7, 31, 21, 0, 0, zone); + check(dateWithZone, 5, 4); + + // August a year earlier shouldn't be displayed. + check(new Date(2015, 7, 15)); + check(cal.createDateTime("20150815")); + dateWithZone.resetTo(2015, 7, 15, 0, 0, 0, zone); + check(dateWithZone); + + // The Saturday of the previous week shouldn't be displayed. + check(new Date(2016, 6, 30)); + check(cal.createDateTime("20160730")); + dateWithZone.resetTo(2016, 6, 30, 0, 0, 0, zone); + check(dateWithZone); + + // The Sunday of the next week shouldn't be displayed. + check(new Date(2016, 8, 11)); + check(cal.createDateTime("20160911")); + dateWithZone.resetTo(2016, 8, 11, 0, 0, 0, zone); + check(dateWithZone); + + // August a year later shouldn't be displayed. + check(new Date(2017, 7, 15)); + check(cal.createDateTime("20170815")); + dateWithZone.resetTo(2017, 7, 15, 0, 0, 0, zone); + check(dateWithZone); + + // UTC dates. + + check(cal.createDateTime("20160801T030000Z"), 1, zone.tzid == "America/Vancouver" ? 1 : 2); + check(cal.createDateTime("20160801T210000Z"), 1, zone.tzid == "Pacific/Auckland" ? 3 : 2); + + check(cal.createDateTime("20160831T030000Z"), 5, zone.tzid == "America/Vancouver" ? 3 : 4); + check(cal.createDateTime("20160831T210000Z"), 5, zone.tzid == "Pacific/Auckland" ? 5 : 4); + + // Dates in different zones. + + let auckland = cal.timezoneService.getTimezone("Pacific/Auckland"); + let vancouver = cal.timezoneService.getTimezone("America/Vancouver"); + + // Early in Auckland is the previous day everywhere else. + dateWithZone.resetTo(2016, 7, 15, 3, 0, 0, auckland); + check(dateWithZone, 3, zone.tzid == "Pacific/Auckland" ? 2 : 1); + + // Late in Auckland is the same day everywhere. + dateWithZone.resetTo(2016, 7, 15, 21, 0, 0, auckland); + check(dateWithZone, 3, 2); + + // Early in Vancouver is the same day everywhere. + dateWithZone.resetTo(2016, 7, 15, 3, 0, 0, vancouver); + check(dateWithZone, 3, 2); + + // Late in Vancouver is the next day everywhere else. + dateWithZone.resetTo(2016, 7, 15, 21, 0, 0, vancouver); + check(dateWithZone, 3, zone.tzid == "America/Vancouver" ? 2 : 3); + + // Reset the minimonth to a different month. + minimonth.showMonth(new Date(2016, 9, 15)); +} + +/** + * Run the test at UTC+12. + */ +add_task(async function auckland() { + Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Auckland"); + await subtest(); +}); + +/** + * Run the test at UTC+2. + */ +add_task(async function berlin() { + Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin"); + await subtest(); +}); + +/** + * Run the test at UTC. + */ +add_task(async function utc() { + Services.prefs.setStringPref("calendar.timezone.local", "UTC"); + await subtest(); +}); + +/** + * Run the test at UTC-7. + */ +add_task(async function vancouver() { + Services.prefs.setStringPref("calendar.timezone.local", "America/Vancouver"); + await subtest(); +}); diff --git a/comm/calendar/test/browser/timezones/browser_timezones.js b/comm/calendar/test/browser/timezones/browser_timezones.js new file mode 100644 index 0000000000..41ce97027a --- /dev/null +++ b/comm/calendar/test/browser/timezones/browser_timezones.js @@ -0,0 +1,867 @@ +/* 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/. */ + +requestLongerTimeout(3); + +var { findEventsInNode } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var DATES = [ + [2009, 1, 1], + [2009, 4, 2], + [2009, 4, 16], + [2009, 4, 30], + [2009, 7, 2], + [2009, 10, 15], + [2009, 10, 29], + [2009, 11, 5], +]; + +var TIMEZONES = [ + "America/St_Johns", + "America/Caracas", // standard time UTC-4:30 from 2007 to 2016 + "America/Phoenix", + "America/Los_Angeles", + "America/Buenos_Aires", // standard time UTC-3, DST UTC-4 from October 2008 to March 2009 + "Europe/Paris", + "Asia/Katmandu", + "Australia/Adelaide", +]; + +const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window); + +add_setup(async () => { + registerCleanupFunction(async () => { + await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState); + Services.prefs.setStringPref("calendar.timezone.local", "UTC"); + }); + + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + // Create weekly recurring events in all TIMEZONES. + let times = [ + [4, 30], + [5, 0], + [3, 0], + [3, 0], + [9, 0], + [14, 0], + [19, 45], + [1, 30], + ]; + let time = cal.createDateTime(); + for (let i = 0; i < TIMEZONES.length; i++) { + let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, i + 11); + let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox); + time.hour = times[i][0]; + time.minute = times[i][1]; + + // Set event data. + await setData(dialogWindow, iframeWindow, { + title: TIMEZONES[i], + repeat: "weekly", + repeatuntil: cal.createDateTime("20091231T000000Z"), + starttime: time, + timezone: TIMEZONES[i], + }); + + await saveAndCloseItemDialog(dialogWindow); + } +}); + +add_task(async function testTimezones3_checkStJohns() { + Services.prefs.setStringPref("calendar.timezone.local", "America/St_Johns"); + let times = [ + [ + [4, 30], + [6, 0], + [6, 30], + [7, 30], + [7, 30], + [9, 30], + [10, 30], + [11, 30], + ], + [ + [4, 30], + [7, 0], + [7, 30], + [7, 30], + [9, 30], + [9, 30], + [11, 30], + [12, 30], + ], + [ + [4, 30], + [7, 0], + [7, 30], + [7, 30], + [9, 30], + [9, 30], + [11, 30], + [13, 30], + ], + [ + [4, 30], + [7, 0], + [7, 30], + [7, 30], + [9, 30], + [9, 30], + [11, 30], + [13, 30], + ], + [ + [4, 30], + [7, 0], + [7, 30], + [7, 30], + [9, 30], + [9, 30], + [11, 30], + [13, 30], + ], + [ + [4, 30], + [7, 0], + [7, 30], + [7, 30], + [9, 30], + [9, 30], + [11, 30], + [12, 30], + ], + [ + [4, 30], + [7, 0], + [7, 30], + [7, 30], + [9, 30], + [10, 30], + [11, 30], + [12, 30], + ], + [ + [4, 30], + [6, 0], + [6, 30], + [7, 30], + [8, 30], + [9, 30], + [10, 30], + [11, 30], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +add_task(async function testTimezones4_checkCaracas() { + Services.prefs.setStringPref("calendar.timezone.local", "America/Caracas"); + let times = [ + [ + [3, 30], + [5, 0], + [5, 30], + [6, 30], + [6, 30], + [8, 30], + [9, 30], + [10, 30], + ], + [ + [2, 30], + [5, 0], + [5, 30], + [5, 30], + [7, 30], + [7, 30], + [9, 30], + [10, 30], + ], + [ + [2, 30], + [5, 0], + [5, 30], + [5, 30], + [7, 30], + [7, 30], + [9, 30], + [11, 30], + ], + [ + [2, 30], + [5, 0], + [5, 30], + [5, 30], + [7, 30], + [7, 30], + [9, 30], + [11, 30], + ], + [ + [2, 30], + [5, 0], + [5, 30], + [5, 30], + [7, 30], + [7, 30], + [9, 30], + [11, 30], + ], + [ + [2, 30], + [5, 0], + [5, 30], + [5, 30], + [7, 30], + [7, 30], + [9, 30], + [10, 30], + ], + [ + [2, 30], + [5, 0], + [5, 30], + [5, 30], + [7, 30], + [8, 30], + [9, 30], + [10, 30], + ], + [ + [3, 30], + [5, 0], + [5, 30], + [6, 30], + [7, 30], + [8, 30], + [9, 30], + [10, 30], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +add_task(async function testTimezones5_checkPhoenix() { + Services.prefs.setStringPref("calendar.timezone.local", "America/Phoenix"); + let times = [ + [ + [1, 0], + [2, 30], + [3, 0], + [4, 0], + [4, 0], + [6, 0], + [7, 0], + [8, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [8, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [9, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [9, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [9, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [8, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [6, 0], + [7, 0], + [8, 0], + ], + [ + [1, 0], + [2, 30], + [3, 0], + [4, 0], + [5, 0], + [6, 0], + [7, 0], + [8, 0], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +add_task(async function testTimezones6_checkLosAngeles() { + Services.prefs.setStringPref("calendar.timezone.local", "America/Los_Angeles"); + let times = [ + [ + [0, 0], + [1, 30], + [2, 0], + [3, 0], + [3, 0], + [5, 0], + [6, 0], + [7, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [8, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [9, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [9, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [9, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [5, 0], + [7, 0], + [8, 0], + ], + [ + [0, 0], + [2, 30], + [3, 0], + [3, 0], + [5, 0], + [6, 0], + [7, 0], + [8, 0], + ], + [ + [0, 0], + [1, 30], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + [6, 0], + [7, 0], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +add_task(async function testTimezones7_checkBuenosAires() { + Services.prefs.setStringPref("calendar.timezone.local", "America/Argentina/Buenos_Aires"); + let times = [ + [ + [6, 0], + [7, 30], + [8, 0], + [9, 0], + [9, 0], + [11, 0], + [12, 0], + [13, 0], + ], + [ + [4, 0], + [6, 30], + [7, 0], + [7, 0], + [9, 0], + [9, 0], + [11, 0], + [12, 0], + ], + [ + [4, 0], + [6, 30], + [7, 0], + [7, 0], + [9, 0], + [9, 0], + [11, 0], + [13, 0], + ], + [ + [4, 0], + [6, 30], + [7, 0], + [7, 0], + [9, 0], + [9, 0], + [11, 0], + [13, 0], + ], + [ + [4, 0], + [6, 30], + [7, 0], + [7, 0], + [9, 0], + [9, 0], + [11, 0], + [13, 0], + ], + [ + [4, 0], + [6, 30], + [7, 0], + [7, 0], + [9, 0], + [9, 0], + [11, 0], + [12, 0], + ], + [ + [4, 0], + [6, 30], + [7, 0], + [7, 0], + [9, 0], + [10, 0], + [11, 0], + [12, 0], + ], + [ + [5, 0], + [6, 30], + [7, 0], + [8, 0], + [9, 0], + [10, 0], + [11, 0], + [12, 0], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +add_task(async function testTimezones8_checkParis() { + Services.prefs.setStringPref("calendar.timezone.local", "Europe/Paris"); + let times = [ + [ + [9, 0], + [10, 30], + [11, 0], + [12, 0], + [12, 0], + [14, 0], + [15, 0], + [16, 0], + ], + [ + [9, 0], + [11, 30], + [12, 0], + [12, 0], + [14, 0], + [14, 0], + [16, 0], + [17, 0], + ], + [ + [9, 0], + [11, 30], + [12, 0], + [12, 0], + [14, 0], + [14, 0], + [16, 0], + [18, 0], + ], + [ + [9, 0], + [11, 30], + [12, 0], + [12, 0], + [14, 0], + [14, 0], + [16, 0], + [18, 0], + ], + [ + [9, 0], + [11, 30], + [12, 0], + [12, 0], + [14, 0], + [14, 0], + [16, 0], + [18, 0], + ], + [ + [9, 0], + [11, 30], + [12, 0], + [12, 0], + [14, 0], + [14, 0], + [16, 0], + [17, 0], + ], + [ + [8, 0], + [10, 30], + [11, 0], + [11, 0], + [13, 0], + [14, 0], + [15, 0], + [16, 0], + ], + [ + [9, 0], + [10, 30], + [11, 0], + [12, 0], + [13, 0], + [14, 0], + [15, 0], + [16, 0], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +add_task(async function testTimezones9_checkKathmandu() { + Services.prefs.setStringPref("calendar.timezone.local", "Asia/Kathmandu"); + let times = [ + [ + [13, 45], + [15, 15], + [15, 45], + [16, 45], + [16, 45], + [18, 45], + [19, 45], + [20, 45], + ], + [ + [12, 45], + [15, 15], + [15, 45], + [15, 45], + [17, 45], + [17, 45], + [19, 45], + [20, 45], + ], + [ + [12, 45], + [15, 15], + [15, 45], + [15, 45], + [17, 45], + [17, 45], + [19, 45], + [21, 45], + ], + [ + [12, 45], + [15, 15], + [15, 45], + [15, 45], + [17, 45], + [17, 45], + [19, 45], + [21, 45], + ], + [ + [12, 45], + [15, 15], + [15, 45], + [15, 45], + [17, 45], + [17, 45], + [19, 45], + [21, 45], + ], + [ + [12, 45], + [15, 15], + [15, 45], + [15, 45], + [17, 45], + [17, 45], + [19, 45], + [20, 45], + ], + [ + [12, 45], + [15, 15], + [15, 45], + [15, 45], + [17, 45], + [18, 45], + [19, 45], + [20, 45], + ], + [ + [13, 45], + [15, 15], + [15, 45], + [16, 45], + [17, 45], + [18, 45], + [19, 45], + [20, 45], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +add_task(async function testTimezones10_checkAdelaide() { + Services.prefs.setStringPref("calendar.timezone.local", "Australia/Adelaide"); + let times = [ + [ + [18, 30], + [20, 0], + [20, 30], + [21, 30], + [21, 30], + [23, 30], + [0, 30, +1], + [1, 30, +1], + ], + [ + [17, 30], + [20, 0], + [20, 30], + [20, 30], + [22, 30], + [22, 30], + [0, 30, +1], + [1, 30, +1], + ], + [ + [16, 30], + [19, 0], + [19, 30], + [19, 30], + [21, 30], + [21, 30], + [23, 30], + [1, 30, +1], + ], + [ + [16, 30], + [19, 0], + [19, 30], + [19, 30], + [21, 30], + [21, 30], + [23, 30], + [1, 30, +1], + ], + [ + [16, 30], + [19, 0], + [19, 30], + [19, 30], + [21, 30], + [21, 30], + [23, 30], + [1, 30, +1], + ], + [ + [17, 30], + [20, 0], + [20, 30], + [20, 30], + [22, 30], + [22, 30], + [0, 30, +1], + [1, 30, +1], + ], + [ + [17, 30], + [20, 0], + [20, 30], + [20, 30], + [22, 30], + [23, 30], + [0, 30, +1], + [1, 30, +1], + ], + [ + [18, 30], + [20, 0], + [20, 30], + [21, 30], + [22, 30], + [23, 30], + [0, 30, +1], + [1, 30, +1], + ], + ]; + EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window); + await CalendarTestUtils.setCalendarView(window, "day"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + await verify(DATES, TIMEZONES, times); +}); + +async function verify(dates, timezones, times) { + function* datetimes() { + for (let idx = 0; idx < dates.length; idx++) { + yield [dates[idx][0], dates[idx][1], dates[idx][2], times[idx]]; + } + } + let allowedDifference = 3; + + for (let [selectedYear, selectedMonth, selectedDay, selectedTime] of datetimes()) { + info(`Verifying on day ${selectedDay}, month ${selectedMonth}, year ${selectedYear}`); + await CalendarTestUtils.goToDate(window, selectedYear, selectedMonth, selectedDay); + + // Find event with timezone tz. + for (let tzIdx = 0; tzIdx < timezones.length; tzIdx++) { + let [hour, minutes, day] = selectedTime[tzIdx]; + info( + `Verifying at ${hour} hours, ${minutes} minutes (offset: ${day || "none"}) ` + + `in timezone "${timezones[tzIdx]}"` + ); + + // following day + if (day == 1) { + await CalendarTestUtils.calendarViewForward(window, 1); + } else if (day == -1) { + await CalendarTestUtils.calendarViewBackward(window, 1); + } + + let hourRect = CalendarTestUtils.dayView.getHourBoxAt(window, hour).getBoundingClientRect(); + let timeY = hourRect.y + hourRect.height * (minutes / 60); + + // Wait for at least one event box to exist. + await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1); + + let eventPositions = Array.from(CalendarTestUtils.dayView.getEventBoxes(window)) + .filter(node => node.mOccurrence.title == timezones[tzIdx]) + .map(node => node.getBoundingClientRect().y); + + dump(`Looking for event at ${timeY}: found ${eventPositions.join(", ")}\n`); + + if (day == 1) { + await CalendarTestUtils.calendarViewBackward(window, 1); + } else if (day == -1) { + await CalendarTestUtils.calendarViewForward(window, 1); + } + + Assert.ok( + eventPositions.some(pos => Math.abs(timeY - pos) < allowedDifference), + `There should be an event box that starts at ${hour} hours, ${minutes} minutes` + ); + } + } +} diff --git a/comm/calendar/test/browser/views/browser.ini b/comm/calendar/test/browser/views/browser.ini new file mode 100644 index 0000000000..0aae8af9d0 --- /dev/null +++ b/comm/calendar/test/browser/views/browser.ini @@ -0,0 +1,32 @@ +[default] +head = head.js +prefs = + calendar.item.promptDelete=false + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + # Default start of the week Thursday to make sure calendar isn't relying on + # built-in assumptions of week start. + calendar.week.start=4 + # Default Sunday to "not a weekend" and Wednesday to "weekend" to make sure + # calendar isn't relying on built-in assumptions of work days. + calendar.week.d0sundaysoff=false + calendar.week.d3wednesdaysoff=true + # Default work hours to be from 3:00 to 12:00 to make sure calendar isn't + # relying on built-in assumptions of work hours. + calendar.view.daystarthour=3 + calendar.view.dayendhour=12 + calendar.view.visiblehours=3 + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird + +[browser_dayView.js] +[browser_monthView.js] +[browser_multiweekView.js] +[browser_propertyChanges.js] +[browser_taskView.js] +[browser_viewSwitch.js] +[browser_weekView.js] diff --git a/comm/calendar/test/browser/views/browser_dayView.js b/comm/calendar/test/browser/views/browser_dayView.js new file mode 100644 index 0000000000..ba68a85eea --- /dev/null +++ b/comm/calendar/test/browser/views/browser_dayView.js @@ -0,0 +1,185 @@ +/* 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 { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const TITLE1 = "Day View Event"; +const TITLE2 = "Day View Event Changed"; +const DESC = "Day View Event Description"; + +add_setup(async function () { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "day"); +}); + +add_task(async function testDayView() { + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + let dayView = document.getElementById("day-view"); + // Verify date in view. + await TestUtils.waitForCondition( + () => dayView.dayColumns[0]?.date.icalString == "20090101", + "Inspecting the date" + ); + + // Create event at 8 AM. + let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, 8); + let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent( + window, + eventBox + ); + + // Check that the start time is correct. + let someDate = cal.createDateTime(); + someDate.resetTo(2009, 0, 1, 8, 0, 0, cal.dtz.UTC); + + let startPicker = iframeDocument.getElementById("event-starttime"); + Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate)); + Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate)); + + // Fill in title, description and calendar. + await setData(dialogWindow, iframeWindow, { + title: TITLE1, + description: DESC, + calendar: "Test", + }); + + await saveAndCloseItemDialog(dialogWindow); + + // If it was created successfully, it can be opened. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1)); + await setData(dialogWindow, iframeWindow, { title: TITLE2 }); + await saveAndCloseItemDialog(dialogWindow); + + // Check if name was saved. + await TestUtils.waitForCondition(() => { + eventBox = CalendarTestUtils.dayView.getEventBoxAt(window, 1); + if (!eventBox) { + return false; + } + + let eventName = eventBox.querySelector(".event-name-label"); + return eventName.textContent == TITLE2; + }, "event was modified in the view"); + + // Delete event + EventUtils.synthesizeMouseAtCenter(eventBox, {}, window); + eventBox.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await CalendarTestUtils.dayView.waitForNoEventBoxAt(window, 1); + + Assert.ok(true, "Test ran to completion"); +}); + +add_task(async function testDayViewDateLabel() { + await CalendarTestUtils.goToDate(window, 2022, 4, 13); + + let heading = CalendarTestUtils.dayView.getColumnHeading(window); + let labelSpan = heading.querySelector("span:not([hidden])"); + + Assert.equal( + labelSpan.textContent, + "Wednesday Apr 13", + "the date label should contain the displayed date in a human-readable string" + ); +}); + +add_task(async function testDayViewCurrentDayHighlight() { + // Sanity check that this date (which should be in the past) is not today's + // date. + let today = new Date(); + Assert.ok(today.getUTCFullYear() != 2022 || today.getUTCMonth() != 3 || today.getUTCDate() != 13); + + // When displaying days which are not the current day, there should be no + // highlight. + await CalendarTestUtils.goToDate(window, 2022, 4, 13); + + let container = CalendarTestUtils.dayView.getColumnContainer(window); + Assert.ok( + !container.classList.contains("day-column-today"), + "the displayed date should not be highlighted if it is not the current day" + ); + + // When displaying the current day, it should be highlighted. + await CalendarTestUtils.goToToday(window); + + container = CalendarTestUtils.dayView.getColumnContainer(window); + Assert.ok( + container.classList.contains("day-column-today"), + "the displayed date should be highlighted if it is the current day" + ); +}); + +add_task(async function testDayViewWorkDayHighlight() { + // The test configuration sets Sunday to be a work day, so it should not have + // the weekend background. + await CalendarTestUtils.goToDate(window, 2022, 4, 10); + + let container = CalendarTestUtils.dayView.getColumnContainer(window); + Assert.ok( + !container.classList.contains("day-column-weekend"), + "the displayed date should not be highlighted if it is a work day" + ); + + await CalendarTestUtils.goToDate(window, 2022, 4, 13); + + container = CalendarTestUtils.dayView.getColumnContainer(window); + Assert.ok( + container.classList.contains("day-column-weekend"), + "the displayed date should be highlighted if it is not a work day" + ); +}); + +add_task(async function testDayViewNavbar() { + await CalendarTestUtils.goToDate(window, 2022, 4, 13); + + let intervalDescription = CalendarTestUtils.getNavBarIntervalDescription(window); + Assert.equal( + intervalDescription.textContent, + "Wednesday, April 13, 2022", + "interval description should contain a description of the displayed date" + ); + + // Note that the value 14 here tests calculation of the calendar week based on + // the starting day of the week; if the calculation built in an assumption of + // Sunday or Monday as the starting day of the week, we would get 15 here. + let calendarWeek = CalendarTestUtils.getNavBarCalendarWeekBox(window); + Assert.equal( + calendarWeek.textContent, + "CW: 14", + "calendar week label should contain an indicator of which week contains displayed date" + ); +}); + +add_task(async function testDayViewTodayButton() { + // Though this code is cribbed from the CalendarTestUtils, it should be + // duplicated in case the utility implementation changes. + let todayButton = CalendarTestUtils.getNavBarTodayButton(window); + + EventUtils.synthesizeMouseAtCenter(todayButton, {}, window); + await CalendarTestUtils.ensureViewLoaded(window); + + let displayedDate = CalendarTestUtils.dayView.getEventColumn(window).date; + + let today = new Date(); + Assert.equal( + displayedDate.year, + today.getUTCFullYear(), + "year of displayed date should be this year" + ); + Assert.equal( + displayedDate.month, + today.getUTCMonth(), + "month of displayed date should be this month" + ); + Assert.equal(displayedDate.day, today.getUTCDate(), "day of displayed date should be today"); +}); diff --git a/comm/calendar/test/browser/views/browser_monthView.js b/comm/calendar/test/browser/views/browser_monthView.js new file mode 100644 index 0000000000..f3a385a3f5 --- /dev/null +++ b/comm/calendar/test/browser/views/browser_monthView.js @@ -0,0 +1,86 @@ +/* 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 { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const TITLE1 = "Month View Event"; +const TITLE2 = "Month View Event Changed"; +const DESC = "Month View Event Description"; + +add_task(async function testMonthView() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "month"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + // Verify date. + await TestUtils.waitForCondition(() => { + let dateLabel = document.querySelector( + '#month-view td[selected="true"] > calendar-month-day-box' + ); + return dateLabel && dateLabel.mDate.icalString == "20090101"; + }, "Inspecting the date"); + + // Create event. + // Thursday of 2009-01-05 should be the selected box in the first row with default settings. + let hour = new Date().getUTCHours(); // Remember time at click. + let eventBox = CalendarTestUtils.monthView.getDayBox(window, 1, 5); + let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent( + window, + eventBox + ); + + // Check that the start time is correct. + // Next full hour except last hour hour of the day. + let nextHour = hour == 23 ? hour : (hour + 1) % 24; + let someDate = cal.dtz.now(); + someDate.resetTo(2009, 0, 5, nextHour, 0, 0, cal.dtz.UTC); + + let startPicker = iframeDocument.getElementById("event-starttime"); + Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate)); + Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate)); + + // Fill in title, description and calendar. + await setData(dialogWindow, iframeWindow, { + title: TITLE1, + description: DESC, + calendar: "Test", + }); + + await saveAndCloseItemDialog(dialogWindow); + + // If it was created successfully, it can be opened. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.monthView.editItemAt(window, 1, 5, 1)); + // Change title and save changes. + await setData(dialogWindow, iframeWindow, { title: TITLE2 }); + await saveAndCloseItemDialog(dialogWindow); + + // Check if name was saved. + let eventName; + await TestUtils.waitForCondition(() => { + eventBox = CalendarTestUtils.monthView.getItemAt(window, 1, 5, 1); + if (!eventBox) { + return false; + } + eventName = eventBox.querySelector(".event-name-label").textContent; + return eventName == TITLE2; + }, "event name did not update in time"); + + Assert.equal(eventName, TITLE2); + + // Delete event. + EventUtils.synthesizeMouseAtCenter(eventBox, {}, window); + eventBox.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await CalendarTestUtils.monthView.waitForNoItemAt(window, 1, 5, 1); + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/views/browser_multiweekView.js b/comm/calendar/test/browser/views/browser_multiweekView.js new file mode 100644 index 0000000000..feb8fcd3ec --- /dev/null +++ b/comm/calendar/test/browser/views/browser_multiweekView.js @@ -0,0 +1,88 @@ +/* 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 { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const TITLE1 = "Multiweek View Event"; +const TITLE2 = "Multiweek View Event Changed"; +const DESC = "Multiweek View Event Description"; + +add_task(async function () { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "multiweek"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + // Verify date. + await TestUtils.waitForCondition(() => { + let dateLabel = document.querySelector( + '#multiweek-view td[selected="true"] > calendar-month-day-box' + ); + return dateLabel && dateLabel.mDate.icalString == "20090101"; + }, "Inspecting the date"); + + // Create event. + // Thursday of 2009-01-05 should be the selected box in the first row with default settings. + let hour = new Date().getUTCHours(); // Remember time at click. + let eventBox = CalendarTestUtils.multiweekView.getDayBox(window, 1, 5); + let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent( + window, + eventBox + ); + + // Check that the start time is correct. + // Next full hour except last hour hour of the day. + let nextHour = hour == 23 ? hour : (hour + 1) % 24; + let someDate = cal.dtz.now(); + someDate.resetTo(2009, 0, 5, nextHour, 0, 0, cal.dtz.UTC); + + let startPicker = iframeDocument.getElementById("event-starttime"); + Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate)); + Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate)); + + // Fill in title, description and calendar. + await setData(dialogWindow, iframeWindow, { + title: TITLE1, + description: DESC, + calendar: "Test", + }); + + await saveAndCloseItemDialog(dialogWindow); + + // If it was created successfully, it can be opened. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.multiweekView.editItemAt( + window, + 1, + 5, + 1 + )); + // Change title and save changes. + await setData(dialogWindow, iframeWindow, { title: TITLE2 }); + await saveAndCloseItemDialog(dialogWindow); + + // Check if name was saved. + await TestUtils.waitForCondition(() => { + eventBox = CalendarTestUtils.multiweekView.getItemAt(window, 1, 5, 1); + if (eventBox === null) { + return false; + } + let eventName = eventBox.querySelector(".event-name-label"); + return eventName && eventName.textContent == TITLE2; + }, "Wait for the new title"); + + // Delete event. + EventUtils.synthesizeMouseAtCenter(eventBox, {}, window); + eventBox.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 1, 5, 1); + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/views/browser_propertyChanges.js b/comm/calendar/test/browser/views/browser_propertyChanges.js new file mode 100644 index 0000000000..79848a0e73 --- /dev/null +++ b/comm/calendar/test/browser/views/browser_propertyChanges.js @@ -0,0 +1,248 @@ +/* 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/. */ + +/** Tests that changes in a calendar's properties are reflected in the current view. */ + +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); + +let composite = cal.view.getCompositeCalendar(window); + +// This is the calendar we're going to change the properties of. +let thisCalendar = CalendarTestUtils.createCalendar("This Calendar", "memory"); +thisCalendar.setProperty("color", "#ffee22"); + +// This calendar isn't going to change, and we'll check it doesn't. +let notThisCalendar = CalendarTestUtils.createCalendar("Not This Calendar", "memory"); +notThisCalendar.setProperty("color", "#dd3333"); + +add_setup(async function () { + let { dedent } = CalendarTestUtils; + await thisCalendar.addItem( + new CalEvent(dedent` + BEGIN:VEVENT + SUMMARY:This Event 1 + DTSTART;VALUE=DATE:20160205 + DTEND;VALUE=DATE:20160206 + END:VEVENT + `) + ); + await thisCalendar.addItem( + new CalEvent(dedent` + BEGIN:VEVENT + SUMMARY:This Event 2 + DTSTART:20160205T130000Z + DTEND:20160205T150000Z + END:VEVENT + `) + ); + await thisCalendar.addItem( + new CalEvent(dedent` + BEGIN:VEVENT + SUMMARY:This Event 3 + DTSTART;VALUE=DATE:20160208 + DTEND;VALUE=DATE:20160209 + RRULE:FREQ=DAILY;INTERVAL=2;COUNT=3 + END:VEVENT + `) + ); + + await notThisCalendar.addItem( + new CalEvent(dedent` + BEGIN:VEVENT + SUMMARY:Not This Event 1 + DTSTART;VALUE=DATE:20160205 + DTEND;VALUE=DATE:20160207 + END:VEVENT + `) + ); + await notThisCalendar.addItem( + new CalEvent(dedent` + BEGIN:VEVENT + SUMMARY:Not This Event 2 + DTSTART:20160205T140000Z + DTEND:20160205T170000Z + END:VEVENT + `) + ); +}); + +/** + * Assert whether the given event box is draggable (editable). + * + * @param {MozCalendarEventBox} eventBox - The event box to test. + * @param {boolean} draggable - Whether we expect it to be draggable. + * @param {string} message - A message for assertions. + */ +async function assertCanDrag(eventBox, draggable, message) { + // Hover to see if the drag gripbars appear. + let enterPromise = BrowserTestUtils.waitForEvent(eventBox, "mouseenter"); + EventUtils.synthesizeMouseAtCenter(eventBox, { type: "mouseover" }, window); + await enterPromise; + Assert.equal( + BrowserTestUtils.is_visible(eventBox.startGripbar), + draggable, + `Start gripbar should be ${draggable ? "visible" : "hidden"} on hover: ${message}` + ); + Assert.equal( + BrowserTestUtils.is_visible(eventBox.endGripbar), + draggable, + `End gripbar should be ${draggable ? "visible" : "hidden"} on hover: ${message}` + ); +} + +/** + * Assert whether the given event element is editable. + * + * @param {Element} eventElement - The event element to test. + * @param {boolean} editable - Whether we expect it to be editable. + * @param {string} message - A message for assertions. + */ +async function assertEditable(eventElement, editable, message) { + // FIXME: Have more ways to test if an event is editable (e.g. test the + // context menu) + if (eventElement.matches("calendar-event-box")) { + await CalendarTestUtils.assertEventBoxDraggable(eventElement, editable, editable, message); + } +} + +async function subTest(viewName, boxSelector, thisBoxCount, notThisBoxCount) { + async function makeChangeWithReload(changeFunction) { + await changeFunction(); + await CalendarTestUtils.ensureViewLoaded(window); + } + + async function checkBoxItems(expectedCount, checkFunction) { + await TestUtils.waitForCondition( + () => view.querySelectorAll(boxSelector).length == expectedCount, + "waiting for the correct number of boxes to be displayed" + ); + let boxItems = view.querySelectorAll(boxSelector); + + if (!checkFunction) { + return; + } + + for (let boxItem of boxItems) { + // TODO: why is it named `item` in some places and `occurrence` elsewhere? + let isThisCalendar = + (boxItem.item && boxItem.item.calendar == thisCalendar) || + boxItem.occurrence.calendar == thisCalendar; + await checkFunction(boxItem, isThisCalendar); + } + } + + let view = document.getElementById(`${viewName}-view`); + + await CalendarTestUtils.setCalendarView(window, viewName); + await CalendarTestUtils.goToDate(window, 2016, 2, 5); + + info("Check initial state."); + + await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => { + let style = getComputedStyle(boxItem); + + if (isThisCalendar) { + Assert.equal(style.backgroundColor, "rgb(255, 238, 34)", "item background correct"); + Assert.equal(style.color, "rgb(34, 34, 34)", "item foreground correct"); + } else { + Assert.equal( + style.backgroundColor, + "rgb(221, 51, 51)", + "item background correct (not target calendar)" + ); + Assert.equal( + style.color, + "rgb(255, 255, 255)", + "item foreground correct (not target calendar)" + ); + } + await assertEditable(boxItem, true, "Initial event"); + }); + + info("Change color."); + + thisCalendar.setProperty("color", "#16a765"); + await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => { + let style = getComputedStyle(boxItem); + + if (isThisCalendar) { + Assert.equal(style.backgroundColor, "rgb(22, 167, 101)", "item background correct"); + Assert.equal(style.color, "rgb(255, 255, 255)", "item foreground correct"); + } else { + Assert.equal( + style.backgroundColor, + "rgb(221, 51, 51)", + "item background correct (not target calendar)" + ); + Assert.equal( + style.color, + "rgb(255, 255, 255)", + "item foreground correct (not target calendar)" + ); + } + }); + + info("Reset color."); + thisCalendar.setProperty("color", "#ffee22"); + + info("Disable."); + + thisCalendar.setProperty("disabled", true); + await checkBoxItems(notThisBoxCount); + + info("Enable."); + + await makeChangeWithReload(() => thisCalendar.setProperty("disabled", false)); + await checkBoxItems(thisBoxCount + notThisBoxCount); + + info("Hide."); + + composite.removeCalendar(thisCalendar); + await checkBoxItems(notThisBoxCount); + + info("Show."); + + await makeChangeWithReload(() => composite.addCalendar(thisCalendar)); + await checkBoxItems(thisBoxCount + notThisBoxCount); + + info("Set read-only."); + + await makeChangeWithReload(() => thisCalendar.setProperty("readOnly", true)); + await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => { + if (isThisCalendar) { + await assertEditable(boxItem, false, "In readonly calendar"); + } else { + await assertEditable(boxItem, true, "In non-readonly calendar"); + } + }); + + info("Clear read-only."); + + await makeChangeWithReload(() => thisCalendar.setProperty("readOnly", false)); + await checkBoxItems(thisBoxCount + notThisBoxCount, async boxItem => { + await assertEditable(boxItem, true, "In non-readonly calendar after clearing"); + }); +} + +add_task(async function testMonthView() { + await subTest("month", "calendar-month-day-box-item", 5, 3); +}); + +add_task(async function testMultiWeekView() { + await subTest("multiweek", "calendar-month-day-box-item", 5, 3); +}); + +add_task(async function testWeekView() { + await subTest("week", "calendar-editable-item, .multiday-events-list calendar-event-box", 4, 3); +}); + +add_task(async function testDayView() { + await subTest("day", "calendar-editable-item, .multiday-events-list calendar-event-box", 2, 2); +}); + +registerCleanupFunction(async () => { + CalendarTestUtils.removeCalendar(thisCalendar); + CalendarTestUtils.removeCalendar(notThisCalendar); +}); diff --git a/comm/calendar/test/browser/views/browser_taskView.js b/comm/calendar/test/browser/views/browser_taskView.js new file mode 100644 index 0000000000..c049c9668f --- /dev/null +++ b/comm/calendar/test/browser/views/browser_taskView.js @@ -0,0 +1,148 @@ +/* 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 { MID_SLEEP, execEventDialogCallback } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarUtils.jsm" +); +var { saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +const TITLE = "Task"; +const DESCRIPTION = "1. Do A\n2. Do B"; +const PERCENTCOMPLETE = "50"; + +add_task(async function () { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + // Open task view. + EventUtils.synthesizeMouseAtCenter(document.getElementById("tasksButton"), {}, window); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, MID_SLEEP)); + + // Make sure that testing calendar is selected. + let calList = document.querySelector(`#calendar-list > [calendar-id="${calendar.id}"]`); + Assert.ok(calList); + EventUtils.synthesizeMouseAtCenter(calList, {}, window); + + let taskTreeNode = document.getElementById("calendar-task-tree"); + Assert.equal(taskTreeNode.mTaskArray.length, 0); + + // Add task. + let taskInput = document.getElementById("view-task-edit-field"); + taskInput.focus(); + EventUtils.sendString(TITLE, window); + EventUtils.synthesizeKey("VK_RETURN", {}, window); + + // Verify added. + await TestUtils.waitForCondition( + () => taskTreeNode.mTaskArray.length == 1, + "Added Task did not appear" + ); + + // Last added task is automatically selected so verify detail window data. + Assert.equal(document.getElementById("calendar-task-details-title").textContent, TITLE); + + // Open added task + // Double-click on completion checkbox is ignored as opening action, so don't + // click at immediate left where the checkbox is located. + let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit"); + let treeChildren = document.querySelector("#calendar-task-tree .calendar-task-treechildren"); + Assert.ok(treeChildren); + EventUtils.synthesizeMouse(treeChildren, 50, 0, { clickCount: 2 }, window); + + await eventWindowPromise; + await execEventDialogCallback(async (taskWindow, iframeWindow) => { + // Verify calendar. + Assert.equal(iframeWindow.document.getElementById("item-calendar").value, "Test"); + + await setData(taskWindow, iframeWindow, { + status: "needs-action", + percent: PERCENTCOMPLETE, + description: DESCRIPTION, + }); + + await saveAndCloseItemDialog(taskWindow); + }); + + Assert.less(taskTreeNode.mTaskArray.length, 2, "Should not have added task"); + Assert.greater(taskTreeNode.mTaskArray.length, 0, "Should not have removed task"); + + // Verify description and status in details pane. + await TestUtils.waitForCondition(() => { + let desc = document.getElementById("calendar-task-details-description"); + return desc && desc.contentDocument.body.innerText == DESCRIPTION; + }, "Calendar task description"); + Assert.equal(document.getElementById("calendar-task-details-status").textContent, "Needs Action"); + + // This is a hack. + taskTreeNode.getTaskAtRow(0).calendar.setProperty("capabilities.priority.supported", true); + + // Set high priority and verify it in detail pane. + EventUtils.synthesizeMouseAtCenter(document.getElementById("task-actions-priority"), {}, window); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, MID_SLEEP)); + + let priorityMenu = document.querySelector( + "#task-actions-priority-menupopup > .priority-1-menuitem" + ); + Assert.ok(priorityMenu); + EventUtils.synthesizeMouseAtCenter(priorityMenu, {}, window); + await TestUtils.waitForCondition( + () => !document.getElementById("calendar-task-details-priority-high").hidden, + "#calendar-task-details-priority-high did not show" + ); + + // Verify that tooltip shows status, priority and percent complete. + let toolTipNode = document.getElementById("taskTreeTooltip"); + toolTipNode.ownerGlobal.showToolTip(toolTipNode, taskTreeNode.getTaskAtRow(0)); + + function getTooltipDescription(index) { + return toolTipNode.querySelector( + `.tooltipHeaderTable > tr:nth-of-type(${index}) > .tooltipHeaderDescription` + ).textContent; + } + + // Name + Assert.equal(getTooltipDescription(1), TITLE); + // Calendar + Assert.equal(getTooltipDescription(2), "Test"); + // Priority + Assert.equal(getTooltipDescription(3), "High"); + // Status + Assert.equal(getTooltipDescription(4), "Needs Action"); + // Complete + Assert.equal(getTooltipDescription(5), PERCENTCOMPLETE + "%"); + + // Mark completed, verify. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("task-actions-markcompleted"), + {}, + window + ); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, MID_SLEEP)); + + toolTipNode.ownerGlobal.showToolTip(toolTipNode, taskTreeNode.getTaskAtRow(0)); + Assert.equal(getTooltipDescription(4), "Completed"); + + // Delete task and verify. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("calendar-delete-task-button"), + {}, + window + ); + await TestUtils.waitForCondition( + () => taskTreeNode.mTaskArray.length == 0, + "Task did not delete" + ); + + let tabmail = document.getElementById("tabmail"); + tabmail.closeTab(tabmail.currentTabInfo); + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/views/browser_viewSwitch.js b/comm/calendar/test/browser/views/browser_viewSwitch.js new file mode 100644 index 0000000000..e730f3d797 --- /dev/null +++ b/comm/calendar/test/browser/views/browser_viewSwitch.js @@ -0,0 +1,138 @@ +/* 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/. */ + +/** + * Tests that the time indicator is restarted and scroll position is restored + * when switching tabs or views. + */ + +/** + * Wait until the view's timebar shows the given number of visible hours. + * + * @param {CalendarMultidayBaseView} view - The calendar view. + * @param {number} numHours - The expected number of visible hours. + * + * @returns {Promise} - Promise that resolves when the timebar has numHours + * visible hours. + */ +function waitForVisibleHours(view, numHours) { + // The timebar is the only scrollable child in its column (the others are + // sticky), so the difference between the scroll area's scrollTopMax and the + // timebar's clientHeight should give us the visible height. + return TestUtils.waitForCondition(() => { + let timebarHeight = view.timebar.clientHeight; + let visiblePx = timebarHeight - view.grid.scrollTopMax; + let expectPx = (numHours / 24) * timebarHeight; + // Allow up to 3px difference to accommodate accumulated integer rounding + // errors (e.g. clientHeight is a rounded integer, whilst client rectangles + // and expectPx are floating). + return Math.abs(visiblePx - expectPx) < 3; + }, `${view.id} should have ${numHours} hours visible`); +} + +/** + * Wait until the view's timebar's first visible hour is the given hour. + * + * @param {CalendarMultidayBaseView} view - The calendar view. + * @param {number} hour - The expected first visible hour. + * + * @returns {Promise} - Promise that resolves when the timebar has the given + * first visible hour. + */ +function waitForFirstVisibleHour(view, hour) { + return TestUtils.waitForCondition(() => { + let expectPx = (hour / 24) * view.timebar.clientHeight; + let actualPx = view.grid.scrollTop; + return Math.abs(actualPx - expectPx) < 3; + }, `${view.id} first visible hour should be ${hour}`); +} + +/** + * Perform a scroll on the view by one hour. + * + * @param {CalendarMultidayBaseView} view - The calendar view to scroll. + * @param {boolean} scrollDown - Whether to scroll down, otherwise scrolls up. + */ +async function doScroll(view, scrollDown) { + let scrollPromise = BrowserTestUtils.waitForEvent(view.grid, "scroll"); + let viewRect = view.getBoundingClientRect(); + EventUtils.synthesizeWheel( + view.grid, + viewRect.width / 2, + viewRect.height / 2, + { deltaY: scrollDown ? 1 : -1, deltaMode: WheelEvent.DOM_DELTA_LINE }, + window + ); + await scrollPromise; +} + +add_task(async function () { + let expectedVisibleHours = 3; + let expectedStartHour = 3; + + let tabmail = document.getElementById("tabmail"); + Assert.equal(tabmail.tabInfo.length, 1); + + Assert.equal(Services.prefs.getIntPref("calendar.view.daystarthour"), expectedStartHour); + Assert.equal(Services.prefs.getIntPref("calendar.view.dayendhour"), 12); + Assert.equal(Services.prefs.getIntPref("calendar.view.visiblehours"), expectedVisibleHours); + + // Open the day view, check the display matches the prefs. + + await CalendarTestUtils.setCalendarView(window, "day"); + + let dayView = document.getElementById("day-view"); + + await waitForFirstVisibleHour(dayView, expectedStartHour); + await waitForVisibleHours(dayView, expectedVisibleHours); + + // Scroll down 3 hours. We'll check this scroll position later. + await doScroll(dayView, true); + await waitForFirstVisibleHour(dayView, expectedStartHour + 1); + + await doScroll(dayView, true); + await doScroll(dayView, true); + await waitForFirstVisibleHour(dayView, expectedStartHour + 3); + await waitForVisibleHours(dayView, expectedVisibleHours); + + // Open the week view, check the display matches the prefs. + + await CalendarTestUtils.setCalendarView(window, "week"); + + let weekView = document.getElementById("week-view"); + + await waitForFirstVisibleHour(weekView, expectedStartHour); + await waitForVisibleHours(weekView, expectedVisibleHours); + + // Scroll up 1 hour. + await doScroll(weekView, false); + await waitForFirstVisibleHour(weekView, expectedStartHour - 1); + await waitForVisibleHours(weekView, expectedVisibleHours); + + // Go back to the day view, check the timer and scroll position. + + await CalendarTestUtils.setCalendarView(window, "day"); + + await waitForFirstVisibleHour(dayView, expectedStartHour + 3); + await waitForVisibleHours(dayView, expectedVisibleHours); + + // Switch away from the calendar tab. + + tabmail.switchToTab(0); + + // Switch back to the calendar tab. Check scroll position. + + tabmail.switchToTab(1); + Assert.equal(window.currentView().id, "day-view"); + + await waitForFirstVisibleHour(dayView, expectedStartHour + 3); + await waitForVisibleHours(dayView, expectedVisibleHours); + + // Go back to the week view. Check scroll position. + + await CalendarTestUtils.setCalendarView(window, "week"); + + await waitForFirstVisibleHour(weekView, expectedStartHour - 1); + await waitForVisibleHours(weekView, expectedVisibleHours); +}); diff --git a/comm/calendar/test/browser/views/browser_weekView.js b/comm/calendar/test/browser/views/browser_weekView.js new file mode 100644 index 0000000000..0835da2f23 --- /dev/null +++ b/comm/calendar/test/browser/views/browser_weekView.js @@ -0,0 +1,81 @@ +/* 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 { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import( + "resource://testing-common/calendar/ItemEditingHelpers.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var TITLE1 = "Week View Event"; +var TITLE2 = "Week View Event Changed"; +var DESC = "Week View Event Description"; + +add_task(async function testWeekView() { + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + await CalendarTestUtils.setCalendarView(window, "week"); + await CalendarTestUtils.goToDate(window, 2009, 1, 1); + + // Verify date. + await TestUtils.waitForCondition(() => { + let dateLabel = document.querySelector("#week-view .day-column-selected calendar-event-column"); + return dateLabel?.date.icalString == "20090101"; + }, "Date is selected"); + + // Create event at 8 AM. + // Thursday of 2009-01-05 is 4th with default settings. + let eventBox = CalendarTestUtils.weekView.getHourBoxAt(window, 5, 8); + let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent( + window, + eventBox + ); + + // Check that the start time is correct. + let someDate = cal.createDateTime(); + someDate.resetTo(2009, 0, 5, 8, 0, 0, cal.dtz.UTC); + + let startPicker = iframeDocument.getElementById("event-starttime"); + Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate)); + Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate)); + + // Fill in title, description and calendar. + await setData(dialogWindow, iframeWindow, { + title: TITLE1, + description: DESC, + calendar: "Test", + }); + + await saveAndCloseItemDialog(dialogWindow); + + // If it was created successfully, it can be opened. + ({ dialogWindow, iframeWindow } = await CalendarTestUtils.weekView.editEventAt(window, 5, 1)); + // Change title and save changes. + await setData(dialogWindow, iframeWindow, { title: TITLE2 }); + await saveAndCloseItemDialog(dialogWindow); + + // Check if name was saved. + let eventName; + await TestUtils.waitForCondition(() => { + eventBox = CalendarTestUtils.weekView.getEventBoxAt(window, 5, 1); + if (!eventBox) { + return false; + } + eventName = eventBox.querySelector(".event-name-label").textContent; + return eventName == TITLE2; + }, "event name did not update in time"); + + Assert.equal(eventName, TITLE2); + + // Delete event. + EventUtils.synthesizeMouseAtCenter(eventBox, {}, window); + eventBox.focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, window); + await CalendarTestUtils.weekView.waitForNoEventBoxAt(window, 5, 1); + + Assert.ok(true, "Test ran to completion"); +}); diff --git a/comm/calendar/test/browser/views/head.js b/comm/calendar/test/browser/views/head.js new file mode 100644 index 0000000000..c0f924d9b5 --- /dev/null +++ b/comm/calendar/test/browser/views/head.js @@ -0,0 +1,13 @@ +/* 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 { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window); + +registerCleanupFunction(async () => { + await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState); +}); diff --git a/comm/calendar/test/moz.build b/comm/calendar/test/moz.build new file mode 100644 index 0000000000..8c6dc6b9db --- /dev/null +++ b/comm/calendar/test/moz.build @@ -0,0 +1,30 @@ +# vim: set filetype=python: +# 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/. + +BROWSER_CHROME_MANIFESTS += [ + "browser/browser.ini", + "browser/contextMenu/browser.ini", + "browser/eventDialog/browser.ini", + "browser/invitations/browser.ini", + "browser/preferences/browser.ini", + "browser/providers/browser.ini", + "browser/recurrence/browser.ini", + "browser/recurrence/browser_rotated.ini", + "browser/timezones/browser.ini", + "browser/views/browser.ini", +] + +TESTING_JS_MODULES.calendar += [ + "CalDAVServer.jsm", + "CalendarTestUtils.jsm", + "CalendarUtils.jsm", + "ICSServer.jsm", + "ItemEditingHelpers.jsm", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "unit/providers/xpcshell.ini", + "unit/xpcshell.ini", +] diff --git a/comm/calendar/test/unit/data/bug1790339.sql b/comm/calendar/test/unit/data/bug1790339.sql new file mode 100644 index 0000000000..ad464de995 --- /dev/null +++ b/comm/calendar/test/unit/data/bug1790339.sql @@ -0,0 +1,194 @@ +-- Data for test_bug1790339.js. There's two events here, one saved with folded ICAL strings
+-- (as libical would do) and one with unfolded ICAL strings (as ical.js does).
+--
+-- This file contains significant white-space and is deliberately saved with Windows line-endings.
+
+CREATE TABLE cal_calendar_schema_version (version INTEGER);
+CREATE TABLE cal_attendees (
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ cal_id TEXT,
+ icalString TEXT);
+CREATE TABLE cal_recurrence (item_id TEXT, cal_id TEXT, icalString TEXT);
+CREATE TABLE cal_properties (
+ item_id TEXT,
+ key TEXT,
+ value BLOB,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ cal_id TEXT);
+CREATE TABLE cal_events (
+ cal_id TEXT,
+ id TEXT,
+ time_created INTEGER,
+ last_modified INTEGER,
+ title TEXT,
+ priority INTEGER,
+ privacy TEXT,
+ ical_status TEXT,
+ flags INTEGER,
+ event_start INTEGER,
+ event_end INTEGER,
+ event_stamp INTEGER,
+ event_start_tz TEXT,
+ event_end_tz TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ alarm_last_ack INTEGER,
+ offline_journal INTEGER);
+CREATE TABLE cal_todos (
+ cal_id TEXT,
+ id TEXT,
+ time_created INTEGER,
+ last_modified INTEGER,
+ title TEXT,
+ priority INTEGER,
+ privacy TEXT,
+ ical_status TEXT,
+ flags INTEGER,
+ todo_entry INTEGER,
+ todo_due INTEGER,
+ todo_completed INTEGER,
+ todo_complete INTEGER,
+ todo_entry_tz TEXT,
+ todo_due_tz TEXT,
+ todo_completed_tz TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ alarm_last_ack INTEGER,
+ todo_stamp INTEGER,
+ offline_journal INTEGER);
+CREATE TABLE cal_tz_version (version TEXT);
+CREATE TABLE cal_metadata (cal_id TEXT, item_id TEXT, value BLOB);
+CREATE TABLE cal_alarms (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_relations (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_attachments (
+ item_id TEXT,
+ cal_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_parameters (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ key1 TEXT,
+ key2 TEXT,
+ value BLOB);
+
+INSERT INTO cal_calendar_schema_version VALUES (23);
+
+INSERT INTO cal_events (
+ cal_id,
+ id,
+ time_created,
+ last_modified,
+ title,
+ flags,
+ event_start,
+ event_end,
+ event_stamp,
+ event_start_tz,
+ event_end_tz
+) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 1663028606000000,
+ 1663030277000000,
+ 'test',
+ 86,
+ 1663032600000000,
+ 1663037100000000,
+ 1663030277000000,
+ 'Pacific/Auckland',
+ 'Pacific/Auckland'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 1663028606000000,
+ 1663030277000000,
+ 'test',
+ 86,
+ 1663032600000000,
+ 1663037100000000,
+ 1663030277000000,
+ 'Pacific/Auckland',
+ 'Pacific/Auckland'
+);
+
+INSERT INTO cal_attachments (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'ATTACH:https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central
+ /thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'ATTACH:https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central/thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2'
+);
+
+INSERT INTO cal_attendees (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=Test Person:mailto:
+ test@example.com'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=Test Person:mailto:test@example.com'
+);
+
+INSERT INTO cal_recurrence (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'RRULE:FREQ=WEEKLY;UNTIL=20220913T013000Z;INTERVAL=22;BYDAY=MO,TU,WE,TH,FR,
+ SA,SU'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'RRULE:FREQ=WEEKLY;UNTIL=20220913T013000Z;INTERVAL=22;BYDAY=MO,TU,WE,TH,FR,SA,SU'
+);
+
+INSERT INTO cal_relations (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@
+ example.com'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@example.com'
+);
+
+INSERT INTO cal_alarms (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-PT5M
+DESCRIPTION:Make sure you don''t miss this very very important event. It''s
+ essential that you don''t forget.
+END:VALARM
+'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-PT5M
+DESCRIPTION:Make sure you don''t miss this very very important event. It''s essential that you don''t forget.
+END:VALARM
+'
+);
diff --git a/comm/calendar/test/unit/data/import.ics b/comm/calendar/test/unit/data/import.ics new file mode 100644 index 0000000000..b6e7a965d7 --- /dev/null +++ b/comm/calendar/test/unit/data/import.ics @@ -0,0 +1,24 @@ +BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:Event One
+DTSTART:20190101T150000
+DTEND:20190101T160000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Four
+DTSTART:20190101T180000
+DTEND:20190101T190000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Three
+DTSTART:20190101T170000
+DTEND:20190101T180000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Two
+DTSTART:20190101T160000
+DTEND:20190101T170000
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/unit/head.js b/comm/calendar/test/unit/head.js new file mode 100644 index 0000000000..2b7b9c99fa --- /dev/null +++ b/comm/calendar/test/unit/head.js @@ -0,0 +1,337 @@ +/* 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 { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); +var { FileUtils } = ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs"); + +var { updateAppInfo } = ChromeUtils.importESModule("resource://testing-common/AppInfo.sys.mjs"); + +ChromeUtils.defineModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); + +updateAppInfo(); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +function createDate(aYear, aMonth, aDay, aHasTime, aHour, aMinute, aSecond, aTimezone) { + let date = Cc["@mozilla.org/calendar/datetime;1"].createInstance(Ci.calIDateTime); + date.resetTo( + aYear, + aMonth, + aDay, + aHour || 0, + aMinute || 0, + aSecond || 0, + aTimezone || cal.dtz.UTC + ); + date.isDate = !aHasTime; + return date; +} + +function createEventFromIcalString(icalString) { + if (/^BEGIN:VCALENDAR/.test(icalString)) { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + parser.parseString(icalString); + let items = parser.getItems(); + cal.ASSERT(items.length == 1); + return items[0].QueryInterface(Ci.calIEvent); + } + let event = Cc["@mozilla.org/calendar/event;1"].createInstance(Ci.calIEvent); + event.icalString = icalString; + return event; +} + +function createTodoFromIcalString(icalString) { + let todo = Cc["@mozilla.org/calendar/todo;1"].createInstance(Ci.calITodo); + todo.icalString = icalString; + return todo; +} + +function getMemoryCal() { + return Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance( + Ci.calISyncWriteCalendar + ); +} + +function getStorageCal() { + // Whenever we get the storage calendar we need to request a profile, + // otherwise the cleanup functions will not run + do_get_profile(); + + // create URI + let db = Services.dirsvc.get("TmpD", Ci.nsIFile); + db.append("test_storage.sqlite"); + let uri = Services.io.newFileURI(db); + + // Make sure timezone service is initialized + Cc["@mozilla.org/calendar/timezone-service;1"].getService(Ci.calIStartupService).startup(null); + + // create storage calendar + let stor = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance( + Ci.calISyncWriteCalendar + ); + stor.uri = uri; + stor.id = cal.getUUID(); + return stor; +} + +/** + * Return an item property as string. + * + * @param aItem + * @param string aProp possible item properties: start, end, duration, + * generation, title, + * id, calendar, creationDate, lastModifiedTime, + * stampTime, priority, privacy, status, + * alarmLastAck, recurrenceStartDate + * and any property that can be obtained using getProperty() + */ +function getProps(aItem, aProp) { + let value = null; + switch (aProp) { + case "start": + value = aItem.startDate || aItem.entryDate || null; + break; + case "end": + value = aItem.endDate || aItem.dueDate || null; + break; + case "duration": + value = aItem.duration || null; + break; + case "generation": + value = aItem.generation; + break; + case "title": + value = aItem.title; + break; + case "id": + value = aItem.id; + break; + case "calendar": + value = aItem.calendar.id; + break; + case "creationDate": + value = aItem.creationDate; + break; + case "lastModifiedTime": + value = aItem.lastModifiedTime; + break; + case "stampTime": + value = aItem.stampTime; + break; + case "priority": + value = aItem.priority; + break; + case "privacy": + value = aItem.privacy; + break; + case "status": + value = aItem.status; + break; + case "alarmLastAck": + value = aItem.alarmLastAck; + break; + case "recurrenceStartDate": + value = aItem.recurrenceStartDate; + break; + default: + value = aItem.getProperty(aProp); + } + if (value) { + return value.toString(); + } + return null; +} + +function compareItemsSpecific(aLeftItem, aRightItem, aPropArray) { + if (!aPropArray) { + // left out: "id", "calendar", "lastModifiedTime", "generation", + // "stampTime" as these are expected to change + aPropArray = [ + "start", + "end", + "duration", + "title", + "priority", + "privacy", + "creationDate", + "status", + "alarmLastAck", + "recurrenceStartDate", + ]; + } + if (aLeftItem instanceof Ci.calIEvent) { + aLeftItem.QueryInterface(Ci.calIEvent); + } else if (aLeftItem instanceof Ci.calITodo) { + aLeftItem.QueryInterface(Ci.calITodo); + } + for (let i = 0; i < aPropArray.length; i++) { + equal(getProps(aLeftItem, aPropArray[i]), getProps(aRightItem, aPropArray[i])); + } +} + +/** + * Unfold ics lines by removing any \r\n or \n followed by a linear whitespace + * (space or htab). + * + * @param aLine The line to unfold + * @returns The unfolded line + */ +function ics_unfoldline(aLine) { + return aLine.replace(/\r?\n[ \t]/g, ""); +} + +/** + * Dedent the template string tagged with this function to make indented data + * easier to read. Usage: + * + * let data = dedent` + * This is indented data it will be unindented so that the first line has + * no leading spaces and the second is indented by two spaces. + * `; + * + * @param strings The string fragments from the template string + * @param ...values The interpolated values + * @returns The interpolated, dedented string + */ +function dedent(strings, ...values) { + let parts = []; + // Perform variable interpolation + let minIndent = Infinity; + for (let [i, string] of strings.entries()) { + let innerparts = string.split("\n"); + if (i == 0) { + innerparts.shift(); + } + if (i == strings.length - 1) { + innerparts.pop(); + } + for (let [j, ip] of innerparts.entries()) { + let match = ip.match(/^(\s*)\S*/); + if (j != 0) { + minIndent = Math.min(minIndent, match[1].length); + } + } + parts.push(innerparts); + } + + return parts + .map((part, i) => { + return ( + part + .map((line, j) => { + return j == 0 && i > 0 ? line : line.substr(minIndent); + }) + .join("\n") + (i < values.length ? values[i] : "") + ); + }) + .join(""); +} + +/** + * Read a JSON file and return the JS object + */ +function readJSONFile(aFile) { + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream); + try { + stream.init(aFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); + let bytes = NetUtil.readInputStream(stream, stream.available()); + let data = JSON.parse(new TextDecoder().decode(bytes)); + return data; + } catch (ex) { + dump("readJSONFile: Error reading JSON file: " + ex); + } finally { + stream.close(); + } + return false; +} + +function do_load_timezoneservice(callback) { + do_test_pending(); + cal.timezoneService.startup({ + onResult() { + do_test_finished(); + callback(); + }, + }); +} + +function do_load_calmgr(callback) { + do_test_pending(); + cal.manager.startup({ + onResult() { + do_test_finished(); + callback(); + }, + }); +} + +function do_calendar_startup(callback) { + let obs = { + observe() { + Services.obs.removeObserver(this, "calendar-startup-done"); + do_test_finished(); + executeSoon(callback); + }, + }; + + let startupService = Cc["@mozilla.org/calendar/startup-service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + + if (startupService.started) { + callback(); + } else { + do_test_pending(); + Services.obs.addObserver(obs, "calendar-startup-done"); + if (this._profileInitialized) { + Services.obs.notifyObservers(null, "profile-after-change", "xpcshell-do-get-profile"); + } else { + do_get_profile(true); + } + } +} + +/** + * Monkey patch the function with the name x on obj and overwrite it with func. + * The first parameter of this function is the original function that can be + * called at any time. + * + * @param obj The object the function is on. + * @param name The string name of the function. + * @param func The function to monkey patch with. + */ +function monkeyPatch(obj, x, func) { + let old = obj[x]; + obj[x] = function () { + let parent = old.bind(obj); + let args = Array.from(arguments); + args.unshift(parent); + try { + return func.apply(obj, args); + } catch (e) { + console.error(e); + throw e; + } + }; +} + +/** + * Asserts the properties of an actual extract parser result to what was + * expected. + * + * @param {object} actual - Mostly the actual output of parse(). + * @param {object} expected - The expected output. + * @param {string} level - The variable name to refer to report on. + */ +function compareExtractResults(actual, expected, level = "") { + for (let [key, value] of Object.entries(expected)) { + let qualifiedKey = [level, Array.isArray(expected) ? `[${key}]` : `.${key}`].join(""); + if (value && typeof value == "object") { + Assert.ok(actual[key], `${qualifiedKey} is not null`); + compareExtractResults(actual[key], value, qualifiedKey); + continue; + } + Assert.equal(actual[key], value, `${qualifiedKey} has value "${value}"`); + } +} diff --git a/comm/calendar/test/unit/providers/head.js b/comm/calendar/test/unit/providers/head.js new file mode 100644 index 0000000000..3c995ab31a --- /dev/null +++ b/comm/calendar/test/unit/providers/head.js @@ -0,0 +1,152 @@ +/* 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/calendar/CalendarTestUtils.jsm" +); +var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +var { PromiseUtils } = ChromeUtils.importESModule("resource://gre/modules/PromiseUtils.sys.mjs"); + +var { updateAppInfo } = ChromeUtils.importESModule("resource://testing-common/AppInfo.sys.mjs"); +updateAppInfo(); + +// 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. + +do_get_profile(); +add_setup(async () => { + await new Promise(resolve => cal.manager.startup({ onResult: resolve })); + await new Promise(resolve => cal.timezoneService.startup({ onResult: resolve })); + cal.manager.addCalendarObserver(calendarObserver); +}); + +let calendarObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + + /* calIObserver */ + + _batchCount: 0, + _batchRequired: true, + onStartBatch(calendar) { + info(`onStartBatch ${calendar?.id} ${++this._batchCount}`); + Assert.equal(calendar, this._expectedCalendar); + Assert.equal(this._batchCount, 1, "onStartBatch must not occur in a batch"); + }, + onEndBatch(calendar) { + info(`onEndBatch ${calendar?.id} ${this._batchCount--}`); + Assert.equal(calendar, this._expectedCalendar); + 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"); + Assert.equal(calendar, this._expectedCalendar); + 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 = cal.manager.createCalendar(type, Services.io.newURI(url)); + calendar.name = type + (useCache ? " with cache" : " without cache"); + calendar.id = cal.getUUID(); + calendar.setProperty("cache.enabled", useCache); + + cal.manager.registerCalendar(calendar); + calendar = cal.manager.getCalendarById(calendar.id); + calendarObserver._expectedCalendar = calendar; + + info(`Created calendar ${calendar.id}`); + return calendar; +} + +/** + * 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(); + await calendar.addItem(event); + 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 calendar.getItem("6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a"); + + let clone = event.clone(); + clone.title = "Modified event"; + + calendarObserver._onModifyItemPromise = PromiseUtils.defer(); + await calendar.modifyItem(clone, event); + await calendarObserver._onModifyItemPromise.promise; +} + +/** + * Deletes the event from runAddItem. + * + * @param {calICalendar} calendar + */ +async function runDeleteItem(calendar) { + let event = await calendar.getItem("6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a"); + + calendarObserver._onDeleteItemPromise = PromiseUtils.defer(); + await calendar.deleteItem(event); + await calendarObserver._onDeleteItemPromise.promise; +} diff --git a/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js b/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js new file mode 100644 index 0000000000..7a61973644 --- /dev/null +++ b/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js @@ -0,0 +1,201 @@ +/* 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/calendar/CalDAVServer.jsm"); + +add_setup(async function () { + CalDAVServer.open(); + await CalDAVServer.putItemInternal( + "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821.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 calendar.getItem("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); + + cal.manager.unregisterCalendar(calendar); +}); + +/** + * Tests calendars that return status 404 for "current-user-privilege-set" are + * not flagged read-only. + */ +add_task(async function testCalendarWithNoPrivSupport() { + CalDAVServer.privileges = null; + calendarObserver._onLoadPromise = PromiseUtils.defer(); + + let calendar = createCalendar("caldav", CalDAVServer.url, true); + await calendarObserver._onLoadPromise.promise; + info("calendar set-up complete"); + + Assert.ok(!calendar.readOnly, "calendar was not marked read-only"); + + cal.manager.unregisterCalendar(calendar); +}); + +/** + * Tests modifyItem() does not hang when the server reports no actual + * modifications were made. + */ +add_task(async function testModifyItemWithNoChanges() { + let event = new CalEvent(); + let calendar = createCalendar("caldav", CalDAVServer.url, false); + event.id = "6f6dd7b6-0fbd-39e4-359a-a74c4c3745bb"; + event.title = "A New Event"; + event.startDate = cal.createDateTime("20200303T205500Z"); + event.endDate = cal.createDateTime("20200303T210200Z"); + await calendar.addItem(event); + + let clone = event.clone(); + clone.title = "A Modified Event"; + + let putItemInternal = CalDAVServer.putItemInternal; + CalDAVServer.putItemInternal = () => {}; + + let modifiedEvent = await calendar.modifyItem(clone, event); + CalDAVServer.putItemInternal = putItemInternal; + + Assert.ok(modifiedEvent, "an event was returned"); + Assert.equal(modifiedEvent.title, event.title, "the un-modified event is returned"); + + await calendar.deleteItem(modifiedEvent); + cal.manager.unregisterCalendar(calendar); +}); + +/** + * Tests that an error response from the server when syncing doesn't delete + * items from the local calendar. + */ +add_task(async function testSyncError1() { + 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 calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"), + "item should exist when first connected" + ); + + info("syncing with rate limit error"); + CalDAVServer.throwRateLimitErrors = true; + calendarObserver._onLoadPromise = PromiseUtils.defer(); + calendar.refresh(); + await calendarObserver._onLoadPromise.promise; + CalDAVServer.throwRateLimitErrors = false; + info("sync with rate limit error complete"); + + Assert.equal( + calendar.getProperty("currentStatus"), + Cr.NS_OK, + "calendar should not be in an error state" + ); + Assert.equal(calendar.getProperty("disabled"), null, "calendar should not be disabled"); + Assert.ok( + await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"), + "item should still exist after error response" + ); + + info("syncing without rate limit error"); + calendarObserver._onLoadPromise = PromiseUtils.defer(); + calendar.refresh(); + await calendarObserver._onLoadPromise.promise; + info("sync without rate limit error complete"); + + Assert.ok( + await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"), + "item should still exist after successful sync" + ); + + cal.manager.unregisterCalendar(calendar); +}); + +/** + * Tests that multiple pages of item responses from the server when syncing + * doesn't result in items being deleted from the local calendar. + * + * The server has a page size of 3, although this test should pass regardless + * of the page size. + */ +add_task(async function testSyncError2() { + // Add some items to the server so multiple requests are required to get + // them all. There's already one item on the server. + for (let i = 0; i < 3; i++) { + await CalDAVServer.putItemInternal( + `fake-uid-${i}.ics`, + CalendarTestUtils.dedent` + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:fake-uid-${i} + SUMMARY:event ${i} + DTSTART:20210401T120000Z + DTEND:20210401T130000Z + END:VEVENT + END:VCALENDAR + ` + ); + } + + 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"); + + let items = await calendar.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_TYPE_ALL, 0, null, null); + Assert.equal(items.length, 4, "all items added to calendar when first connected"); + + info("forced syncing with multiple pages"); + calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject.mWebdavSyncToken = null; + calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject.saveCalendarProperties(); + calendarObserver._onLoadPromise = PromiseUtils.defer(); + calendar.refresh(); + await calendarObserver._onLoadPromise.promise; + info("forced sync with multiple pages complete"); + + items = await calendar.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_TYPE_ALL, 0, null, null); + Assert.equal(items.length, 4, "all items still in calendar after forced refresh"); + + cal.manager.unregisterCalendar(calendar); + + // Delete the added items. + for (let i = 0; i < 3; i++) { + CalDAVServer.deleteItemInternal(`fake-uid-${i}.ics`); + } +}); diff --git a/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js b/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js new file mode 100644 index 0000000000..025a1ba871 --- /dev/null +++ b/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js @@ -0,0 +1,96 @@ +/* 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/calendar/CalDAVServer.jsm"); + +add_setup(async function () { + CalDAVServer.open(); + await CalDAVServer.putItemInternal( + "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821.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 calendar.getItem("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); + + cal.manager.unregisterCalendar(calendar); +}); + +/** + * Tests calendars that return status 404 for "current-user-privilege-set" are + * not flagged read-only. + */ +add_task(async function testCalendarWithNoPrivSupport() { + CalDAVServer.privileges = null; + calendarObserver._onLoadPromise = PromiseUtils.defer(); + + let calendar = createCalendar("caldav", CalDAVServer.url, false); + await calendarObserver._onLoadPromise.promise; + info("calendar set-up complete"); + + Assert.ok(!calendar.readOnly, "calendar was not marked read-only"); + + cal.manager.unregisterCalendar(calendar); +}); + +/** + * Tests modifyItem() does not hang when the server reports no actual + * modifications were made. + */ +add_task(async function testModifyItemWithNoChanges() { + let event = new CalEvent(); + let calendar = createCalendar("caldav", CalDAVServer.url, false); + event.id = "6f6dd7b6-0fbd-39e4-359a-a74c4c3745bb"; + event.title = "A New Event"; + event.startDate = cal.createDateTime("20200303T205500Z"); + event.endDate = cal.createDateTime("20200303T210200Z"); + await calendar.addItem(event); + + let clone = event.clone(); + clone.title = "A Modified Event"; + + let putItemInternal = CalDAVServer.putItemInternal; + CalDAVServer.putItemInternal = () => {}; + + let modifiedEvent = await calendar.modifyItem(clone, event); + CalDAVServer.putItemInternal = putItemInternal; + + Assert.ok(modifiedEvent, "an event was returned"); + Assert.equal(modifiedEvent.title, event.title, "the un-modified event is returned"); + + await calendar.deleteItem(modifiedEvent); + cal.manager.unregisterCalendar(calendar); +}); diff --git a/comm/calendar/test/unit/providers/test_icsCalendar_cached.js b/comm/calendar/test/unit/providers/test_icsCalendar_cached.js new file mode 100644 index 0000000000..9bc1b127da --- /dev/null +++ b/comm/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/calendar/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 calendar.getItem("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/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js b/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js new file mode 100644 index 0000000000..db5db6e99f --- /dev/null +++ b/comm/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/calendar/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 calendar.getItem("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/comm/calendar/test/unit/providers/test_storageCalendar.js b/comm/calendar/test/unit/providers/test_storageCalendar.js new file mode 100644 index 0000000000..778f9f9251 --- /dev/null +++ b/comm/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/comm/calendar/test/unit/providers/xpcshell.ini b/comm/calendar/test/unit/providers/xpcshell.ini new file mode 100644 index 0000000000..272fef7c78 --- /dev/null +++ b/comm/calendar/test/unit/providers/xpcshell.ini @@ -0,0 +1,11 @@ +[default] +head = head.js +prefs = + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + +[test_caldavCalendar_cached.js] +[test_caldavCalendar_uncached.js] +[test_icsCalendar_cached.js] +[test_icsCalendar_uncached.js] +[test_storageCalendar.js] diff --git a/comm/calendar/test/unit/test_CalendarFileImporter.js b/comm/calendar/test/unit/test_CalendarFileImporter.js new file mode 100644 index 0000000000..358a82942d --- /dev/null +++ b/comm/calendar/test/unit/test_CalendarFileImporter.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 https://mozilla.org/MPL/2.0/. */ + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); +var { CalendarFileImporter } = ChromeUtils.import("resource:///modules/CalendarFileImporter.jsm"); + +/** + * Test CalendarFileImporter can import ics file correctly. + */ +async function test_importIcsFile() { + let importer = new CalendarFileImporter(); + + // Parse items from ics file should work. + let items = await importer.parseIcsFile(do_get_file("data/import.ics")); + equal(items.length, 4); + + // Create a temporary calendar. + let calendar = CalendarTestUtils.createCalendar(); + registerCleanupFunction(() => { + CalendarTestUtils.removeCalendar(calendar); + }); + + // Put items to the temporary calendar should work. + await importer.startImport(items, calendar); + let result = await calendar.getItemsAsArray( + Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, + cal.createDateTime("20190101T000000"), + cal.createDateTime("20190102T000000") + ); + equal(result.length, 4); +} + +function run_test() { + do_get_profile(); + + add_test(() => { + do_calendar_startup(async () => { + await test_importIcsFile(); + run_next_test(); + }); + }); +} diff --git a/comm/calendar/test/unit/test_alarm.js b/comm/calendar/test/unit/test_alarm.js new file mode 100644 index 0000000000..14b2ce76bd --- /dev/null +++ b/comm/calendar/test/unit/test_alarm.js @@ -0,0 +1,674 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +function run_tests() { + test_initial_creation(); + + test_display_alarm(); + test_email_alarm(); + test_audio_alarm(); + test_custom_alarm(); + test_repeat(); + test_xprop(); + + test_dates(); + + test_clone(); + test_immutable(); + test_serialize(); + test_strings(); +} + +function run_test() { + do_calendar_startup(run_tests); +} + +function test_initial_creation() { + dump("Testing initial creation..."); + let alarm = new CalAlarm(); + + let passed; + try { + // eslint-disable-next-line no-unused-expressions + alarm.icalString; + passed = false; + } catch (e) { + passed = true; + } + if (!passed) { + do_throw("Fresh calIAlarm should not produce a valid icalString"); + } + dump("Done\n"); +} + +function test_display_alarm() { + dump("Testing DISPLAY alarms..."); + let alarm = new CalAlarm(); + // Set ACTION to DISPLAY, make sure this was not rejected + alarm.action = "DISPLAY"; + equal(alarm.action, "DISPLAY"); + + // Set a Description, REQUIRED for ACTION:DISPLAY + alarm.description = "test"; + equal(alarm.description, "test"); + + // SUMMARY is not valid for ACTION:DISPLAY + alarm.summary = "test"; + equal(alarm.summary, null); + + // No attendees allowed + let attendee = new CalAttendee(); + attendee.id = "mailto:horst"; + + throws(() => { + // DISPLAY alarm should not be able to save attendees + alarm.addAttendee(attendee); + }, /Alarm type AUDIO\/DISPLAY may not have attendees/); + + throws(() => { + // DISPLAY alarm should not be able to save attachment + alarm.addAttachment(new CalAttachment()); + }, /Alarm type DISPLAY may not have attachments/); + + dump("Done\n"); +} + +function test_email_alarm() { + dump("Testing EMAIL alarms..."); + let alarm = new CalAlarm(); + // Set ACTION to DISPLAY, make sure this was not rejected + alarm.action = "EMAIL"; + equal(alarm.action, "EMAIL"); + + // Set a Description, REQUIRED for ACTION:EMAIL + alarm.description = "description"; + equal(alarm.description, "description"); + + // Set a Summary, REQUIRED for ACTION:EMAIL + alarm.summary = "summary"; + equal(alarm.summary, "summary"); + + // Set an offset of some sort + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration(); + + // Check for at least one attendee + let attendee1 = new CalAttendee(); + attendee1.id = "mailto:horst"; + let attendee2 = new CalAttendee(); + attendee2.id = "mailto:gustav"; + + equal(alarm.getAttendees().length, 0); + alarm.addAttendee(attendee1); + equal(alarm.getAttendees().length, 1); + alarm.addAttendee(attendee2); + equal(alarm.getAttendees().length, 2); + alarm.addAttendee(attendee1); + let addedAttendees = alarm.getAttendees(); + equal(addedAttendees.length, 2); + equal(addedAttendees[0].wrappedJSObject, attendee2); + equal(addedAttendees[1].wrappedJSObject, attendee1); + + ok(!!alarm.icalComponent.serializeToICS().match(/mailto:horst/)); + ok(!!alarm.icalComponent.serializeToICS().match(/mailto:gustav/)); + + alarm.deleteAttendee(attendee1); + equal(alarm.getAttendees().length, 1); + + alarm.clearAttendees(); + equal(alarm.getAttendees().length, 0); + + // Make sure attendees are correctly folded/imported + alarm.icalString = dedent` + BEGIN:VALARM + ACTION:EMAIL + TRIGGER;VALUE=DURATION:-PT5M + ATTENDEE:mailto:test@example.com + ATTENDEE:mailto:test@example.com + ATTENDEE:mailto:test2@example.com + END:VALARM + `; + equal(alarm.icalString.match(/ATTENDEE:mailto:test@example.com/g).length, 1); + equal(alarm.icalString.match(/ATTENDEE:mailto:test2@example.com/g).length, 1); + + // TODO test attachments + dump("Done\n"); +} + +function test_audio_alarm() { + dump("Testing AUDIO alarms..."); + let alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; + alarm.alarmDate = cal.createDateTime(); + // Set ACTION to AUDIO, make sure this was not rejected + alarm.action = "AUDIO"; + equal(alarm.action, "AUDIO"); + + // No Description for ACTION:AUDIO + alarm.description = "description"; + equal(alarm.description, null); + + // No Summary, for ACTION:AUDIO + alarm.summary = "summary"; + equal(alarm.summary, null); + + // No attendees allowed + let attendee = new CalAttendee(); + attendee.id = "mailto:horst"; + + try { + alarm.addAttendee(attendee); + do_throw("AUDIO alarm should not be able to save attendees"); + } catch (e) { + // TODO looks like this test is disabled. Why? + } + + // Test attachments + let sound = new CalAttachment(); + sound.uri = Services.io.newURI("file:///sound.wav"); + let sound2 = new CalAttachment(); + sound2.uri = Services.io.newURI("file:///sound2.wav"); + + // Adding an attachment should work + alarm.addAttachment(sound); + let addedAttachments = alarm.getAttachments(); + equal(addedAttachments.length, 1); + equal(addedAttachments[0].wrappedJSObject, sound); + ok(alarm.icalString.includes("ATTACH:file:///sound.wav")); + + // Adding twice shouldn't change anything + alarm.addAttachment(sound); + addedAttachments = alarm.getAttachments(); + equal(addedAttachments.length, 1); + equal(addedAttachments[0].wrappedJSObject, sound); + + try { + alarm.addAttachment(sound2); + do_throw("Adding a second attachment should fail for type AUDIO"); + } catch (e) { + // TODO looks like this test is disabled. Why? + } + + // Deleting should work + alarm.deleteAttachment(sound); + addedAttachments = alarm.getAttachments(); + equal(addedAttachments.length, 0); + + // As well as clearing + alarm.addAttachment(sound); + alarm.clearAttachments(); + addedAttachments = alarm.getAttachments(); + equal(addedAttachments.length, 0); + + // AUDIO alarms should only be allowing one attachment, and folding any with the same value + alarm.icalString = dedent` + BEGIN:VALARM + ACTION:AUDIO + TRIGGER;VALUE=DURATION:-PT5M + ATTACH:Basso + UID:28F8007B-FE56-442E-917C-1F4E48DD406A + X-APPLE-DEFAULT-ALARM:TRUE + ATTACH:Basso + END:VALARM + `; + equal(alarm.icalString.match(/ATTACH:Basso/g).length, 1); + + dump("Done\n"); +} + +function test_custom_alarm() { + dump("Testing X-SMS (custom) alarms..."); + let alarm = new CalAlarm(); + // Set ACTION to a custom value, make sure this was not rejected + alarm.action = "X-SMS"; + equal(alarm.action, "X-SMS"); + + // There is no restriction on DESCRIPTION for custom alarms + alarm.description = "description"; + equal(alarm.description, "description"); + + // There is no restriction on SUMMARY for custom alarms + alarm.summary = "summary"; + equal(alarm.summary, "summary"); + + // Test for attendees + let attendee1 = new CalAttendee(); + attendee1.id = "mailto:horst"; + let attendee2 = new CalAttendee(); + attendee2.id = "mailto:gustav"; + + equal(alarm.getAttendees().length, 0); + alarm.addAttendee(attendee1); + equal(alarm.getAttendees().length, 1); + alarm.addAttendee(attendee2); + equal(alarm.getAttendees().length, 2); + alarm.addAttendee(attendee1); + equal(alarm.getAttendees().length, 2); + + alarm.deleteAttendee(attendee1); + equal(alarm.getAttendees().length, 1); + + alarm.clearAttendees(); + equal(alarm.getAttendees().length, 0); + + // Test for attachments + let attach1 = new CalAttachment(); + attach1.uri = Services.io.newURI("file:///example.txt"); + let attach2 = new CalAttachment(); + attach2.uri = Services.io.newURI("file:///example2.txt"); + + alarm.addAttachment(attach1); + alarm.addAttachment(attach2); + + let addedAttachments = alarm.getAttachments(); + equal(addedAttachments.length, 2); + equal(addedAttachments[0].wrappedJSObject, attach1); + equal(addedAttachments[1].wrappedJSObject, attach2); + + alarm.deleteAttachment(attach1); + addedAttachments = alarm.getAttachments(); + equal(addedAttachments.length, 1); + + alarm.clearAttachments(); + addedAttachments = alarm.getAttachments(); + equal(addedAttachments.length, 0); +} + +// Check if any combination of REPEAT and DURATION work as expected. +function test_repeat() { + dump("Testing REPEAT and DURATION properties..."); + let alarm = new CalAlarm(); + + // Check initial value + equal(alarm.repeat, 0); + equal(alarm.repeatOffset, null); + equal(alarm.repeatDate, null); + + // Should not be able to get REPEAT when DURATION is not set + alarm.repeat = 1; + equal(alarm.repeat, 0); + + // Both REPEAT and DURATION should be accessible, when the two are set. + alarm.repeatOffset = cal.createDuration(); + notEqual(alarm.repeatOffset, null); + notEqual(alarm.repeat, 0); + + // Should not be able to get DURATION when REPEAT is not set + alarm.repeat = null; + equal(alarm.repeatOffset, null); + + // Should be able to unset alarm DURATION attribute. (REPEAT already tested above) + try { + alarm.repeatOffset = null; + } catch (e) { + do_throw("Could not set repeatOffset attribute to null" + e); + } + + // Check unset value + equal(alarm.repeat, 0); + equal(alarm.repeatOffset, null); + + // Check repeatDate + alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; + alarm.alarmDate = cal.createDateTime(); + alarm.repeat = 1; + alarm.repeatOffset = cal.createDuration(); + alarm.repeatOffset.inSeconds = 3600; + + let date = alarm.alarmDate.clone(); + date.second += 3600; + equal(alarm.repeatDate.icalString, date.icalString); + + dump("Done\n"); +} + +function test_xprop() { + dump("Testing X-Props..."); + let alarm = new CalAlarm(); + alarm.setProperty("X-PROP", "X-VALUE"); + ok(alarm.hasProperty("X-PROP")); + equal(alarm.getProperty("X-PROP"), "X-VALUE"); + alarm.deleteProperty("X-PROP"); + ok(!alarm.hasProperty("X-PROP")); + equal(alarm.getProperty("X-PROP"), null); + + // also check X-MOZ-LASTACK prop + let date = cal.createDateTime(); + alarm.setProperty("X-MOZ-LASTACK", date.icalString); + alarm.action = "DISPLAY"; + alarm.description = "test"; + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration("-PT5M"); + ok(alarm.icalComponent.serializeToICS().includes(date.icalString)); + + alarm.deleteProperty("X-MOZ-LASTACK"); + ok(!alarm.icalComponent.serializeToICS().includes(date.icalString)); + dump("Done\n"); +} + +function test_dates() { + dump("Testing alarm dates..."); + let passed; + // Initial value + let alarm = new CalAlarm(); + equal(alarm.alarmDate, null); + equal(alarm.offset, null); + + // Set an offset and check it + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + let offset = cal.createDuration("-PT5M"); + alarm.offset = offset; + equal(alarm.alarmDate, null); + equal(alarm.offset, offset); + try { + alarm.alarmDate = cal.createDateTime(); + passed = false; + } catch (e) { + passed = true; + } + if (!passed) { + do_throw("Setting alarmDate when alarm is relative should not succeed"); + } + + // Set an absolute time and check it + alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; + let alarmDate = createDate(2007, 0, 1, true, 2, 0, 0); + alarm.alarmDate = alarmDate; + equal(alarm.alarmDate.icalString, alarmDate.icalString); + equal(alarm.offset, null); + try { + alarm.offset = cal.createDuration(); + passed = false; + } catch (e) { + passed = true; + } + if (!passed) { + do_throw("Setting offset when alarm is absolute should not succeed"); + } + + dump("Done\n"); +} + +var propMap = { + related: Ci.calIAlarm.ALARM_RELATED_START, + repeat: 1, + action: "X-TEST", + description: "description", + summary: "summary", + offset: cal.createDuration("PT4M"), + repeatOffset: cal.createDuration("PT1M"), +}; +var clonePropMap = { + related: Ci.calIAlarm.ALARM_RELATED_END, + repeat: 2, + action: "X-CHANGED", + description: "description-changed", + summary: "summary-changed", + offset: cal.createDuration("PT5M"), + repeatOffset: cal.createDuration("PT2M"), +}; + +function test_immutable() { + dump("Testing immutable alarms..."); + let alarm = new CalAlarm(); + // Set up each attribute + for (let prop in propMap) { + alarm[prop] = propMap[prop]; + } + + // Set up some extra props + alarm.setProperty("X-FOO", "X-VAL"); + alarm.setProperty("X-DATEPROP", cal.createDateTime()); + alarm.addAttendee(new CalAttendee()); + + // Initial checks + ok(alarm.isMutable); + alarm.makeImmutable(); + ok(!alarm.isMutable); + alarm.makeImmutable(); + ok(!alarm.isMutable); + + // Check each attribute + for (let prop in propMap) { + try { + alarm[prop] = propMap[prop]; + } catch (e) { + equal(e.result, Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + continue; + } + do_throw("Attribute " + prop + " was writable while item was immutable"); + } + + // Functions + throws(() => { + alarm.setProperty("X-FOO", "changed"); + }, /Can not modify immutable data container/); + + throws(() => { + alarm.deleteProperty("X-FOO"); + }, /Can not modify immutable data container/); + + ok(!alarm.getProperty("X-DATEPROP").isMutable); + + dump("Done\n"); +} + +function test_clone() { + dump("Testing cloning alarms..."); + let alarm = new CalAlarm(); + // Set up each attribute + for (let prop in propMap) { + alarm[prop] = propMap[prop]; + } + + // Set up some extra props + alarm.setProperty("X-FOO", "X-VAL"); + alarm.setProperty("X-DATEPROP", cal.createDateTime()); + alarm.addAttendee(new CalAttendee()); + + // Make a copy + let newAlarm = alarm.clone(); + newAlarm.makeImmutable(); + newAlarm = newAlarm.clone(); + ok(newAlarm.isMutable); + + // Check if item is still the same + // TODO This is not quite optimal, maybe someone can find a better way to do + // the comparisons. + for (let prop in propMap) { + if (prop == "item") { + equal(alarm.item.icalString, newAlarm.item.icalString); + } else { + try { + alarm[prop].QueryInterface(Ci.nsISupports); + equal(alarm[prop].icalString, newAlarm[prop].icalString); + } catch { + equal(alarm[prop], newAlarm[prop]); + } + } + } + + // Check if changes on the cloned object do not affect the original object. + for (let prop in clonePropMap) { + newAlarm[prop] = clonePropMap[prop]; + dump("Checking " + prop + "..."); + notEqual(alarm[prop], newAlarm[prop]); + dump("OK!\n"); + break; + } + + // Check x props + alarm.setProperty("X-FOO", "BAR"); + equal(alarm.getProperty("X-FOO"), "BAR"); + let date = alarm.getProperty("X-DATEPROP"); + equal(date.isMutable, true); + + // Test xprop params + alarm.icalString = + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER:-PT15M\n" + + "X-FOO;X-PARAM=PARAMVAL:BAR\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM"; + + newAlarm = alarm.clone(); + equal(alarm.icalString, newAlarm.icalString); + + dump("Done\n"); +} + +function test_serialize() { + // most checks done by other tests, these don't fit into categories + let alarm = new CalAlarm(); + + throws( + () => { + alarm.icalComponent = cal.icsService.createIcalComponent("BARF"); + }, + /0x80070057/, + "Invalid Argument" + ); + + function addProp(name, value) { + let prop = cal.icsService.createIcalProperty(name); + prop.value = value; + comp.addProperty(prop); + } + function addActionDisplay() { + addProp("ACTION", "DISPLAY"); + } + function addActionEmail() { + addProp("ACTION", "EMAIL"); + } + function addTrigger() { + addProp("TRIGGER", "-PT15M"); + } + function addDescr() { + addProp("DESCRIPTION", "TEST"); + } + function addDuration() { + addProp("DURATION", "-PT15M"); + } + function addRepeat() { + addProp("REPEAT", "1"); + } + function addAttendee() { + addProp("ATTENDEE", "mailto:horst"); + } + function addAttachment() { + addProp("ATTACH", "data:yeah"); + } + + // All is there, should not throw + let comp = cal.icsService.createIcalComponent("VALARM"); + addActionDisplay(); + addTrigger(); + addDescr(); + addDuration(); + addRepeat(); + alarm.icalComponent = comp; + alarm.toString(); + + // Attachments and attendees + comp = cal.icsService.createIcalComponent("VALARM"); + addActionEmail(); + addTrigger(); + addDescr(); + addAttendee(); + addAttachment(); + alarm.icalComponent = comp; + alarm.toString(); + + // Missing action + throws( + () => { + comp = cal.icsService.createIcalComponent("VALARM"); + addTrigger(); + addDescr(); + alarm.icalComponent = comp; + }, + /Illegal value/, + "Invalid Argument" + ); + + // Missing trigger + throws( + () => { + comp = cal.icsService.createIcalComponent("VALARM"); + addActionDisplay(); + addDescr(); + alarm.icalComponent = comp; + }, + /Illegal value/, + "Invalid Argument" + ); + + // Missing duration with repeat + throws( + () => { + comp = cal.icsService.createIcalComponent("VALARM"); + addActionDisplay(); + addTrigger(); + addDescr(); + addRepeat(); + alarm.icalComponent = comp; + }, + /Illegal value/, + "Invalid Argument" + ); + + // Missing repeat with duration + throws( + () => { + comp = cal.icsService.createIcalComponent("VALARM"); + addActionDisplay(); + addTrigger(); + addDescr(); + addDuration(); + alarm.icalComponent = comp; + }, + /Illegal value/, + "Invalid Argument" + ); +} + +function test_strings() { + // Serializing the string shouldn't throw, but we don't really care about + // the string itself. + let alarm = new CalAlarm(); + alarm.action = "DISPLAY"; + alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; + alarm.alarmDate = cal.createDateTime(); + alarm.toString(); + + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration(); + alarm.toString(); + alarm.toString(new CalTodo()); + + alarm.related = Ci.calIAlarm.ALARM_RELATED_END; + alarm.offset = cal.createDuration(); + alarm.toString(); + alarm.toString(new CalTodo()); + + alarm.offset = cal.createDuration("P1D"); + alarm.toString(); + + alarm.offset = cal.createDuration("PT1H"); + alarm.toString(); + + alarm.offset = cal.createDuration("-PT1H"); + alarm.toString(); +} diff --git a/comm/calendar/test/unit/test_alarmservice.js b/comm/calendar/test/unit/test_alarmservice.js new file mode 100644 index 0000000000..df61dc029b --- /dev/null +++ b/comm/calendar/test/unit/test_alarmservice.js @@ -0,0 +1,606 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); +var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +var EXPECT_NONE = 0; +var EXPECT_FIRED = 1; +var EXPECT_TIMER = 2; + +function do_check_xor(a, b, aMessage) { + return ok((a && !b) || (!a && b), aMessage); +} + +var alarmObserver = { + QueryInterface: ChromeUtils.generateQI(["calIAlarmServiceObserver"]), + + service: null, + firedMap: {}, + expectedMap: {}, + pendingOps: {}, + + onAlarm(aItem, aAlarm) { + this.firedMap[aItem.hashId] = this.firedMap[aItem.hashId] || {}; + this.firedMap[aItem.hashId][aAlarm.icalString] = true; + }, + + onNotification(item) {}, + + onRemoveAlarmsByItem(aItem) { + if (aItem.hashId in this.firedMap) { + delete this.firedMap[aItem.hashId]; + } + }, + + onRemoveAlarmsByCalendar() {}, + + onAlarmsLoaded(aCalendar) { + this.checkLoadStatus(); + if (aCalendar.id in this.pendingOps) { + this.pendingOps[aCalendar.id].call(); + } + }, + + async doOnAlarmsLoaded(aCalendar) { + this.checkLoadStatus(); + if ( + aCalendar.id in this.service.mLoadedCalendars && + this.service.mLoadedCalendars[aCalendar.id] + ) { + // the calendar's alarms have already been loaded + } else { + await new Promise(resolve => { + // the calendar hasn't been fully loaded yet, set as a pending operation + this.pendingOps[aCalendar.id] = resolve; + }); + } + }, + + getTimer(aCalendarId, aItemId, aAlarmStr) { + return aCalendarId in this.service.mTimerMap && + aItemId in this.service.mTimerMap[aCalendarId] && + aAlarmStr in this.service.mTimerMap[aCalendarId][aItemId] + ? this.service.mTimerMap[aCalendarId][aItemId][aAlarmStr] + : null; + }, + + expectResult(aCalendar, aItem, aAlarm, aExpected) { + let expectedAndTitle = { + expected: aExpected, + title: aItem.title, + }; + this.expectedMap[aCalendar.id] = this.expectedMap[aCalendar.id] || {}; + this.expectedMap[aCalendar.id][aItem.hashId] = + this.expectedMap[aCalendar.id][aItem.hashId] || {}; + this.expectedMap[aCalendar.id][aItem.hashId][aAlarm.icalString] = expectedAndTitle; + }, + + expectOccurrences(aCalendar, aItem, aAlarm, aExpectedArray) { + // we need to be earlier than the first occurrence + let date = aItem.startDate.clone(); + date.second -= 1; + + for (let expected of aExpectedArray) { + let occ = aItem.recurrenceInfo.getNextOccurrence(date); + occ.QueryInterface(Ci.calIEvent); + date = occ.startDate; + this.expectResult(aCalendar, occ, aAlarm, expected); + } + }, + + checkExpected(aMessage) { + for (let calId in this.expectedMap) { + for (let id in this.expectedMap[calId]) { + for (let icalString in this.expectedMap[calId][id]) { + let expectedAndTitle = this.expectedMap[calId][id][icalString]; + // if no explicit message has been passed, take the item title + let message = typeof aMessage == "string" ? aMessage : expectedAndTitle.title; + // only alarms expected as fired should exist in our fired alarm map + do_check_xor( + expectedAndTitle.expected != EXPECT_FIRED, + id in this.firedMap && icalString in this.firedMap[id], + message + "; check fired" + ); + // only alarms expected as timers should exist in the service's timer map + do_check_xor( + expectedAndTitle.expected != EXPECT_TIMER, + !!this.getTimer(calId, id, icalString), + message + "; check timer" + ); + } + } + } + }, + + checkLoadStatus() { + for (let calId in this.service.mLoadedCalendars) { + if (!this.service.mLoadedCalendars[calId]) { + // at least one calendar hasn't finished loading alarms + ok(this.service.isLoading); + return; + } + } + ok(!this.service.isLoading); + }, + + clear() { + this.firedMap = {}; + this.pendingOps = {}; + this.expectedMap = {}; + }, +}; + +add_setup(async function () { + do_get_profile(); + await new Promise(resolve => + do_calendar_startup(() => { + alarmObserver.service = Cc["@mozilla.org/calendar/alarm-service;1"].getService( + Ci.calIAlarmService + ).wrappedJSObject; + ok(!alarmObserver.service.mStarted); + alarmObserver.service.startup(null); + ok(alarmObserver.service.mStarted); + + // we need to replace the existing observers with our observer + for (let obs of alarmObserver.service.mObservers.values()) { + alarmObserver.service.removeObserver(obs); + } + alarmObserver.service.addObserver(alarmObserver); + resolve(); + }) + ); +}); + +function createAlarmFromDuration(aOffset) { + let alarm = new CalAlarm(); + + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration(aOffset); + + return alarm; +} + +function createEventWithAlarm(aCalendar, aStart, aEnd, aOffset, aRRule) { + let alarm = null; + let item = new CalEvent(); + + item.id = cal.getUUID(); + item.calendar = aCalendar; + item.startDate = aStart || cal.dtz.now(); + item.endDate = aEnd || cal.dtz.now(); + if (aOffset) { + alarm = createAlarmFromDuration(aOffset); + item.addAlarm(alarm); + } + if (aRRule) { + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule(aRRule)); + } + return [item, alarm]; +} + +async function addTestItems(aCalendar) { + let item, alarm; + + // alarm on an item starting more than a month in the past should not fire + let date = cal.dtz.now(); + date.day -= 32; + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "P7D"); + item.title = "addTestItems Test 1"; + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE); + await aCalendar.addItem(item); + + // alarm 15 minutes ago should fire + date = cal.dtz.now(); + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M"); + item.title = "addTestItems Test 2"; + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED); + await aCalendar.addItem(item); + + // alarm within 6 hours should have a timer set + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT1H"); + item.title = "addTestItems Test 3"; + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER); + await aCalendar.addItem(item); + + // alarm more than 6 hours in the future should not have a timer set + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT7H"); + item.title = "addTestItems Test 4"; + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE); + await aCalendar.addItem(item); + + // test multiple alarms on an item + [item, alarm] = createEventWithAlarm(aCalendar, date, date); + item.title = "addTestItems Test 5"; + const firedOffsets = [ + ["-PT1H", EXPECT_FIRED], + ["-PT15M", EXPECT_FIRED], + ["PT1H", EXPECT_TIMER], + ["PT7H", EXPECT_NONE], + ["P7D", EXPECT_NONE], + ]; + + firedOffsets.forEach(([offset, expected]) => { + alarm = createAlarmFromDuration(offset); + item.addAlarm(alarm); + alarmObserver.expectResult(aCalendar, item, alarm, expected); + }); + await aCalendar.addItem(item); + + // Bug 1344068 - Alarm with lastAck on exception, should take parent lastAck. + // Alarm 15 minutes ago should fire. + date = cal.dtz.now(); + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=DAILY;COUNT=1"); + item.title = "addTestItems Test 6"; + + // Parent item is acknowledged before alarm, so it should fire. + let lastAck = item.startDate.clone(); + lastAck.hour -= 1; + item.alarmLastAck = lastAck; + + // Occurrence is acknowledged after alarm (start date), so if the alarm + // service wrongly uses the exception occurrence then we catch it. + let occ = item.recurrenceInfo.getOccurrenceFor(item.startDate); + occ.alarmLastAck = item.startDate.clone(); + item.recurrenceInfo.modifyException(occ, true); + + alarmObserver.expectOccurrences(aCalendar, item, alarm, [EXPECT_FIRED]); + await aCalendar.addItem(item); + + // daily repeating event starting almost 2 full days ago. The alarms on the first 2 occurrences + // should fire, and a timer should be set for the next occurrence only + date = cal.dtz.now(); + date.hour -= 47; + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=DAILY"); + item.title = "addTestItems Test 7"; + alarmObserver.expectOccurrences(aCalendar, item, alarm, [ + EXPECT_FIRED, + EXPECT_FIRED, + EXPECT_TIMER, + EXPECT_NONE, + EXPECT_NONE, + ]); + await aCalendar.addItem(item); + + // monthly repeating event starting 2 months and a day ago. The alarms on the first 2 occurrences + // should be ignored, the alarm on the next occurrence only should fire. + // Missing recurrences of the event in particular days of the year generate exceptions to the + // regular sequence of alarms. + date = cal.dtz.now(); + let statusAlarmSequences = { + reg: [EXPECT_NONE, EXPECT_NONE, EXPECT_FIRED, EXPECT_NONE, EXPECT_NONE], + excep1: [EXPECT_NONE, EXPECT_FIRED, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE], + excep2: [EXPECT_NONE, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE], + }; + let expected = []; + if (date.day == 1) { + // Exceptions for missing occurrences on months with 30 days when the event starts on 31st. + let sequence = [ + "excep1", + "reg", + "excep2", + "excep1", + "reg", + "excep1", + "reg", + "excep1", + "reg", + "excep2", + "excep1", + "reg", + ][date.month]; + expected = statusAlarmSequences[sequence]; + } else if (date.day == 30 && (date.month == 2 || date.month == 3)) { + // Exceptions for missing occurrences or different start date caused by February. + let leapYear = date.endOfYear.yearday == 366; + expected = leapYear ? statusAlarmSequences.reg : statusAlarmSequences.excep1; + } else if (date.day == 31 && date.month == 2) { + // Exceptions for missing occurrences caused by February. + expected = statusAlarmSequences.excep1; + } else { + // Regular sequence of alarms expected for all the others days. + expected = statusAlarmSequences.reg; + } + date.month -= 2; + date.day -= 1; + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=MONTHLY"); + item.title = "addTestItems Test 8"; + alarmObserver.expectOccurrences(aCalendar, item, alarm, expected); + await aCalendar.addItem(item); +} + +async function doModifyItemTest(aCalendar) { + let item, alarm; + + // begin with item starting before the alarm date range + let date = cal.dtz.now(); + date.day -= 32; + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT0S"); + await aCalendar.addItem(item); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE); + alarmObserver.checkExpected("doModifyItemTest Test 1"); + + // move event into the fired range + let oldItem = item.clone(); + date.day += 31; + item.startDate = date.clone(); + item.generation++; + await aCalendar.modifyItem(item, oldItem); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED); + alarmObserver.checkExpected("doModifyItemTest Test 2"); + + // move event into the timer range + oldItem = item.clone(); + date.hour += 25; + item.startDate = date.clone(); + item.generation++; + await aCalendar.modifyItem(item, oldItem); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER); + alarmObserver.checkExpected("doModifyItemTest Test 3"); + + // move event past the timer range + oldItem = item.clone(); + date.hour += 6; + item.startDate = date.clone(); + item.generation++; + await aCalendar.modifyItem(item, oldItem); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE); + alarmObserver.checkExpected("doModifyItemTest Test 4"); + + // re-move the event in the timer range and verify that the timer + // doesn't change when the timezone changes to floating (bug 1300493). + oldItem = item.clone(); + date.hour -= 6; + item.startDate = date.clone(); + item.generation++; + await aCalendar.modifyItem(item, oldItem); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER); + alarmObserver.checkExpected("doModifyItemTest Test 5"); + let oldTimer = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString); + oldItem = item.clone(); + // change the timezone to floating + item.startDate.timezone = cal.dtz.floating; + item.generation++; + await aCalendar.modifyItem(item, oldItem); + // the alarm must still be timer and with the same value (apart from milliseconds) + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER); + alarmObserver.checkExpected("doModifyItemTest Test 5, floating timezone"); + let newTimer = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString); + ok( + newTimer.delay - oldTimer.delay <= 1000, + "doModifyItemTest Test 5, floating timezone; check timer value" + ); +} + +async function doDeleteItemTest(aCalendar) { + alarmObserver.clear(); + let item, alarm; + let item2, alarm2; + + // create a fired alarm and a timer + let date = cal.dtz.now(); + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT5M"); + [item2, alarm2] = createEventWithAlarm(aCalendar, date, date, "PT1H"); + item.title = "doDeleteItemTest item Test 1"; + item2.title = "doDeleteItemTest item2 Test 1"; + await aCalendar.addItem(item); + await aCalendar.addItem(item2); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED); + alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_TIMER); + alarmObserver.checkExpected(); + + // item deletion should clear the fired alarm and timer + await aCalendar.deleteItem(item); + await aCalendar.deleteItem(item2); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE); + alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_NONE); + alarmObserver.checkExpected("doDeleteItemTest, cleared fired alarm and timer"); +} + +async function doAcknowledgeTest(aCalendar) { + alarmObserver.clear(); + let item, alarm; + let item2, alarm2; + + // create the fired alarms + let date = cal.dtz.now(); + [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT5M"); + [item2, alarm2] = createEventWithAlarm(aCalendar, date, date, "-PT5M"); + item.title = "doAcknowledgeTest item Test 1"; + item2.title = "doAcknowledgeTest item2 Test 1"; + await aCalendar.addItem(item); + await aCalendar.addItem(item2); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED); + alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_FIRED); + alarmObserver.checkExpected(); + + // test snooze alarm + alarmObserver.service.snoozeAlarm(item, alarm, cal.createDuration("PT1H")); + alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER); + alarmObserver.checkExpected("doAcknowledgeTest, test snooze alarm"); + + // the snoozed alarm timer delay should be close to an hour + let tmr = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString); + ok( + Math.abs(tmr.delay - 3600000) <= 1000, + "doAcknowledgeTest, snoozed alarm timer delay close to an hour" + ); + + // test dismiss alarm + alarmObserver.service.dismissAlarm(item2, alarm2); + alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_NONE); + alarmObserver.checkExpected("doAcknowledgeTest, test dismiss alarm"); +} + +async function doRunTest(aOnCalendarCreated) { + alarmObserver.clear(); + + let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + memory.id = cal.getUUID(); + + if (aOnCalendarCreated) { + await aOnCalendarCreated(memory); + } + + cal.manager.registerCalendar(memory); + await alarmObserver.doOnAlarmsLoaded(memory); + return memory; +} + +/** + * Test the initial alarm loading of a calendar with existing data. + */ +add_task(async function test_loadCalendar() { + await doRunTest(async memory => addTestItems(memory)); + alarmObserver.checkExpected(); +}); + +/** + * Test adding alarm data to a calendar already registered. + */ +add_task(async function test_addItems() { + let memory = await doRunTest(); + await addTestItems(memory); + alarmObserver.checkExpected(); +}); + +/** + * Test response to modification of alarm data. + */ +add_task(async function test_modifyItems() { + let memory = await doRunTest(); + await doModifyItemTest(memory); + await doDeleteItemTest(memory); + await doAcknowledgeTest(memory); +}); + +/** + * Test an array of timers has expected delay values. + * + * @param {nsITimer[]} timers - An array of nsITimer. + * @param {number[]} expected - Expected delays in seconds. + */ +function matchTimers(timers, expected) { + let delays = timers.map(timer => timer.delay / 1000); + let matched = true; + for (let i = 0; i < delays.length; i++) { + if (Math.abs(delays[i] - expected[i]) > 2) { + matched = false; + + break; + } + } + ok(matched, `Delays=${delays} should match Expected=${expected}`); +} + +/** + * Test notification timers are set up correctly when add/modify/remove a + * calendar item. + */ +add_task(async function test_notificationTimers() { + let memory = await doRunTest(); + // Add an item. + let date = cal.dtz.now(); + date.hour += 1; + let item, oldItem; + [item] = createEventWithAlarm(memory, date, date, null); + await memory.addItem(item); + equal( + alarmObserver.service.mNotificationTimerMap[item.calendar.id], + undefined, + "should have no notification timer" + ); + + // Set the pref to have one notifiaction. + Services.prefs.setCharPref("calendar.notifications.times", "-PT1H"); + oldItem = item.clone(); + date.hour += 1; + item.startDate = date.clone(); + item.generation++; + await memory.modifyItem(item, oldItem); + // Should have one notification timer + matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [3600]); + + // Set the pref to have three notifiactions. + Services.prefs.setCharPref("calendar.notifications.times", "END:PT2M,PT0M,END:-PT30M,-PT5M"); + oldItem = item.clone(); + date.hour -= 1; + item.startDate = date.clone(); + date.hour += 1; + item.endDate = date.clone(); + item.generation++; + await memory.modifyItem(item, oldItem); + // Should have four notification timers. + matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [ + 3300, // 55 minutes + 3600, // 60 minutes + 5400, // 90 minutes, which is 30 minutes before the end (END:-PT30M) + 7320, // 122 minutes, which is 2 minutes after the end (END:PT2M) + ]); + + alarmObserver.service.removeFiredNotificationTimer(item); + // Should have three notification timers. + matchTimers( + alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], + [3600, 5400, 7320] + ); + + await memory.deleteItem(item); + equal( + alarmObserver.service.mNotificationTimerMap[item.calendar.id], + undefined, + "notification timers should be removed" + ); + + Services.prefs.clearUserPref("calendar.notifications.times"); +}); + +/** + * Test notification timers are set up correctly according to the calendar level + * notifications.times config. + */ +add_task(async function test_calendarLevelNotificationTimers() { + let loaded = false; + let item; + let memory = await doRunTest(); + + if (!loaded) { + loaded = true; + // Set the global pref to have one notifiaction. + Services.prefs.setCharPref("calendar.notifications.times", "-PT1H"); + + // Add an item. + let date = cal.dtz.now(); + date.hour += 2; + [item] = createEventWithAlarm(memory, date, date, null); + await memory.addItem(item); + + // Should have one notification timer. + matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [3600]); + // Set the calendar level pref to have two notification timers. + memory.setProperty("notifications.times", "-PT5M,PT0M"); + } + + await TestUtils.waitForCondition( + () => alarmObserver.service.mNotificationTimerMap[item.calendar.id]?.[item.hashId].length == 2 + ); + // Should have two notification timers + matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [ + 6900, // 105 minutes + 7200, // 120 minutes + ]); + + Services.prefs.clearUserPref("calendar.notifications.times"); +}); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("calendar.notifications.times"); +}); diff --git a/comm/calendar/test/unit/test_alarmutils.js b/comm/calendar/test/unit/test_alarmutils.js new file mode 100644 index 0000000000..e51453367d --- /dev/null +++ b/comm/calendar/test/unit/test_alarmutils.js @@ -0,0 +1,171 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +function run_test() { + do_calendar_startup(run_next_test); +} + +add_task(async function test_setDefaultValues_events() { + let item, alarm; + + Services.prefs.setIntPref("calendar.alarms.onforevents", 1); + Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "hours"); + Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 60); + item = new CalEvent(); + cal.alarms.setDefaultValues(item); + alarm = item.getAlarms()[0]; + ok(alarm); + equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START); + equal(alarm.action, "DISPLAY"); + equal(alarm.offset.icalString, "-P2DT12H"); + + Services.prefs.setIntPref("calendar.alarms.onforevents", 1); + Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "yards"); + Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 20); + item = new CalEvent(); + cal.alarms.setDefaultValues(item); + alarm = item.getAlarms()[0]; + ok(alarm); + equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START); + equal(alarm.action, "DISPLAY"); + equal(alarm.offset.icalString, "-PT20M"); + + Services.prefs.setIntPref("calendar.alarms.onforevents", 0); + item = new CalEvent(); + cal.alarms.setDefaultValues(item); + equal(item.getAlarms().length, 0); + + let mockCalendar = { + getProperty() { + return ["SHOUT"]; + }, + }; + + Services.prefs.setIntPref("calendar.alarms.onforevents", 1); + Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "hours"); + Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 60); + item = new CalEvent(); + item.calendar = mockCalendar; + cal.alarms.setDefaultValues(item); + alarm = item.getAlarms()[0]; + ok(alarm); + equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START); + equal(alarm.action, "SHOUT"); + equal(alarm.offset.icalString, "-P2DT12H"); + + Services.prefs.clearUserPref("calendar.alarms.onforevents"); + Services.prefs.clearUserPref("calendar.alarms.eventalarmunit"); + Services.prefs.clearUserPref("calendar.alarms.eventalarmlen"); +}); + +add_task(async function test_setDefaultValues_tasks() { + let item, alarm; + let calnow = cal.dtz.now; + let nowDate = cal.createDateTime("20150815T120000"); + cal.dtz.now = function () { + return nowDate; + }; + + Services.prefs.setIntPref("calendar.alarms.onfortodos", 1); + Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "hours"); + Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 60); + item = new CalTodo(); + equal(item.entryDate, null); + cal.alarms.setDefaultValues(item); + alarm = item.getAlarms()[0]; + ok(alarm); + equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START); + equal(alarm.action, "DISPLAY"); + equal(alarm.offset.icalString, "-P2DT12H"); + equal(item.entryDate.icalString, nowDate.icalString); + + Services.prefs.setIntPref("calendar.alarms.onfortodos", 1); + Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "yards"); + Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 20); + item = new CalTodo(); + cal.alarms.setDefaultValues(item); + alarm = item.getAlarms()[0]; + ok(alarm); + equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START); + equal(alarm.action, "DISPLAY"); + equal(alarm.offset.icalString, "-PT20M"); + + Services.prefs.setIntPref("calendar.alarms.onfortodos", 0); + item = new CalTodo(); + cal.alarms.setDefaultValues(item); + equal(item.getAlarms().length, 0); + + let mockCalendar = { + getProperty() { + return ["SHOUT"]; + }, + }; + + Services.prefs.setIntPref("calendar.alarms.onfortodos", 1); + Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "hours"); + Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 60); + item = new CalTodo(); + item.calendar = mockCalendar; + cal.alarms.setDefaultValues(item); + alarm = item.getAlarms()[0]; + ok(alarm); + equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START); + equal(alarm.action, "SHOUT"); + equal(alarm.offset.icalString, "-P2DT12H"); + + Services.prefs.clearUserPref("calendar.alarms.onfortodos"); + Services.prefs.clearUserPref("calendar.alarms.todoalarmunit"); + Services.prefs.clearUserPref("calendar.alarms.todoalarmlen"); + cal.dtz.now = calnow; +}); + +add_task(async function test_calculateAlarmDate() { + let item = new CalEvent(); + item.startDate = cal.createDateTime("20150815T120000"); + item.endDate = cal.createDateTime("20150815T130000"); + + let calculateAlarmDate = cal.alarms.calculateAlarmDate.bind(cal.alarms, item); + + let alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; + alarm.alarmDate = cal.createDateTime("20150815T110000"); + equal(calculateAlarmDate(alarm).icalString, "20150815T110000"); + + alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration("-PT1H"); + equal(calculateAlarmDate(alarm).icalString, "20150815T110000Z"); + + alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_END; + alarm.offset = cal.createDuration("-PT2H"); + equal(calculateAlarmDate(alarm).icalString, "20150815T110000Z"); + + item.startDate.isDate = true; + alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration("-PT1H"); + equal(calculateAlarmDate(alarm).icalString, "20150814T230000Z"); + item.startDate.isDate = false; + + item.endDate.isDate = true; + alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_END; + alarm.offset = cal.createDuration("-PT2H"); + equal(calculateAlarmDate(alarm).icalString, "20150814T220000Z"); + item.endDate.isDate = false; + + alarm = new CalAlarm(); + alarm.related = Ci.calIAlarm.ALARM_RELATED_END; + equal(calculateAlarmDate(alarm), null); +}); diff --git a/comm/calendar/test/unit/test_attachment.js b/comm/calendar/test/unit/test_attachment.js new file mode 100644 index 0000000000..6e2b935851 --- /dev/null +++ b/comm/calendar/test/unit/test_attachment.js @@ -0,0 +1,112 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + test_serialize(); + test_hashes(); + test_uriattach(); + test_binaryattach(); +} + +function test_hashes() { + let attach = new CalAttachment(); + + attach.rawData = "hello"; + let hash1 = attach.hashId; + + attach.rawData = "world"; + notEqual(hash1, attach.hashId); + + attach.rawData = "hello"; + equal(hash1, attach.hashId); + + // Setting raw data should give us a BINARY attachment + equal(attach.getParameter("VALUE"), "BINARY"); + + attach.uri = Services.io.newURI("http://hello"); + + // Setting an uri should delete the value parameter + equal(attach.getParameter("VALUE"), null); +} + +function test_uriattach() { + let attach = new CalAttachment(); + + // Attempt to set a property and check its values + let e = new CalEvent(); + // eslint-disable-next-line no-useless-concat + e.icalString = "BEGIN:VEVENT\r\n" + "ATTACH;FMTTYPE=x-moz/test:http://hello\r\n" + "END:VEVENT"; + let prop = e.icalComponent.getFirstProperty("ATTACH"); + attach.icalProperty = prop; + + notEqual(attach.getParameter("VALUE"), "BINARY"); + equal(attach.formatType, "x-moz/test"); + equal(attach.getParameter("FMTTYPE"), "x-moz/test"); + equal(attach.uri.spec, Services.io.newURI("http://hello").spec); + equal(attach.rawData, "http://hello"); +} + +function test_binaryattach() { + let attach = new CalAttachment(); + let e = new CalEvent(); + + let attachString = + "ATTACH;ENCODING=BASE64;FMTTYPE=x-moz/test2;VALUE=BINARY:aHR0cDovL2hlbGxvMg==\r\n"; + let icalString = "BEGIN:VEVENT\r\n" + attachString + "END:VEVENT"; + e.icalString = icalString; + let prop = e.icalComponent.getFirstProperty("ATTACH"); + attach.icalProperty = prop; + + equal(attach.formatType, "x-moz/test2"); + equal(attach.getParameter("FMTTYPE"), "x-moz/test2"); + equal(attach.encoding, "BASE64"); + equal(attach.getParameter("ENCODING"), "BASE64"); + equal(attach.uri, null); + equal(attach.rawData, "aHR0cDovL2hlbGxvMg=="); + equal(attach.getParameter("VALUE"), "BINARY"); + + let propIcalString = attach.icalProperty.icalString; + ok(!!propIcalString.match(/ENCODING=BASE64/)); + ok(!!propIcalString.match(/FMTTYPE=x-moz\/test2/)); + ok(!!propIcalString.match(/VALUE=BINARY/)); + ok(!!propIcalString.replace("\r\n ", "").match(/:aHR0cDovL2hlbGxvMg==/)); + + propIcalString = attach.clone().icalProperty.icalString; + + ok(!!propIcalString.match(/ENCODING=BASE64/)); + ok(!!propIcalString.match(/FMTTYPE=x-moz\/test2/)); + ok(!!propIcalString.match(/VALUE=BINARY/)); + ok(!!propIcalString.replace("\r\n ", "").match(/:aHR0cDovL2hlbGxvMg==/)); +} + +function test_serialize() { + let attach = new CalAttachment(); + attach.formatType = "x-moz/test2"; + attach.uri = Services.io.newURI("data:text/plain,"); + equal(attach.icalString, "ATTACH;FMTTYPE=x-moz/test2:data:text/plain,\r\n"); + + attach = new CalAttachment(); + attach.encoding = "BASE64"; + attach.uri = Services.io.newURI("data:text/plain,"); + equal(attach.icalString, "ATTACH;ENCODING=BASE64:data:text/plain,\r\n"); + + throws(() => { + attach.icalString = "X-STICKER:smiley"; + }, /Illegal value/); + + attach = new CalAttachment(); + attach.uri = Services.io.newURI("data:text/plain,"); + attach.setParameter("X-PROP", "VAL"); + equal(attach.icalString, "ATTACH;X-PROP=VAL:data:text/plain,\r\n"); + attach.setParameter("X-PROP", null); + equal(attach.icalString, "ATTACH:data:text/plain,\r\n"); +} diff --git a/comm/calendar/test/unit/test_attendee.js b/comm/calendar/test/unit/test_attendee.js new file mode 100644 index 0000000000..55051f720b --- /dev/null +++ b/comm/calendar/test/unit/test_attendee.js @@ -0,0 +1,318 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + test_values(); + test_serialize(); + test_properties(); + test_doubleParameters(); // Bug 875739 + test_emptyAttendee(); +} + +function test_values() { + function findAttendeesInResults(event, expectedAttendees) { + // Getting all attendees + let allAttendees = event.getAttendees(); + + equal(allAttendees.length, expectedAttendees.length); + + // Check if all expected attendees are found + for (let i = 0; i < expectedAttendees.length; i++) { + ok(allAttendees.includes(expectedAttendees[i])); + } + + // Check if all found attendees are expected + for (let i = 0; i < allAttendees.length; i++) { + ok(expectedAttendees.includes(allAttendees[i])); + } + } + function findById(event, id, a) { + let foundAttendee = event.getAttendeeById(id); + equal(foundAttendee, a); + } + function testImmutability(a, properties) { + ok(!a.isMutable); + // Check if setting a property throws. It should. + for (let i = 0; i < properties.length; i++) { + let old = a[properties[i]]; + throws(() => { + a[properties[i]] = old + 1; + }, /Can not modify immutable data container/); + + equal(a[properties[i]], old); + } + } + + // Create Attendee + let attendee1 = new CalAttendee(); + // Testing attendee set/get. + let properties = ["id", "commonName", "rsvp", "role", "participationStatus", "userType"]; + let values = ["myid", "mycn", "TRUE", "CHAIR", "DECLINED", "RESOURCE"]; + // Make sure test is valid + equal(properties.length, values.length); + + for (let i = 0; i < properties.length; i++) { + attendee1[properties[i]] = values[i]; + equal(attendee1[properties[i]], values[i]); + } + + // Create event + let event = new CalEvent(); + + // Add attendee to event + event.addAttendee(attendee1); + + // Add 2nd attendee to event. + let attendee2 = new CalAttendee(); + attendee2.id = "myid2"; + event.addAttendee(attendee2); + + // Finding by ID + findById(event, "myid", attendee1); + findById(event, "myid2", attendee2); + + findAttendeesInResults(event, [attendee1, attendee2]); + + // Making attendee immutable + attendee1.makeImmutable(); + testImmutability(attendee1, properties); + // Testing cascaded immutability (event -> attendee) + event.makeImmutable(); + testImmutability(attendee2, properties); + + // Testing cloning + let eventClone = event.clone(); + let clonedatts = eventClone.getAttendees(); + let atts = event.getAttendees(); + equal(atts.length, clonedatts.length); + + for (let i = 0; i < clonedatts.length; i++) { + // The attributes should not be equal + notEqual(atts[i], clonedatts[i]); + // But the ids should + equal(atts[i].id, clonedatts[i].id); + } + + // Make sure organizers are also cloned correctly + let attendee3 = new CalAttendee(); + attendee3.id = "horst"; + attendee3.isOrganizer = true; + let attendee4 = attendee3.clone(); + + ok(attendee4.isOrganizer); + attendee3.isOrganizer = false; + ok(attendee4.isOrganizer); +} + +function test_serialize() { + let a = new CalAttendee(); + + throws(() => { + // eslint-disable-next-line no-unused-expressions + a.icalProperty; + }, /Component not initialized/); + + a.id = "horst"; + a.commonName = "Horst"; + a.rsvp = "TRUE"; + + a.isOrganizer = false; + + a.role = "CHAIR"; + a.participationStatus = "DECLINED"; + a.userType = "RESOURCE"; + + a.setProperty("X-NAME", "X-VALUE"); + + let prop = a.icalProperty; + dump(prop.icalString); + equal(prop.value, "horst"); + equal(prop.propertyName, "ATTENDEE"); + equal(prop.getParameter("CN"), "Horst"); + equal(prop.getParameter("RSVP"), "TRUE"); + equal(prop.getParameter("ROLE"), "CHAIR"); + equal(prop.getParameter("PARTSTAT"), "DECLINED"); + equal(prop.getParameter("CUTYPE"), "RESOURCE"); + equal(prop.getParameter("X-NAME"), "X-VALUE"); + + a.isOrganizer = true; + prop = a.icalProperty; + equal(prop.value, "horst"); + equal(prop.propertyName, "ORGANIZER"); + equal(prop.getParameter("CN"), "Horst"); + equal(prop.getParameter("RSVP"), "TRUE"); + equal(prop.getParameter("ROLE"), "CHAIR"); + equal(prop.getParameter("PARTSTAT"), "DECLINED"); + equal(prop.getParameter("CUTYPE"), "RESOURCE"); + equal(prop.getParameter("X-NAME"), "X-VALUE"); +} + +function test_properties() { + let a = new CalAttendee(); + + throws(() => { + // eslint-disable-next-line no-unused-expressions + a.icalProperty; + }, /Component not initialized/); + + a.id = "horst"; + a.commonName = "Horst"; + a.rsvp = "TRUE"; + + a.isOrganizer = false; + + a.role = "CHAIR"; + a.participationStatus = "DECLINED"; + a.userType = "RESOURCE"; + + // Only X-Props should show up in the enumerator + a.setProperty("X-NAME", "X-VALUE"); + for (let [name, value] of a.properties) { + equal(name, "X-NAME"); + equal(value, "X-VALUE"); + } + + a.deleteProperty("X-NAME"); + for (let [name, value] of a.properties) { + do_throw("Unexpected property " + name + " = " + value); + } + + a.setProperty("X-NAME", "X-VALUE"); + a.setProperty("X-NAME", null); + + for (let [name, value] of a.properties) { + do_throw("Unexpected property after setting null " + name + " = " + value); + } +} + +function test_doubleParameters() { + function testParameters(aAttendees, aExpected) { + for (let attendee of aAttendees) { + let prop = attendee.icalProperty; + let parNames = []; + let parValues = []; + + // Extract the parameters + for ( + let paramName = prop.getFirstParameterName(); + paramName; + paramName = prop.getNextParameterName() + ) { + parNames.push(paramName); + parValues.push(prop.getParameter(paramName)); + } + + // Check the results + let att_n = attendee.id.substr(7, 9); + for (let parIndex in parNames) { + ok( + aExpected[att_n].param.includes(parNames[parIndex]), + "Parameter " + parNames[parIndex] + " included in " + att_n + ); + ok( + aExpected[att_n].values.includes(parValues[parIndex]), + "Value " + parValues[parIndex] + " for parameter " + parNames[parIndex] + ); + } + ok( + parNames.length == aExpected[att_n].param.length, + "Each parameter has been considered for " + att_n + ); + } + } + + // Event with attendees and organizer with one of the parameter duplicated. + let ics = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Marketcircle Inc.//Daylite 4.0//EN", + "BEGIN:VEVENT", + "DTSTART:20130529T100000", + "DTEND:20130529T110000", + "SUMMARY:Summary", + "CREATED:20130514T124220Z", + "DTSTAMP:20130524T101307Z", + "UID:9482DDFA-07B4-44B9-8228-ED4BC17BA278", + "SEQUENCE:3", + "ORGANIZER;CN=CN_organizer;X-ORACLE-GUID=A5120D71D6193E11E04400144F;", + " X-UW-AVAILABLE-APPOINTMENT-ROLE=OWNER;X-UW-AVAILABLE-APPOINTMENT", + " -ROLE=OWNER:mailto:organizer@example.com", + "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;RSVP=TRUE;", + " PARTSTAT=NEEDS-ACTION;X-RECEIVED-DTSTAMP=", + " 20130827T124944Z;CN=CN_attendee1:mailto:attendee1@example.com", + "ATTENDEE;ROLE=CHAIR;CN=CN_attendee2;CUTYPE=INDIVIDUAL;", + " PARTSTAT=ACCEPTED;CN=CN_attendee2:mailto:attendee2@example.com", + "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=RESOURCE;", + " PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=CN_attendee3", + " :mailto:attendee3@example.com", + 'ATTENDEE;CN="CN_attendee4";PARTSTAT=ACCEPTED;X-RECEIVED-DTSTAMP=', + " 20130827T124944Z;X-RECEIVED-SEQUENCE=0;X-RECEIVED-SEQUENCE=0", + " :mailto:attendee4@example.com", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + let expectedOrganizer = { + organizer: { + param: ["CN", "X-ORACLE-GUID", "X-UW-AVAILABLE-APPOINTMENT-ROLE"], + values: ["CN_organizer", "A5120D71D6193E11E04400144F", "OWNER"], + }, + }; + let expectedAttendee = { + attendee1: { + param: ["CN", "RSVP", "ROLE", "PARTSTAT", "CUTYPE", "X-RECEIVED-DTSTAMP"], + values: [ + "CN_attendee1", + "TRUE", + "REQ-PARTICIPANT", + "NEEDS-ACTION", + "INDIVIDUAL", + "20130827T124944Z", + ], + }, + attendee2: { + param: ["CN", "ROLE", "PARTSTAT", "CUTYPE"], + values: ["CN_attendee2", "CHAIR", "ACCEPTED", "INDIVIDUAL"], + }, + attendee3: { + param: ["CN", "RSVP", "ROLE", "PARTSTAT", "CUTYPE"], + values: ["CN_attendee3", "TRUE", "REQ-PARTICIPANT", "NEEDS-ACTION", "RESOURCE"], + }, + attendee4: { + param: ["CN", "PARTSTAT", "X-RECEIVED-DTSTAMP", "X-RECEIVED-SEQUENCE"], + values: ["CN_attendee4", "ACCEPTED", "20130827T124944Z", "0"], + }, + }; + + let event = createEventFromIcalString(ics); + let organizer = [event.organizer]; + let attendees = event.getAttendees(); + + testParameters(organizer, expectedOrganizer); + testParameters(attendees, expectedAttendee); +} +function test_emptyAttendee() { + // Event with empty attendee. + const event = createEventFromIcalString( + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "BEGIN:VEVENT", + "DTSTAMP:19700101T000000Z", + "DTSTART:19700101T000001Z", + "ATTENDEE:", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n") + ); + const attendees = event.getAttendees(); + equal(attendees.length, 0); +} diff --git a/comm/calendar/test/unit/test_auth_utils.js b/comm/calendar/test/unit/test_auth_utils.js new file mode 100644 index 0000000000..929762561e --- /dev/null +++ b/comm/calendar/test/unit/test_auth_utils.js @@ -0,0 +1,100 @@ +/* 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 USERNAME = "fred"; +const PASSWORD = "********"; +const ORIGIN = "https://origin"; +const REALM = "realm"; + +function run_test() { + do_get_profile(); + run_next_test(); +} + +function checkLoginCount(total) { + Assert.equal(total, Services.logins.countLogins("", "", "")); +} + +/** + * Tests the passwordManager{Get,Save,Remove} functions + */ +add_task(async function test_password_manager() { + await Services.logins.initializationPromise; + checkLoginCount(0); + + // Save the password + cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM); + checkLoginCount(1); + + // Save again, should modify the existing login + cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM); + checkLoginCount(1); + + // Retrieve the saved password + let passout = {}; + let found = cal.auth.passwordManagerGet(USERNAME, passout, ORIGIN, REALM); + Assert.equal(passout.value, PASSWORD); + Assert.ok(found); + checkLoginCount(1); + + // Retrieving should still happen with signon saving disabled, but saving should not + Services.prefs.setBoolPref("signon.rememberSignons", false); + passout = {}; + found = cal.auth.passwordManagerGet(USERNAME, passout, ORIGIN, REALM); + Assert.equal(passout.value, PASSWORD); + Assert.ok(found); + + Assert.throws( + () => cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM), + /NS_ERROR_NOT_AVAILABLE/ + ); + Services.prefs.clearUserPref("signon.rememberSignons"); + checkLoginCount(1); + + // Remove the password + found = cal.auth.passwordManagerRemove(USERNAME, ORIGIN, REALM); + checkLoginCount(0); + Assert.ok(found); + + // Really gone? + found = cal.auth.passwordManagerRemove(USERNAME, ORIGIN, REALM); + checkLoginCount(0); + Assert.ok(!found); +}); + +/** + * Tests various origins that can be passed to passwordManagerSave + */ +add_task(async function test_password_manager_origins() { + await Services.logins.initializationPromise; + checkLoginCount(0); + + // The scheme of the origin should be normalized to lowercase, this won't add any new passwords + cal.auth.passwordManagerSave(USERNAME, PASSWORD, "OAUTH:xpcshell@example.com", REALM); + checkLoginCount(1); + cal.auth.passwordManagerSave(USERNAME, PASSWORD, "oauth:xpcshell@example.com", REALM); + checkLoginCount(1); + + // Make sure that the prePath isn't used for oauth, because that is only the scheme + let found = cal.auth.passwordManagerGet(USERNAME, {}, "oauth:", REALM); + Assert.ok(!found); + + // Save a https url with a path (only prePath should be used) + cal.auth.passwordManagerSave(USERNAME, PASSWORD, "https://example.com/withpath", REALM); + found = cal.auth.passwordManagerGet(USERNAME, {}, "https://example.com", REALM); + Assert.ok(found); + checkLoginCount(2); + + // Entering something that is not an URL should assume https + cal.auth.passwordManagerSave(USERNAME, PASSWORD, "example.net", REALM); + found = cal.auth.passwordManagerGet(USERNAME, {}, "https://example.net", REALM); + Assert.ok(found); + checkLoginCount(3); + + // Cleanup + cal.auth.passwordManagerRemove(USERNAME, "oauth:xpcshell@example.com", REALM); + cal.auth.passwordManagerRemove(USERNAME, "https://example.com", REALM); + cal.auth.passwordManagerRemove(USERNAME, "https://example.net", REALM); + checkLoginCount(0); +}); diff --git a/comm/calendar/test/unit/test_bug1199942.js b/comm/calendar/test/unit/test_bug1199942.js new file mode 100644 index 0000000000..7d78cb1244 --- /dev/null +++ b/comm/calendar/test/unit/test_bug1199942.js @@ -0,0 +1,81 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + // Test the graceful handling of attendee ids for bug 1199942 + createAttendee_test(); + serializeEvent_test(); +} + +function createAttendee_test() { + let data = [ + { input: "mailto:user1@example.net", expected: "mailto:user1@example.net" }, + { input: "MAILTO:user2@example.net", expected: "mailto:user2@example.net" }, + { input: "user3@example.net", expected: "mailto:user3@example.net" }, + { input: "urn:uuid:user4", expected: "urn:uuid:user4" }, + ]; + let event = new CalEvent(); + for (let test of data) { + let attendee = new CalAttendee(); + attendee.id = test.input; + event.addAttendee(attendee); + let readAttendee = event.getAttendeeById(cal.email.prependMailTo(test.input)); + equal(readAttendee.id, test.expected); + } +} + +function serializeEvent_test() { + let ics = + "BEGIN:VCALENDAR\n" + + "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VEVENT\n" + + "CREATED:20150801T213509Z\n" + + "LAST-MODIFIED:20150830T164104Z\n" + + "DTSTAMP:20150830T164104Z\n" + + "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf\n" + + "SUMMARY:New Event\n" + + "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net\n" + + "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:MAILTO:user2@example.net\n" + + "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:user3@example.net\n" + + "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:user4@example.net\n" + + "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:urn:uuid:user5\n" + + "DTSTART:20150729T103000Z\n" + + "DTEND:20150729T113000Z\n" + + "TRANSP:OPAQUE\n" + + "END:VEVENT\n" + + "END:VCALENDAR\n"; + + let expectedIds = [ + "mailto:user2@example.net", + "mailto:user3@example.net", + "mailto:user4@example.net", + "urn:uuid:user5", + ]; + let event = createEventFromIcalString(ics); + let attendees = event.getAttendees(); + + // check whether all attendees get returned with expected id + for (let attendee of attendees) { + ok(expectedIds.includes(attendee.id)); + } + + // serialize the event again and check whether the attendees still are in shape + let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + serializer.addItems([event]); + let serialized = ics_unfoldline(serializer.serializeToString()); + for (let id of expectedIds) { + ok(serialized.search(id) != -1); + } +} diff --git a/comm/calendar/test/unit/test_bug1204255.js b/comm/calendar/test/unit/test_bug1204255.js new file mode 100644 index 0000000000..9277378ec4 --- /dev/null +++ b/comm/calendar/test/unit/test_bug1204255.js @@ -0,0 +1,146 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + // Test attendee duplicate handling for bug 1204255 + test_newAttendee(); + test_fromICS(); +} + +function test_newAttendee() { + let data = [ + { + input: [ + { id: "user2@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" }, + { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" }, + ], + expected: { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" }, + }, + { + input: [ + { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" }, + { id: "user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" }, + ], + expected: { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" }, + }, + { + input: [ + { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" }, + { id: "user4@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" }, + ], + expected: { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" }, + }, + { + input: [ + { id: "user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" }, + { id: "mailto:user5@example.net", partstat: "ACCEPTED", cname: "PREFIXED" }, + ], + expected: { id: "mailto:user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" }, + }, + { + input: [ + { id: "user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" }, + { id: "mailto:user6@example.net", partstat: "TENTATIVE", cname: "PREFIXED" }, + ], + expected: { id: "mailto:user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" }, + }, + { + input: [ + { id: "user7@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" }, + { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" }, + ], + expected: { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" }, + }, + ]; + + let event = new CalEvent(); + for (let test of data) { + for (let input of test.input) { + let attendee = new CalAttendee(); + attendee.id = input.id; + attendee.participationStatus = input.partstat; + attendee.commonName = input.cname; + event.addAttendee(attendee); + } + let readAttendee = event.getAttendeeById(cal.email.prependMailTo(test.expected.id)); + equal(readAttendee.id, test.expected.id); + equal( + readAttendee.participationStatus, + test.expected.partstat, + "partstat matches for " + test.expected.id + ); + equal( + readAttendee.commonName, + test.expected.cname, + "commonName matches for " + test.expected.id + ); + } +} + +function test_fromICS() { + let ics = [ + "BEGIN:VCALENDAR", + "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN", + "VERSION:2.0", + "BEGIN:VEVENT", + "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf", + "SUMMARY:New Event", + "DTSTART:20150729T103000Z", + "DTEND:20150729T113000Z", + "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net", + + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user2@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user2@example.net', + + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user3@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user3@example.net', + + 'ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user4@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user4@example.net', + + 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user5@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user5@example.net', + + 'ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user6@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user6@example.net', + + 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user7@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user7@example.net', + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + let expected = [ + { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" }, + { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" }, + { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" }, + { id: "mailto:user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" }, + { id: "mailto:user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" }, + { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" }, + ]; + let event = createEventFromIcalString(ics); + let attendees = event.getAttendees(); + + // check whether all attendees get returned as expected + equal(attendees.length, expected.length); + let count = 0; + for (let attendee of attendees) { + for (let exp of expected) { + if (attendee.id == exp.id) { + equal(attendee.participationStatus, exp.partstat, "partstat matches for " + exp.id); + equal(attendee.commonName, exp.cname, "commonName matches for " + exp.id); + count++; + } + } + } + equal(count, expected.length, "all attendees were processed"); +} diff --git a/comm/calendar/test/unit/test_bug1209399.js b/comm/calendar/test/unit/test_bug1209399.js new file mode 100644 index 0000000000..6392f25867 --- /dev/null +++ b/comm/calendar/test/unit/test_bug1209399.js @@ -0,0 +1,117 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + // Test handling for multiple double quotes leading/trailing to attendee CN for bug 1209399 + test_newAttendee(); + test_fromICS(); +} + +function test_newAttendee() { + let data = [ + { + input: { cname: null, id: "mailto:user1@example.net" }, + expected: { cname: null }, + }, + { + input: { cname: "Test2", id: "mailto:user2@example.net" }, + expected: { cname: "Test2" }, + }, + { + input: { cname: '"Test3"', id: "mailto:user3@example.net" }, + expected: { cname: "Test3" }, + }, + { + input: { cname: '""Test4""', id: "mailto:user4@example.net" }, + expected: { cname: "Test4" }, + }, + { + input: { cname: '""Test5"', id: "mailto:user5@example.net" }, + expected: { cname: "Test5" }, + }, + { + input: { cname: '"Test6""', id: "mailto:user6@example.net" }, + expected: { cname: "Test6" }, + }, + { + input: { cname: "", id: "mailto:user7@example.net" }, + expected: { cname: "" }, + }, + { + input: { cname: '""', id: "mailto:user8@example.net" }, + expected: { cname: null }, + }, + { + input: { cname: '""""', id: "mailto:user9@example.net" }, + expected: { cname: null }, + }, + ]; + + let i = 0; + let event = new CalEvent(); + for (let test of data) { + i++; + let attendee = new CalAttendee(); + attendee.id = test.input.id; + attendee.commonName = test.input.cname; + + event.addAttendee(attendee); + let readAttendee = event.getAttendeeById(test.input.id); + equal( + readAttendee.commonName, + test.expected.cname, + "Test #" + i + " for commonName matching of " + test.input.id + ); + } +} + +function test_fromICS() { + let ics = [ + "BEGIN:VCALENDAR", + "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN", + "VERSION:2.0", + "BEGIN:VEVENT", + "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf", + "SUMMARY:New Event", + "DTSTART:20150729T103000Z", + "DTEND:20150729T113000Z", + "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net", + + "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=Test2;ROLE=REQ-PARTICIPANT:mailto:user2@example.net", + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="Test3";ROLE=REQ-PARTICIPANT:mailto:user3@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=""Test4"";ROLE=REQ-PARTICIPANT:mailto:user4@example.net', + "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=;ROLE=REQ-PARTICIPANT:mailto:user5@example.net", + "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:user6@example.net", + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="";ROLE=REQ-PARTICIPANT:mailto:user7@example.net', + 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="""";ROLE=REQ-PARTICIPANT:mailto:user8@example.net', + + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + let expected = [ + { id: "mailto:user2@example.net", cname: "Test2" }, + { id: "mailto:user3@example.net", cname: "Test3" }, + { id: "mailto:user4@example.net", cname: "" }, + { id: "mailto:user5@example.net", cname: "" }, + { id: "mailto:user6@example.net", cname: null }, + { id: "mailto:user7@example.net", cname: "" }, + { id: "mailto:user8@example.net", cname: "" }, + ]; + let event = createEventFromIcalString(ics); + + equal(event.getAttendees().length, expected.length, "Check test consistency"); + for (let exp of expected) { + let attendee = event.getAttendeeById(exp.id); + equal(attendee.commonName, exp.cname, "Test for commonName matching of " + exp.id); + } +} diff --git a/comm/calendar/test/unit/test_bug1790339.js b/comm/calendar/test/unit/test_bug1790339.js new file mode 100644 index 0000000000..4d2bed95ce --- /dev/null +++ b/comm/calendar/test/unit/test_bug1790339.js @@ -0,0 +1,71 @@ +/* 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 () => { + let dbFile = do_get_profile(); + dbFile.append("test_storage.sqlite"); + + let sql = await IOUtils.readUTF8(do_get_file("data/bug1790339.sql").path); + let db = Services.storage.openDatabase(dbFile); + db.executeSimpleSQL(sql); + db.close(); + + await new Promise(resolve => { + do_calendar_startup(resolve); + }); + + let calendar = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance( + Ci.calISyncWriteCalendar + ); + calendar.uri = Services.io.newFileURI(dbFile); + calendar.id = "00000000-0000-0000-0000-000000000000"; + + checkItem(await calendar.getItem("00000000-0000-0000-0000-111111111111")); + checkItem(await calendar.getItem("00000000-0000-0000-0000-222222222222")); +}); + +function checkItem(item) { + info(`Checking item ${item.id}`); + + let attachments = item.getAttachments(); + Assert.equal(attachments.length, 1); + let attach = attachments[0]; + Assert.equal( + attach.uri.spec, + "https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central/thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2" + ); + + let attendees = item.getAttendees(); + Assert.equal(attendees.length, 1); + let attendee = attendees[0]; + Assert.equal(attendee.id, "mailto:test@example.com"); + Assert.equal(attendee.role, "REQ-PARTICIPANT"); + Assert.equal(attendee.participationStatus, "NEEDS-ACTION"); + + let recurrenceItems = item.recurrenceInfo.getRecurrenceItems(); + Assert.equal(recurrenceItems.length, 1); + let recurrenceItem = recurrenceItems[0]; + Assert.equal(recurrenceItem.type, "WEEKLY"); + Assert.equal(recurrenceItem.interval, 22); + Assert.equal(recurrenceItem.isByCount, false); + Assert.equal(recurrenceItem.isFinite, true); + Assert.deepEqual(recurrenceItem.getComponent("BYDAY"), [2, 3, 4, 5, 6, 7, 1]); + Assert.equal(recurrenceItem.isNegative, false); + + let relations = item.getRelations(); + Assert.equal(relations.length, 1); + let relation = relations[0]; + Assert.equal(relation.relType, "SIBLING"); + Assert.equal(relation.relId, "19960401-080045-4000F192713@example.com"); + + let alarms = item.getAlarms(); + Assert.equal(alarms.length, 1); + let alarm = alarms[0]; + Assert.equal(alarm.action, "DISPLAY"); + Assert.equal(alarm.offset.inSeconds, -300); + Assert.equal( + alarm.description, + "Make sure you don't miss this very very important event. It's essential that you don't forget." + ); +} diff --git a/comm/calendar/test/unit/test_bug272411.js b/comm/calendar/test/unit/test_bug272411.js new file mode 100644 index 0000000000..c871b53cf0 --- /dev/null +++ b/comm/calendar/test/unit/test_bug272411.js @@ -0,0 +1,15 @@ +/* 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/. */ + +function run_test() { + let jsd = new Date(); + let cdt = cal.dtz.jsDateToDateTime(jsd); + + let cdtTime = cal.dtz.dateTimeToJsDate(cdt).getTime() / 1000; + let jsdTime = Math.floor(jsd.getTime() / 1000); + + // calIDateTime is only accurate to the second, milliseconds need to be + // stripped. + equal(cdtTime, jsdTime); +} diff --git a/comm/calendar/test/unit/test_bug343792.js b/comm/calendar/test/unit/test_bug343792.js new file mode 100644 index 0000000000..c212315af3 --- /dev/null +++ b/comm/calendar/test/unit/test_bug343792.js @@ -0,0 +1,66 @@ +/* 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/. */ + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + // Check that Bug 343792 doesn't regress: + // Freeze (hang) on RRULE which has INTERVAL=0 + + let icalString = + "BEGIN:VCALENDAR\n" + + "CALSCALE:GREGORIAN\n" + + "PRODID:-//Ximian//NONSGML Evolution Calendar//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VTIMEZONE\n" + + "TZID:/softwarestudio.org/Olson_20011030_5/America/Los_Angeles\n" + + "X-LIC-LOCATION:America/Los_Angeles\n" + + "BEGIN:STANDARD\n" + + "TZOFFSETFROM:-0700\n" + + "TZOFFSETTO:-0800\n" + + "TZNAME:PST\n" + + "DTSTART:19701025T020000\n" + + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;INTERVAL=1\n" + + "END:STANDARD\n" + + "BEGIN:DAYLIGHT\n" + + "TZOFFSETFROM:-0800\n" + + "TZOFFSETTO:-0700\n" + + "TZNAME:PDT\n" + + "DTSTART:19700405T020000\n" + + "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;INTERVAL=1\n" + + "END:DAYLIGHT\n" + + "END:VTIMEZONE\n" + + "BEGIN:VEVENT\n" + + "UID:20060705T145529-1768-1244-1267-46@localhost\n" + + "ORGANIZER:MAILTO:No Body\n" + + "DTSTAMP:20060705T145529Z\n" + + "DTSTART;TZID=/softwarestudio.org/Olson_20011030_5/America/Los_Angeles:\n" + + " 20060515T170000\n" + + "DTEND;TZID=/softwarestudio.org/Olson_20011030_5/America/Los_Angeles:\n" + + " 20060515T173000\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=0\n" + + "LOCATION:Maui Building\n" + + "TRANSP:OPAQUE\n" + + "SEQUENCE:0\n" + + "SUMMARY:FW development Status\n" + + "PRIORITY:4\n" + + "CLASS:PUBLIC\n" + + "DESCRIPTION:Daily standup Mtg and/or status update on FW\n" + + "END:VEVENT\n" + + "END:VCALENDAR"; + + let event = createEventFromIcalString(icalString); + let start = createDate(2009, 4, 1); + let end = createDate(2009, 4, 30); + + // the following call caused a never ending loop: + let occurrenceDates = event.recurrenceInfo.getOccurrenceDates(start, end, 0); + equal(occurrenceDates.length, 4); + + // the following call caused a never ending loop: + let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0); + equal(occurrences.length, 4); +} diff --git a/comm/calendar/test/unit/test_bug350845.js b/comm/calendar/test/unit/test_bug350845.js new file mode 100644 index 0000000000..735f4b6eb4 --- /dev/null +++ b/comm/calendar/test/unit/test_bug350845.js @@ -0,0 +1,43 @@ +/* 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/. */ + +function run_test() { + let event = createEventFromIcalString( + "BEGIN:VEVENT\n" + + "UID:182d2719-fe2a-44c1-9210-0286b16c0538\n" + + "X-FOO;X-BAR=BAZ:QUUX\n" + + "END:VEVENT" + ); + + // Test getters for imported event + equal(event.getProperty("X-FOO"), "QUUX"); + ok(event.hasProperty("X-FOO")); + equal(event.getPropertyParameter("X-FOO", "X-BAR"), "BAZ"); + ok(event.hasPropertyParameter("X-FOO", "X-BAR")); + + // Test setters + throws(() => { + event.setPropertyParameter("X-UNKNOWN", "UNKNOWN", "VALUE"); + }, /Property X-UNKNOWN not set/); + + // More setters + event.setPropertyParameter("X-FOO", "X-BAR", "FNORD"); + equal(event.getPropertyParameter("X-FOO", "X-BAR"), "FNORD"); + notEqual(event.icalString.match(/^X-FOO;X-BAR=FNORD:QUUX$/m), null); + + // Test we can get the parameter names + throws(() => { + event.getParameterNames("X-UNKNOWN"); + }, /Property X-UNKNOWN not set/); + equal(event.getParameterNames("X-FOO").length, 1); + ok(event.getParameterNames("X-FOO").includes("X-BAR")); + + // Deletion of parameters when deleting properties + event.deleteProperty("X-FOO"); + ok(!event.hasProperty("X-FOO")); + event.setProperty("X-FOO", "SNORK"); + equal(event.getProperty("X-FOO"), "SNORK"); + equal(event.getParameterNames("X-FOO").length, 0); + equal(event.getPropertyParameter("X-FOO", "X-BAR"), null); +} diff --git a/comm/calendar/test/unit/test_bug356207.js b/comm/calendar/test/unit/test_bug356207.js new file mode 100644 index 0000000000..1ad23c6acd --- /dev/null +++ b/comm/calendar/test/unit/test_bug356207.js @@ -0,0 +1,47 @@ +/* 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/. */ + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + // Check that Bug 356207 doesn't regress: + // Freeze (hang) on RRULE which has BYMONTHDAY and BYDAY + + let icalString = + "BEGIN:VCALENDAR\n" + + "PRODID:-//Randy L Pearson//NONSGML Outlook2vCal V1.1//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VEVENT\n" + + "CREATED:20040829T163323\n" + + "UID:00000000EBFAC68C9B92BF119D643623FBD17E1424312000\n" + + "SEQUENCE:1\n" + + "LAST-MODIFIED:20060615T231158\n" + + "DTSTAMP:20040829T163323\n" + + "ORGANIZER:Unknown\n" + + "DTSTART:20040901T141500\n" + + "DESCRIPTION:Contact Mary Tindall for more details.\n" + + "CLASS:PUBLIC\n" + + "LOCATION:Church\n" + + "CATEGORIES:Church Events\n" + + "SUMMARY:Friendship Circle\n" + + "PRIORITY:1\n" + + "DTEND:20040901T141500\n" + + "RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1;BYDAY=WE\n" + + "END:VEVENT\n" + + "END:VCALENDAR"; + + let event = createEventFromIcalString(icalString); + let start = createDate(2009, 0, 1); + let end = createDate(2009, 11, 31); + + // the following call caused a never ending loop: + let occurrenceDates = event.recurrenceInfo.getOccurrenceDates(start, end, 0); + equal(occurrenceDates.length, 2); + + // the following call caused a never ending loop: + let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0); + equal(occurrences.length, 2); +} diff --git a/comm/calendar/test/unit/test_bug485571.js b/comm/calendar/test/unit/test_bug485571.js new file mode 100644 index 0000000000..855109b7e8 --- /dev/null +++ b/comm/calendar/test/unit/test_bug485571.js @@ -0,0 +1,99 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", +}); + +function run_test() { + // Check that the RELATED property is correctly set + // after parsing the given VALARM component + + // trigger set 15 minutes prior to the start of the event + check_relative( + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER:-PT15M\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM", + Ci.calIAlarm.ALARM_RELATED_START + ); + + // trigger set 15 minutes prior to the start of the event + check_relative( + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER;VALUE=DURATION:-PT15M\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM", + Ci.calIAlarm.ALARM_RELATED_START + ); + + // trigger set 15 minutes prior to the start of the event + check_relative( + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER;RELATED=START:-PT15M\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM", + Ci.calIAlarm.ALARM_RELATED_START + ); + + // trigger set 15 minutes prior to the start of the event + check_relative( + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER;VALUE=DURATION;RELATED=START:-PT15M\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM", + Ci.calIAlarm.ALARM_RELATED_START + ); + + // trigger set 5 minutes after the end of an event + check_relative( + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER;RELATED=END:PT5M\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM", + Ci.calIAlarm.ALARM_RELATED_END + ); + + // trigger set 5 minutes after the end of an event + check_relative( + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER;VALUE=DURATION;RELATED=END:PT5M\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM", + Ci.calIAlarm.ALARM_RELATED_END + ); + + // trigger set to an absolute date/time + check_absolute( + "BEGIN:VALARM\n" + + "ACTION:DISPLAY\n" + + "TRIGGER;VALUE=DATE-TIME:20090430T080000Z\n" + + "DESCRIPTION:TEST\n" + + "END:VALARM" + ); +} + +function check_relative(aIcalString, aRelated) { + let alarm = new CalAlarm(); + alarm.icalString = aIcalString; + equal(alarm.related, aRelated); + equal(alarm.alarmDate, null); + notEqual(alarm.offset, null); +} + +function check_absolute(aIcalString) { + let alarm = new CalAlarm(); + alarm.icalString = aIcalString; + equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_ABSOLUTE); + ok(alarm.alarmDate != null); + equal(alarm.offset, null); +} diff --git a/comm/calendar/test/unit/test_bug486186.js b/comm/calendar/test/unit/test_bug486186.js new file mode 100644 index 0000000000..cf25594790 --- /dev/null +++ b/comm/calendar/test/unit/test_bug486186.js @@ -0,0 +1,21 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", +}); + +function run_test() { + // ensure that RELATED property is correctly set on the VALARM component + let alarm = new CalAlarm(); + alarm.action = "DISPLAY"; + alarm.description = "test"; + alarm.related = Ci.calIAlarm.ALARM_RELATED_END; + alarm.offset = cal.createDuration("-PT15M"); + if (alarm.icalString.search(/RELATED=END/) == -1) { + do_throw("Bug 486186: RELATED property missing in VALARM component"); + } +} diff --git a/comm/calendar/test/unit/test_bug494140.js b/comm/calendar/test/unit/test_bug494140.js new file mode 100644 index 0000000000..9a5baa6956 --- /dev/null +++ b/comm/calendar/test/unit/test_bug494140.js @@ -0,0 +1,57 @@ +/* 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/. */ + +/** + * In bug 494140 we found out that creating an exception to a series duplicates + * alarms. This unit test makes sure the alarms don't duplicate themselves. The + * same goes for relations and attachments. + */ +add_task(async () => { + let storageCal = getStorageCal(); + + let item = createEventFromIcalString( + "BEGIN:VEVENT\r\n" + + "CREATED:20090603T171401Z\r\n" + + "LAST-MODIFIED:20090617T080410Z\r\n" + + "DTSTAMP:20090617T080410Z\r\n" + + "UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5\r\n" + + "SUMMARY:Test\r\n" + + "DTSTART:20090603T073000Z\r\n" + + "DTEND:20090603T091500Z\r\n" + + "RRULE:FREQ=DAILY;COUNT=5\r\n" + + "RELATED-TO:RELTYPE=SIBLING:<foo@example.org>\r\n" + + "ATTACH:http://www.example.org/\r\n" + + "BEGIN:VALARM\r\n" + + "ACTION:DISPLAY\r\n" + + "TRIGGER;VALUE=DURATION:-PT10M\r\n" + + "DESCRIPTION:Mozilla Alarm: Test\r\n" + + "END:VALARM\r\n" + + "END:VEVENT" + ); + + // There should be one alarm, one relation and one attachment + equal(item.getAlarms().length, 1); + equal(item.getRelations().length, 1); + equal(item.getAttachments().length, 1); + + // Change the occurrence to another day + let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20090604T073000Z")); + occ.QueryInterface(Ci.calIEvent); + occ.startDate = cal.createDateTime("20090618T073000Z"); + item.recurrenceInfo.modifyException(occ, true); + + // There should still be one alarm, one relation and one attachment + equal(item.getAlarms().length, 1); + equal(item.getRelations().length, 1); + equal(item.getAttachments().length, 1); + + // Add the item to the storage calendar and retrieve it again + await storageCal.adoptItem(item); + + let retrievedItem = await storageCal.getItem("c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5"); + // There should still be one alarm, one relation and one attachment + equal(retrievedItem.getAlarms().length, 1); + equal(retrievedItem.getRelations().length, 1); + equal(retrievedItem.getAttachments().length, 1); +}); diff --git a/comm/calendar/test/unit/test_bug523860.js b/comm/calendar/test/unit/test_bug523860.js new file mode 100644 index 0000000000..c5455eda3d --- /dev/null +++ b/comm/calendar/test/unit/test_bug523860.js @@ -0,0 +1,15 @@ +/* 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"); + +function run_test() { + // In bug 523860, we found out that in the spec doublequotes should not be + // escaped. + let prop = cal.icsService.createIcalProperty("DESCRIPTION"); + let expected = "A String with \"quotes\" and 'other quotes'"; + + prop.value = expected; + equal(prop.icalString, "DESCRIPTION:" + expected + "\r\n"); +} diff --git a/comm/calendar/test/unit/test_bug653924.js b/comm/calendar/test/unit/test_bug653924.js new file mode 100644 index 0000000000..1573ae54aa --- /dev/null +++ b/comm/calendar/test/unit/test_bug653924.js @@ -0,0 +1,20 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalRelation: "resource:///modules/CalRelation.jsm", +}); + +function run_test() { + const evt = new CalEvent(); + const rel = new CalRelation("RELATED-TO:2424d594-0453-49a1-b842-6faee483ca79"); + evt.addRelation(rel); + + equal(1, evt.icalString.match(/RELATED-TO/g).length); + evt.icalString = evt.icalString; // eslint-disable-line no-self-assign + equal(1, evt.icalString.match(/RELATED-TO/g).length); +} diff --git a/comm/calendar/test/unit/test_bug668222.js b/comm/calendar/test/unit/test_bug668222.js new file mode 100644 index 0000000000..eb6e9f5d0b --- /dev/null +++ b/comm/calendar/test/unit/test_bug668222.js @@ -0,0 +1,28 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", +}); + +function run_test() { + let attendee = new CalAttendee(); + attendee.id = "mailto:somebody"; + + // Set the property and make sure its there + attendee.setProperty("SCHEDULE-AGENT", "CLIENT"); + equal(attendee.getProperty("SCHEDULE-AGENT"), "CLIENT"); + + // Reserialize the property, this has caused the property to go away + // in the past. + attendee.icalProperty = attendee.icalProperty; // eslint-disable-line no-self-assign + equal(attendee.getProperty("SCHEDULE-AGENT"), "CLIENT"); + + // Also make sure there are no promoted properties set. This does not + // technically belong to this bug, but I almost caused this error while + // writing the patch. + ok(!attendee.icalProperty.icalString.includes("RSVP")); +} diff --git a/comm/calendar/test/unit/test_bug759324.js b/comm/calendar/test/unit/test_bug759324.js new file mode 100644 index 0000000000..e2dabcd9f9 --- /dev/null +++ b/comm/calendar/test/unit/test_bug759324.js @@ -0,0 +1,74 @@ +/* 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/. */ + +let storage = getStorageCal(); + +/** + * Checks if the capabilities.propagate-sequence feature of the storage calendar + * still works + */ +add_task(async function testBug759324() { + storage.setProperty("capabilities.propagate-sequence", "true"); + + let str = [ + "BEGIN:VEVENT", + "UID:recItem", + "SEQUENCE:3", + "RRULE:FREQ=WEEKLY", + "DTSTART:20120101T010101Z", + "END:VEVENT", + ].join("\r\n"); + + let item = createEventFromIcalString(str); + let rid = cal.createDateTime("20120101T010101Z"); + let rec = item.recurrenceInfo.getOccurrenceFor(rid); + rec.title = "changed"; + item.recurrenceInfo.modifyException(rec, true); + + do_test_pending(); + + let addedItem = await storage.addItem(item); + addedItem.QueryInterface(Ci.calIEvent); + let seq = addedItem.getProperty("SEQUENCE"); + let occ = addedItem.recurrenceInfo.getOccurrenceFor(rid); + + equal(seq, 3); + equal(occ.getProperty("SEQUENCE"), seq); + + let changedItem = addedItem.clone(); + changedItem.setProperty("SEQUENCE", parseInt(seq, 10) + 1); + + checkModifiedItem(rid, await storage.modifyItem(changedItem, addedItem)); +}); + +async function checkModifiedItem(rid, changedItem) { + changedItem.QueryInterface(Ci.calIEvent); + let seq = changedItem.getProperty("SEQUENCE"); + let occ = changedItem.recurrenceInfo.getOccurrenceFor(rid); + + equal(seq, 4); + equal(occ.getProperty("SEQUENCE"), seq); + + // Now check with the pref off + storage.deleteProperty("capabilities.propagate-sequence"); + + let changedItem2 = changedItem.clone(); + changedItem2.setProperty("SEQUENCE", parseInt(seq, 10) + 1); + + checkNormalItem(rid, await storage.modifyItem(changedItem2, changedItem)); +} + +function checkNormalItem(rid, changedItem) { + changedItem.QueryInterface(Ci.calIEvent); + let seq = changedItem.getProperty("SEQUENCE"); + let occ = changedItem.recurrenceInfo.getOccurrenceFor(rid); + + equal(seq, 5); + equal(occ.getProperty("SEQUENCE"), 4); + completeTest(); +} + +function completeTest() { + do_test_finished(); +} diff --git a/comm/calendar/test/unit/test_calIteratorUtils.js b/comm/calendar/test/unit/test_calIteratorUtils.js new file mode 100644 index 0000000000..db5158d6c9 --- /dev/null +++ b/comm/calendar/test/unit/test_calIteratorUtils.js @@ -0,0 +1,38 @@ +/* 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/. */ + +/** + * Tests for cal.iterate.* + */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalReadableStreamFactory } = ChromeUtils.import( + "resource:///modules/CalReadableStreamFactory.jsm" +); + +/** + * Test streamValues() iterates over all values found in a stream. + */ +add_task(async function testStreamValues() { + let src = Array(10) + .fill(null) + .map((_, i) => i + 1); + let stream = CalReadableStreamFactory.createReadableStream({ + start(controller) { + for (let i = 0; i < src.length; i++) { + controller.enqueue(src[i]); + } + controller.close(); + }, + }); + + let dest = []; + for await (let value of cal.iterate.streamValues(stream)) { + dest.push(value); + } + Assert.ok( + src.every((val, idx) => (dest[idx] = val)), + "all values were read from the stream" + ); +}); diff --git a/comm/calendar/test/unit/test_calStorageHelpers.js b/comm/calendar/test/unit/test_calStorageHelpers.js new file mode 100644 index 0000000000..b7b3d54f04 --- /dev/null +++ b/comm/calendar/test/unit/test_calStorageHelpers.js @@ -0,0 +1,23 @@ +const { newDateTime } = ChromeUtils.import("resource:///modules/calendar/calStorageHelpers.jsm"); + +add_task(async function testNewDateTimeWithIcalTimezoneDef() { + // Define a timezone that is unlikely to match anything in common use + const icalTimezoneDef = `BEGIN:VTIMEZONE +TZID:Totally_Made_Up_Standard_Time +BEGIN:STANDARD +DTSTART:19671029T020000 +TZOFFSETFROM:-0427 +TZOFFSETTO:-0527 +END:STANDARD +END:VTIMEZONE`; + + // 6 October, 2022 at 17:23:08 UTC + const dateTime = newDateTime(1665076988000000, icalTimezoneDef); + + Assert.equal(dateTime.year, 2022, "year should be 2022"); + Assert.equal(dateTime.month, 9, "zero-based month should be October"); + Assert.equal(dateTime.day, 6, "day should be the 6th"); + Assert.equal(dateTime.hour, 11, "hour should be 11 AM"); + Assert.equal(dateTime.minute, 56, "minute should be 56"); + Assert.equal(dateTime.second, 8, "second should be 8"); +}); diff --git a/comm/calendar/test/unit/test_caldav_requests.js b/comm/calendar/test/unit/test_caldav_requests.js new file mode 100644 index 0000000000..2f46c09a8d --- /dev/null +++ b/comm/calendar/test/unit/test_caldav_requests.js @@ -0,0 +1,970 @@ +/* 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 { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + +var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +var { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +var { + CalDavGenericRequest, + CalDavItemRequest, + CalDavDeleteItemRequest, + CalDavPropfindRequest, + CalDavHeaderRequest, + CalDavPrincipalPropertySearchRequest, + CalDavOutboxRequest, + CalDavFreeBusyRequest, +} = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm"); +var { CalDavWebDavSyncHandler } = ChromeUtils.import( + "resource:///modules/caldav/CalDavRequestHandlers.jsm" +); + +var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm"); +var { CalDavXmlns } = ChromeUtils.import("resource:///modules/caldav/CalDavUtils.jsm"); +var { Preferences } = ChromeUtils.importESModule("resource://gre/modules/Preferences.sys.mjs"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +class LowerMap extends Map { + get(key) { + return super.get(key.toLowerCase()); + } + set(key, value) { + return super.set(key.toLowerCase(), value); + } +} + +var gServer; + +var MockConflictPrompt = { + _origFunc: null, + overwrite: false, + register() { + if (!this._origFunc) { + this._origFunc = cal.provider.promptOverwrite; + cal.provider.promptOverwrite = (aMode, aItem) => { + return this.overwrite; + }; + } + }, + + unregister() { + if (this._origFunc) { + cal.provider.promptOverwrite = this._origFunc; + this._origFunc = null; + } + }, +}; + +class MockAlertsService { + QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]); + showAlert() {} +} + +function replaceAlertsService() { + let originalAlertsServiceCID = MockRegistrar.register( + "@mozilla.org/alerts-service;1", + MockAlertsService + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(originalAlertsServiceCID); + }); +} + +var gMockCalendar = { + name: "xpcshell", + makeUri(insert, base) { + return base; + }, + verboseLogging() { + return true; + }, + ensureEncodedPath(x) { + return x; + }, + ensureDecodedPath(x) { + return x; + }, + startBatch() {}, + endBatch() {}, + addTargetCalendarItem() {}, + finalizeUpdatedItems() {}, + mHrefIndex: [], +}; +gMockCalendar.superCalendar = gMockCalendar; + +class CalDavServer { + constructor(calendarId) { + this.server = new HttpServer(); + this.calendarId = calendarId; + this.session = new CalDavSession(this.calendarId, "xpcshell"); + this.serverRequests = {}; + + this.server.registerPrefixHandler( + "/principals/", + this.router.bind(this, this.principals.bind(this)) + ); + this.server.registerPrefixHandler( + "/calendars/", + this.router.bind(this, this.calendars.bind(this)) + ); + this.server.registerPrefixHandler( + "/requests/", + this.router.bind(this, this.requests.bind(this)) + ); + } + + start() { + this.server.start(-1); + registerCleanupFunction(() => this.server.stop(() => {})); + } + + reset() { + this.serverRequests = {}; + } + + uri(path) { + let base = Services.io.newURI(`http://localhost:${this.server.identity.primaryPort}/`); + return Services.io.newURI(path, null, base); + } + + router(nextHandler, request, response) { + try { + let method = request.method; + let parameters = new Map(request.queryString.split("&").map(part => part.split("=", 2))); + let available = request.bodyInputStream.available(); + let body = + available > 0 ? NetUtil.readInputStreamToString(request.bodyInputStream, available) : null; + + let headers = new LowerMap(); + + let headerIterator = function* (enumerator) { + while (enumerator.hasMoreElements()) { + yield enumerator.getNext().QueryInterface(Ci.nsISupportsString); + } + }; + + for (let hdr of headerIterator(request.headers)) { + headers.set(hdr.data, request.getHeader(hdr.data)); + } + + return nextHandler(request, response, method, headers, parameters, body); + } catch (e) { + info("Server Error: " + e.fileName + ":" + e.lineNumber + ": " + e + "\n"); + return null; + } + } + + resetClient(client) { + MockConflictPrompt.unregister(); + cal.manager.unregisterCalendar(client); + } + + waitForLoad(aCalendar) { + return new Promise((resolve, reject) => { + let observer = cal.createAdapter(Ci.calIObserver, { + onLoad() { + let uncached = aCalendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject; + aCalendar.removeObserver(observer); + + if (Components.isSuccessCode(uncached._lastStatus)) { + resolve(aCalendar); + } else { + reject(uncached._lastMessage); + } + }, + }); + aCalendar.addObserver(observer); + }); + } + + getClient() { + let uri = this.uri("/calendars/xpcshell/events"); + let client = cal.manager.createCalendar("caldav", uri); + let uclient = client.wrappedJSObject; + client.name = "xpcshell"; + client.setProperty("cache.enabled", true); + + // Make sure we catch the last error message in case sync fails + monkeyPatch(uclient, "replayChangesOn", (protofunc, aListener) => { + protofunc({ + onResult(operation, detail) { + uclient._lastStatus = operation.status; + uclient._lastMessage = detail; + aListener.onResult(operation, detail); + }, + }); + }); + + cal.manager.registerCalendar(client); + + let cachedCalendar = cal.manager.getCalendarById(client.id); + return this.waitForLoad(cachedCalendar); + } + + principals(request, response, method, headers, parameters, body) { + this.serverRequests.principals = { method, headers, parameters, body }; + + if (method == "REPORT" && request.path == "/principals/") { + response.setHeader("Content-Type", "application/xml"); + response.write(dedent` + <?xml version="1.0" encoding="utf-8" ?> + <D:multistatus xmlns:D="DAV:" xmlns:B="http://BigCorp.com/ns/"> + <D:response> + <D:href>http://www.example.com/users/jdoe</D:href> + <D:propstat> + <D:prop> + <D:displayname>John Doe</D:displayname> + <B:department>Widget Sales</B:department> + <B:phone>234-4567</B:phone> + <B:office>209</B:office> + </D:prop> + <D:status>HTTP/1.1 200 OK</D:status> + </D:propstat> + <D:propstat> + <D:prop> + <B:salary/> + </D:prop> + <D:status>HTTP/1.1 403 Forbidden</D:status> + </D:propstat> + </D:response> + <D:response> + <D:href>http://www.example.com/users/zsmith</D:href> + <D:propstat> + <D:prop> + <D:displayname>Zygdoebert Smith</D:displayname> + <B:department>Gadget Sales</B:department> + <B:phone>234-7654</B:phone> + <B:office>114</B:office> + </D:prop> + <D:status>HTTP/1.1 200 OK</D:status> + </D:propstat> + <D:propstat> + <D:prop> + <B:salary/> + </D:prop> + <D:status>HTTP/1.1 403 Forbidden</D:status> + </D:propstat> + </D:response> + </D:multistatus> + `); + response.setStatusLine(null, 207, "Multistatus"); + } else if (method == "PROPFIND" && request.path == "/principals/xpcshell/user/") { + response.setHeader("Content-Type", "application/xml"); + response.write(dedent` + <?xml version="1.0" encoding="utf-8"?> + <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> + <D:response> + <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href> + <D:propstat> + <D:prop> + <C:calendar-home-set> + <D:href>${this.uri("/calendars/xpcshell/user/").spec}</D:href> + </C:calendar-home-set> + <C:calendar-user-address-set> + <D:href>mailto:xpcshell@example.com</D:href> + </C:calendar-user-address-set> + <C:schedule-inbox-URL> + <D:href>${this.uri("/calendars/xpcshell/inbox").spec}/</D:href> + </C:schedule-inbox-URL> + <C:schedule-outbox-URL> + <D:href>${this.uri("/calendars/xpcshell/outbox").spec}</D:href> + </C:schedule-outbox-URL> + </D:prop> + <D:status>HTTP/1.1 200 OK</D:status> + </D:propstat> + </D:response> + </D:multistatus> + `); + response.setStatusLine(null, 207, "Multistatus"); + } + } + + calendars(request, response, method, headers, parameters, body) { + this.serverRequests.calendars = { method, headers, parameters, body }; + + if ( + method == "PROPFIND" && + request.path.startsWith("/calendars/xpcshell/events") && + headers.get("depth") == 0 + ) { + response.setHeader("Content-Type", "application/xml"); + response.write(dedent` + <?xml version="1.0" encoding="utf-8" ?> + <D:multistatus ${CalDavXmlns("D", "C", "CS")} xmlns:R="http://www.foo.bar/boxschema/"> + <D:response> + <D:href>${request.path}</D:href> + <D:propstat> + <D:prop> + <D:resourcetype> + <D:collection/> + <C:calendar/> + </D:resourcetype> + <R:plain-text-prop>hello, world</R:plain-text-prop> + <D:principal-collection-set> + <D:href>${this.uri("/principals/").spec}</D:href> + <D:href>${this.uri("/principals/subthing/").spec}</D:href> + </D:principal-collection-set> + <D:current-user-principal> + <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href> + </D:current-user-principal> + <D:supported-report-set> + <D:supported-report> + <D:report> + <D:principal-property-search/> + </D:report> + </D:supported-report> + <D:supported-report> + <D:report> + <C:calendar-multiget/> + </D:report> + </D:supported-report> + <D:supported-report> + <D:report> + <D:sync-collection/> + </D:report> + </D:supported-report> + </D:supported-report-set> + <C:supported-calendar-component-set> + <C:comp name="VEVENT"/> + <C:comp name="VTODO"/> + </C:supported-calendar-component-set> + <C:schedule-inbox-URL> + <D:href>${this.uri("/calendars/xpcshell/inbox").spec}</D:href> + </C:schedule-inbox-URL> + <C:schedule-outbox-URL> + ${this.uri("/calendars/xpcshell/outbox").spec} + </C:schedule-outbox-URL> + <CS:getctag>1413647159-1007960</CS:getctag> + </D:prop> + <D:status>HTTP/1.1 200 OK</D:status> + </D:propstat> + <D:propstat> + <D:prop> + <R:obscure-thing-not-found/> + </D:prop> + <D:status>HTTP/1.1 404 Not Found</D:status> + </D:propstat> + </D:response> + </D:multistatus> + `); + response.setStatusLine(null, 207, "Multistatus"); + } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox") { + response.setHeader("Content-Type", "application/xml"); + response.write(dedent` + <?xml version="1.0" encoding="utf-8" ?> + <C:schedule-response ${CalDavXmlns("D", "C")}> + <D:response> + <D:href>mailto:recipient1@example.com</D:href> + <D:request-status>2.0;Success</D:request-status> + </D:response> + <D:response> + <D:href>mailto:recipient2@example.com</D:href> + <D:request-status>2.0;Success</D:request-status> + </D:response> + </C:schedule-response> + `); + response.setStatusLine(null, 200, "OK"); + } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox2") { + response.setHeader("Content-Type", "application/xml"); + response.write(dedent` + <?xml version="1.0" encoding="utf-8" ?> + <C:schedule-response ${CalDavXmlns("D", "C")}> + <D:response> + <D:recipient> + <D:href>mailto:recipient1@example.com</D:href> + </D:recipient> + <D:request-status>2.0;Success</D:request-status> + <C:calendar-data> + BEGIN:VCALENDAR + PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN + VERSION:2.0 + METHOD:REQUEST + BEGIN:VFREEBUSY + DTSTART;VALUE=DATE:20180102 + DTEND;VALUE=DATE:20180126 + ORGANIZER:mailto:xpcshell@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mail + to:recipient@example.com + FREEBUSY;FBTYPE=FREE:20180103T010101Z/20180117T010101Z + FREEBUSY;FBTYPE=BUSY:20180118T010101Z/P7D + END:VFREEBUSY + END:VCALENDAR + </C:calendar-data> + </D:response> + </C:schedule-response> + `); + response.setStatusLine(null, 200, "OK"); + } else if (method == "OPTIONS" && request.path == "/calendars/xpcshell/") { + response.setHeader( + "DAV", + "1, 2, 3, access-control, extended-mkcol, resource-sharing, calendar-access, calendar-auto-schedule, calendar-query-extended, calendar-availability, calendarserver-sharing, inbox-availability" + ); + response.setStatusLine(null, 200, "OK"); + } else if (method == "REPORT" && request.path == "/calendars/xpcshell/events/") { + response.setHeader("Content-Type", "application/xml"); + let bodydom = cal.xml.parseString(body); + let report = bodydom.documentElement.localName; + let eventName = String.fromCharCode(...new TextEncoder().encode("γ€γγ³γ")); + if (report == "sync-collection") { + response.write(dedent` + <?xml version="1.0" encoding="utf-8" ?> + <D:multistatus ${CalDavXmlns("D")}> + <D:response> + <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href> + <D:propstat> + <D:prop> + <D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype> + <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag> + <D:displayname>${eventName}</D:displayname> + </D:prop> + <D:status>HTTP/1.1 200 OK</D:status> + </D:propstat> + </D:response> + </D:multistatus> + `); + } else if (report == "calendar-multiget") { + let event = new CalEvent(); + event.title = "δΌθ°"; + event.startDate = cal.dtz.now(); + event.endDate = cal.dtz.now(); + let icalString = String.fromCharCode(...new TextEncoder().encode(event.icalString)); + response.write(dedent` + <?xml version="1.0" encoding="utf-8"?> + <D:multistatus ${CalDavXmlns("D", "C")}> + <D:response> + <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href> + <D:propstat> + <D:prop> + <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag> + <C:calendar-data>${icalString}</C:calendar-data> + </D:prop> + </D:propstat> + </D:response> + </D:multistatus> + `); + } + response.setStatusLine(null, 207, "Multistatus"); + } else { + console.log("XXX: " + method, request.path, [...headers.entries()]); + } + } + + requests(request, response, method, headers, parameters, body) { + // ["", "requests", "generic"] := /requests/generic + let parts = request.path.split("/"); + let id = parts[2]; + let status = parseInt(parts[3] || "", 10) || 200; + + if (id == "redirected") { + response.setHeader("Location", "/requests/redirected-target", false); + status = 302; + } else if (id == "dav") { + response.setHeader("DAV", "1, calendar-schedule, calendar-auto-schedule"); + } + + this.serverRequests[id] = { method, headers, parameters, body }; + + for (let [hdr, value] of headers.entries()) { + response.setHeader(hdr, "response-" + value, false); + } + + response.setHeader("Content-Type", "application/xml"); + response.write(`<response id="${id}">xpc</response>`); + response.setStatusLine(null, status, null); + } +} + +function run_test() { + Preferences.set("calendar.debug.log", true); + Preferences.set("calendar.debug.log.verbose", true); + cal.console.maxLogLevel = "debug"; + replaceAlertsService(); + + // TODO: make do_calendar_startup to work with this test and replace the startup code here + do_get_profile(); + do_test_pending(); + + cal.manager.startup({ + onResult() { + gServer = new CalDavServer("xpcshell@example.com"); + gServer.start(); + cal.timezoneService.startup({ + onResult() { + run_next_test(); + do_test_finished(); + }, + }); + }, + }); +} + +add_task(async function test_caldav_session() { + gServer.reset(); + + let prepared = 0; + let redirected = 0; + let completed = 0; + let restart = false; + + gServer.session.authAdapters.localhost = { + async prepareRequest(aChannel) { + prepared++; + }, + + async prepareRedirect(aOldChannel, aNewChannel) { + redirected++; + }, + + async completeRequest(aResponse) { + completed++; + if (restart) { + restart = false; + return CalDavSession.RESTART_REQUEST; + } + return null; + }, + }; + + // First a simple request + let uri = gServer.uri("/requests/session"); + let request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri); + await request.commit(); + + equal(prepared, 1); + equal(redirected, 0); + equal(completed, 1); + + // Now a redirect + prepared = redirected = completed = 0; + + uri = gServer.uri("/requests/redirected"); + request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri); + await request.commit(); + + equal(prepared, 1); + equal(redirected, 1); + equal(completed, 1); + + // Now with restarting the request + prepared = redirected = completed = 0; + restart = true; + + uri = gServer.uri("/requests/redirected"); + request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri); + await request.commit(); + + equal(prepared, 2); + equal(redirected, 2); + equal(completed, 2); +}); + +/** + * This test covers both GenericRequest and the base class CalDavRequestBase/CalDavResponseBase + */ +add_task(async function test_generic_request() { + gServer.reset(); + let uri = gServer.uri("/requests/generic"); + let headers = { "X-Hdr": "exists" }; + let request = new CalDavGenericRequest( + gServer.session, + gMockCalendar, + "PUT", + uri, + headers, + "<body>xpc</body>", + "text/plain" + ); + + strictEqual(request.uri.spec, uri.spec); + strictEqual(request.session.id, gServer.session.id); + strictEqual(request.calendar, gMockCalendar); + strictEqual(request.uploadData, "<body>xpc</body>"); + strictEqual(request.contentType, "text/plain"); + strictEqual(request.response, null); + strictEqual(request.getHeader("X-Hdr"), null); // Only works after commit + + let response = await request.commit(); + + ok(!!request.response); + equal(request.getHeader("X-Hdr"), "exists"); + + equal(response.uri.spec, uri.spec); + ok(!response.redirected); + equal(response.status, 200); + equal(response.statusCategory, 2); + ok(response.ok); + ok(!response.clientError); + ok(!response.conflict); + ok(!response.notFound); + ok(!response.serverError); + equal(response.text, '<response id="generic">xpc</response>'); + equal(response.xml.documentElement.localName, "response"); + equal(response.getHeader("X-Hdr"), "response-exists"); + + let serverResult = gServer.serverRequests.generic; + + equal(serverResult.method, "PUT"); + equal(serverResult.headers.get("x-hdr"), "exists"); + equal(serverResult.headers.get("content-type"), "text/plain"); + equal(serverResult.body, "<body>xpc</body>"); +}); + +add_task(async function test_generic_redirected_request() { + gServer.reset(); + let uri = gServer.uri("/requests/redirected"); + let headers = { + Depth: 1, + Originator: "o", + Recipient: "r", + "If-None-Match": "*", + "If-Match": "123", + }; + let request = new CalDavGenericRequest( + gServer.session, + gMockCalendar, + "PUT", + uri, + headers, + "<body>xpc</body>", + "text/plain" + ); + + let response = await request.commit(); + + ok(response.redirected); + equal(response.status, 200); + equal(response.text, '<response id="redirected-target">xpc</response>'); + equal(response.xml.documentElement.getAttribute("id"), "redirected-target"); + + ok(gServer.serverRequests.redirected); + ok(gServer.serverRequests["redirected-target"]); + + let results = gServer.serverRequests.redirected; + equal(results.headers.get("Depth"), 1); + equal(results.headers.get("Originator"), "o"); + equal(results.headers.get("Recipient"), "r"); + equal(results.headers.get("If-None-Match"), "*"); + equal(results.headers.get("If-Match"), "123"); + + results = gServer.serverRequests["redirected-target"]; + equal(results.headers.get("Depth"), 1); + equal(results.headers.get("Originator"), "o"); + equal(results.headers.get("Recipient"), "r"); + equal(results.headers.get("If-None-Match"), "*"); + equal(results.headers.get("If-Match"), "123"); + + equal(response.lastRedirectStatus, 302); +}); + +add_task(async function test_item_request() { + gServer.reset(); + let uri = gServer.uri("/requests/item/201"); + let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT"; + let componentString = `BEGIN:VCALENDAR\r\nPRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\r\nVERSION:2.0\r\n${icalString}\r\nEND:VCALENDAR\r\n`; + let request = new CalDavItemRequest( + gServer.session, + gMockCalendar, + uri, + new CalEvent(icalString), + "*" + ); + let response = await request.commit(); + + equal(response.status, 201); + ok(response.ok); + + let serverResult = gServer.serverRequests.item; + + equal(serverResult.method, "PUT"); + equal(serverResult.body, componentString); + equal(serverResult.headers.get("If-None-Match"), "*"); + ok(!serverResult.headers.has("If-Match")); + + // Now the same with 204 No Content and an etag + gServer.reset(); + uri = gServer.uri("/requests/item/204"); + request = new CalDavItemRequest( + gServer.session, + gMockCalendar, + uri, + new CalEvent(icalString), + "123123" + ); + response = await request.commit(); + + equal(response.status, 204); + ok(response.ok); + + serverResult = gServer.serverRequests.item; + + equal(serverResult.method, "PUT"); + equal(serverResult.body, componentString); + equal(serverResult.headers.get("If-Match"), "123123"); + ok(!serverResult.headers.has("If-None-Match")); + + // Now the same with 200 OK and no etag + gServer.reset(); + uri = gServer.uri("/requests/item/200"); + request = new CalDavItemRequest(gServer.session, gMockCalendar, uri, new CalEvent(icalString)); + response = await request.commit(); + + equal(response.status, 200); + ok(response.ok); + + serverResult = gServer.serverRequests.item; + + equal(serverResult.method, "PUT"); + equal(serverResult.body, componentString); + ok(!serverResult.headers.has("If-Match")); + ok(!serverResult.headers.has("If-None-Match")); +}); + +add_task(async function test_delete_item_request() { + gServer.reset(); + let uri = gServer.uri("/requests/deleteitem"); + let request = new CalDavDeleteItemRequest(gServer.session, gMockCalendar, uri, "*"); + + strictEqual(request.uploadData, null); + strictEqual(request.contentType, null); + + let response = await request.commit(); + + equal(response.status, 200); + ok(response.ok); + + let serverResult = gServer.serverRequests.deleteitem; + + equal(serverResult.method, "DELETE"); + equal(serverResult.headers.get("If-Match"), "*"); + ok(!serverResult.headers.has("If-None-Match")); + + // Now the same with no etag, and a (valid) 404 response + gServer.reset(); + uri = gServer.uri("/requests/deleteitem/404"); + request = new CalDavDeleteItemRequest(gServer.session, gMockCalendar, uri); + response = await request.commit(); + + equal(response.status, 404); + ok(response.ok); + + serverResult = gServer.serverRequests.deleteitem; + + equal(serverResult.method, "DELETE"); + ok(!serverResult.headers.has("If-Match")); + ok(!serverResult.headers.has("If-None-Match")); +}); + +add_task(async function test_propfind_request() { + gServer.reset(); + let uri = gServer.uri("/calendars/xpcshell/events"); + let props = [ + "D:principal-collection-set", + "D:current-user-principal", + "D:supported-report-set", + "C:supported-calendar-component-set", + "C:schedule-inbox-URL", + "C:schedule-outbox-URL", + "R:obscure-thing-not-found", + ]; + let request = new CalDavPropfindRequest(gServer.session, gMockCalendar, uri, props); + let response = await request.commit(); + + equal(response.status, 207); + ok(response.ok); + + let results = gServer.serverRequests.calendars; + + ok( + results.body.match(/<D:prop>\s*<D:principal-collection-set\/>\s*<D:current-user-principal\/>/) + ); + + equal(Object.keys(response.data).length, 1); + ok(!!response.data[uri.filePath]); + ok(!!response.firstProps); + + let resprops = response.firstProps; + + deepEqual(resprops["D:principal-collection-set"], [ + gServer.uri("/principals/").spec, + gServer.uri("/principals/subthing/").spec, + ]); + equal(resprops["D:current-user-principal"], gServer.uri("/principals/xpcshell/user").spec); + + deepEqual( + [...resprops["D:supported-report-set"].values()], + ["D:principal-property-search", "C:calendar-multiget", "D:sync-collection"] + ); + + deepEqual([...resprops["C:supported-calendar-component-set"].values()], ["VEVENT", "VTODO"]); + equal(resprops["C:schedule-inbox-URL"], gServer.uri("/calendars/xpcshell/inbox").spec); + equal(resprops["C:schedule-outbox-URL"], gServer.uri("/calendars/xpcshell/outbox").spec); + strictEqual(resprops["R:obscure-thing-not-found"], null); + equal(resprops["R:plain-text-prop"], "hello, world"); +}); + +add_task(async function test_davheader_request() { + gServer.reset(); + let uri = gServer.uri("/requests/dav"); + let request = new CalDavHeaderRequest(gServer.session, gMockCalendar, uri); + let response = await request.commit(); + + let serverResult = gServer.serverRequests.dav; + + equal(serverResult.method, "OPTIONS"); + deepEqual([...response.features], ["calendar-schedule", "calendar-auto-schedule"]); + strictEqual(response.version, 1); +}); + +add_task(async function test_propsearch_request() { + gServer.reset(); + let uri = gServer.uri("/principals/"); + let props = ["D:displayname", "B:department", "B:phone", "B:office"]; + let request = new CalDavPrincipalPropertySearchRequest( + gServer.session, + gMockCalendar, + uri, + "doE", + "D:displayname", + props + ); + let response = await request.commit(); + + equal(response.status, 207); + ok(response.ok); + + equal(response.data["http://www.example.com/users/jdoe"]["D:displayname"], "John Doe"); + + ok(gServer.serverRequests.principals.body.includes("<D:match>doE</D:match>")); + ok(gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<\/D:prop>/)); + ok( + gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<B:department\/>/) + ); +}); + +add_task(async function test_outbox_request() { + gServer.reset(); + let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT"; + let uri = gServer.uri("/calendars/xpcshell/outbox"); + let request = new CalDavOutboxRequest( + gServer.session, + gMockCalendar, + uri, + "xpcshell@example.com", + ["recipient1@example.com", "recipient2@example.com"], + "REPLY", + new CalEvent(icalString) + ); + let response = await request.commit(); + + equal(response.status, 200); + ok(response.ok); + + let results = gServer.serverRequests.calendars; + + ok(results.body.includes("METHOD:REPLY")); + equal(results.method, "POST"); + equal(results.headers.get("Originator"), "xpcshell@example.com"); + equal(results.headers.get("Recipient"), "recipient1@example.com, recipient2@example.com"); +}); + +add_task(async function test_freebusy_request() { + gServer.reset(); + let uri = gServer.uri("/calendars/xpcshell/outbox2"); + let request = new CalDavFreeBusyRequest( + gServer.session, + gMockCalendar, + uri, + "mailto:xpcshell@example.com", + "mailto:recipient@example.com", + cal.createDateTime("20180101"), + cal.createDateTime("20180201") + ); + + let response = await request.commit(); + + equal(response.status, 200); + ok(response.ok); + + let results = gServer.serverRequests.calendars; + equal( + ics_unfoldline( + results.body + .replace(/\r\n/g, "\n") + .replace(/(UID|DTSTAMP):[^\n]+\n/g, "") + .trim() + ), + dedent` + BEGIN:VCALENDAR + PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN + VERSION:2.0 + METHOD:REQUEST + BEGIN:VFREEBUSY + DTSTART;VALUE=DATE:20180101 + DTEND;VALUE=DATE:20180201 + ORGANIZER:mailto:xpcshell@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mailto:recipient@example.com + END:VFREEBUSY + END:VCALENDAR + ` + ); + equal(results.method, "POST"); + equal(results.headers.get("Content-Type"), "text/calendar; charset=utf-8"); + equal(results.headers.get("Originator"), "mailto:xpcshell@example.com"); + equal(results.headers.get("Recipient"), "mailto:recipient@example.com"); + + let first = response.firstRecipient; + equal(first.status, "2.0;Success"); + deepEqual( + first.intervals.map(interval => interval.type), + ["UNKNOWN", "FREE", "BUSY", "UNKNOWN"] + ); + deepEqual( + first.intervals.map(interval => interval.begin.icalString + ":" + interval.end.icalString), + [ + "20180101:20180102", + "20180103T010101Z:20180117T010101Z", + "20180118T010101Z:20180125T010101Z", + "20180126:20180201", + ] + ); +}); + +add_task(async function test_caldav_client() { + let client = await gServer.getClient(); + let items = await client.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null); + + equal(items.length, 1); + equal(items[0].title, "δΌθ°"); +}); + +/** + * Test non-ASCII text in the XML response is parsed correctly in CalDavWebDavSyncHandler. + */ +add_task(async function test_caldav_sync() { + gServer.reset(); + let uri = gServer.uri("/calendars/xpcshell/events/"); + gMockCalendar.session = gServer.session; + let webDavSync = new CalDavWebDavSyncHandler(gMockCalendar, uri); + await webDavSync.doWebDAVSync(); + ok(webDavSync.logXML.includes("γ€γγ³γ"), "Non-ASCII text should be parsed correctly"); +}); + +add_task(function test_can_get_google_adapter() { + // Initialize a session with bogus values + const session = new CalDavSession("xpcshell@example.com", "xpcshell"); + + // We don't have a facility for actually testing our Google CalDAV requests, + // but we can at least verify that the adapter looks okay at a glance + equal( + session.authAdapters["apidata.googleusercontent.com"].authorizationEndpoint, + "https://accounts.google.com/o/oauth2/auth" + ); +}); diff --git a/comm/calendar/test/unit/test_calmgr.js b/comm/calendar/test/unit/test_calmgr.js new file mode 100644 index 0000000000..e4d09db0a3 --- /dev/null +++ b/comm/calendar/test/unit/test_calmgr.js @@ -0,0 +1,411 @@ +/* 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 { CalReadableStreamFactory } = ChromeUtils.import( + "resource:///modules/CalReadableStreamFactory.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +/** + * Tests the calICalendarManager interface + */ +function run_test() { + do_calendar_startup(run_next_test); +} + +class CalendarManagerObserver { + QueryInterface = ChromeUtils.generateQI(["calICalendarManager"]); + + constructor() { + this.reset(); + } + + reset() { + this.registered = []; + this.unregistering = []; + this.deleting = []; + } + + check({ unregistering, registered, deleting }) { + equal(this.unregistering[0], unregistering); + equal(this.registered[0], registered); + equal(this.deleting[0], deleting); + + this.reset(); + } + + onCalendarRegistered(calendar) { + this.registered.push(calendar.id); + } + + onCalendarUnregistering(calendar) { + this.unregistering.push(calendar.id); + } + + onCalendarDeleting(calendar) { + this.deleting.push(calendar.id); + } +} + +add_test(function test_builtin_registration() { + function checkCalendarCount(net, rdonly, all) { + equal(cal.manager.networkCalendarCount, net); + equal(cal.manager.readOnlyCalendarCount, rdonly); + equal(cal.manager.calendarCount, all); + } + + // Initially there should be no calendars. + checkCalendarCount(0, 0, 0); + + // Create a local memory calendar, this shouldn't register any calendars. + let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + checkCalendarCount(0, 0, 0); + + // Register an observer to test it. + let calmgrObserver = new CalendarManagerObserver(); + + let readOnly = false; + let calendarObserver = cal.createAdapter(Ci.calIObserver, { + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + equal(aCalendar.id, memory.id); + equal(aName, "readOnly"); + readOnly = aValue; + }, + }); + + memory.addObserver(calendarObserver); + cal.manager.addObserver(calmgrObserver); + + // Register the calendar and check if its counted and observed. + cal.manager.registerCalendar(memory); + calmgrObserver.check({ registered: memory.id }); + checkCalendarCount(0, 0, 1); + + // The calendar should now have an id. + notEqual(memory.id, null); + + // And be in the list of calendars. + equal(memory, cal.manager.getCalendarById(memory.id)); + ok(cal.manager.getCalendars().some(x => x.id == memory.id)); + + // Make it readonly and check if the observer caught it. + memory.setProperty("readOnly", true); + equal(readOnly, true); + + // Now unregister it. + cal.manager.unregisterCalendar(memory); + calmgrObserver.check({ unregistering: memory.id }); + checkCalendarCount(0, 0, 0); + + // The calendar shouldn't be in the list of ids. + equal(cal.manager.getCalendarById(memory.id), null); + ok(cal.manager.getCalendars().every(x => x.id != memory.id)); + + // And finally delete it. + cal.manager.removeCalendar(memory, Ci.calICalendarManager.REMOVE_NO_UNREGISTER); + calmgrObserver.check({ deleting: memory.id }); + checkCalendarCount(0, 0, 0); + + // Now remove the observer again. + cal.manager.removeObserver(calmgrObserver); + memory.removeObserver(calendarObserver); + + // Check if removing it actually worked. + cal.manager.registerCalendar(memory); + cal.manager.removeCalendar(memory); + memory.setProperty("readOnly", false); + calmgrObserver.check({}); + equal(readOnly, true); + checkCalendarCount(0, 0, 0); + + // We are done now, start the next test. + run_next_test(); +}); + +add_task(async function test_dynamic_registration() { + class CalendarProvider extends cal.provider.BaseClass { + QueryInterface = ChromeUtils.generateQI(["calICalendar"]); + type = "blm"; + + constructor() { + super(); + this.initProviderBase(); + } + + getItems(itemFilter, count, rangeStart, rangeEnd, listener) { + return CalReadableStreamFactory.createEmptyReadableStream(); + } + } + + function checkCalendar(expectedCount = 1) { + let calendars = cal.manager.getCalendars(); + equal(calendars.length, expectedCount); + let calendar = calendars[0]; + + if (expectedCount > 0) { + notEqual(calendar, null); + } + return calendar; + } + + let calmgrObserver = new CalendarManagerObserver(); + cal.manager.addObserver(calmgrObserver); + equal(cal.manager.calendarCount, 0); + + // No provider registered. + let calendar = cal.manager.createCalendar("blm", Services.io.newURI("black-lives-matter://")); + equal(calendar, null); + ok(!cal.manager.hasCalendarProvider("blm")); + + // Register dynamic provider. + cal.manager.registerCalendarProvider("blm", CalendarProvider); + calendar = cal.manager.createCalendar("blm", Services.io.newURI("black-lives-matter://")); + notEqual(calendar, null); + ok(calendar.wrappedJSObject instanceof CalendarProvider); + ok(cal.manager.hasCalendarProvider("blm")); + + // Register a calendar using it. + cal.manager.registerCalendar(calendar); + calendar = checkCalendar(); + + let originalId = calendar.id; + calmgrObserver.check({ registered: originalId }); + + // Unregister the provider from under its feet. + cal.manager.unregisterCalendarProvider("blm"); + calendar = checkCalendar(); + calmgrObserver.check({ unregistering: originalId, registered: originalId }); + + equal(calendar.type, "blm"); + equal(calendar.getProperty("force-disabled"), true); + equal(calendar.id, originalId); + + // Re-register the provider should reactive it. + cal.manager.registerCalendarProvider("blm", CalendarProvider); + calendar = checkCalendar(); + calmgrObserver.check({ unregistering: originalId, registered: originalId }); + + equal(calendar.type, "blm"); + notEqual(calendar.getProperty("force-disabled"), true); + equal(calendar.id, originalId); + + // Make sure calendar is loaded from prefs. + cal.manager.unregisterCalendarProvider("blm"); + calmgrObserver.check({ unregistering: originalId, registered: originalId }); + + await new Promise(resolve => cal.manager.shutdown({ onResult: resolve })); + cal.manager.wrappedJSObject.mCache = null; + await new Promise(resolve => cal.manager.startup({ onResult: resolve })); + calmgrObserver.check({}); + + calendar = checkCalendar(); + equal(calendar.type, "blm"); + equal(calendar.getProperty("force-disabled"), true); + equal(calendar.id, originalId); + + // Unregister the calendar for cleanup. + cal.manager.unregisterCalendar(calendar); + checkCalendar(0); + calmgrObserver.check({ unregistering: originalId }); +}); + +add_test(function test_calobserver() { + function checkCounters(add, modify, del, alladd, allmodify, alldel) { + equal(calcounter.addItem, add); + equal(calcounter.modifyItem, modify); + equal(calcounter.deleteItem, del); + equal(allcounter.addItem, alladd === undefined ? add : alladd); + equal(allcounter.modifyItem, allmodify === undefined ? modify : allmodify); + equal(allcounter.deleteItem, alldel === undefined ? del : alldel); + resetCounters(); + } + function resetCounters() { + calcounter = { addItem: 0, modifyItem: 0, deleteItem: 0 }; + allcounter = { addItem: 0, modifyItem: 0, deleteItem: 0 }; + } + + // First of all we need a local calendar to work on and some variables + let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + let memory2 = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + let calcounter, allcounter; + + // These observers will end up counting calls which we will use later on + let calobs = cal.createAdapter(Ci.calIObserver, { + onAddItem: () => calcounter.addItem++, + onModifyItem: () => calcounter.modifyItem++, + onDeleteItem: () => calcounter.deleteItem++, + }); + let allobs = cal.createAdapter(Ci.calIObserver, { + onAddItem: () => allcounter.addItem++, + onModifyItem: () => allcounter.modifyItem++, + onDeleteItem: () => allcounter.deleteItem++, + }); + + // Set up counters and observers + resetCounters(); + cal.manager.registerCalendar(memory); + cal.manager.registerCalendar(memory2); + cal.manager.addCalendarObserver(allobs); + memory.addObserver(calobs); + + // Add an item + let item = new CalEvent(); + item.id = cal.getUUID(); + item.startDate = cal.dtz.now(); + item.endDate = cal.dtz.now(); + memory.addItem(item); + checkCounters(1, 0, 0); + + // Modify the item + let newItem = item.clone(); + newItem.title = "title"; + memory.modifyItem(newItem, item); + checkCounters(0, 1, 0); + + // Delete the item + newItem.generation++; // circumvent generation checks for easier code + memory.deleteItem(newItem); + checkCounters(0, 0, 1); + + // Now check the same for adding the item to a calendar only observed by the + // calendar manager. The calcounters should still be 0, but the calendar + // manager counter should have an item added, modified and deleted + memory2.addItem(item); + memory2.modifyItem(newItem, item); + memory2.deleteItem(newItem); + checkCounters(0, 0, 0, 1, 1, 1); + + // Remove observers + memory.removeObserver(calobs); + cal.manager.removeCalendarObserver(allobs); + + // Make sure removing it actually worked + memory.addItem(item); + memory.modifyItem(newItem, item); + memory.deleteItem(newItem); + checkCounters(0, 0, 0); + + // We are done now, start the next test + run_next_test(); +}); + +add_test(function test_removeModes() { + function checkCounts(modes, shouldDelete, expectCount, extraFlags = 0) { + if (cal.manager.calendarCount == baseCalendarCount) { + cal.manager.registerCalendar(memory); + equal(cal.manager.calendarCount, baseCalendarCount + 1); + } + deleteCalled = false; + removeModes = modes; + + cal.manager.removeCalendar(memory, extraFlags); + equal(cal.manager.calendarCount, baseCalendarCount + expectCount); + equal(deleteCalled, shouldDelete); + } + function mockCalendar(memory) { + let oldGetProperty = memory.wrappedJSObject.getProperty; + memory.wrappedJSObject.getProperty = function (name) { + if (name == "capabilities.removeModes") { + return removeModes; + } + return oldGetProperty.apply(this, arguments); + }; + + let oldDeleteCalendar = memory.wrappedJSObject.deleteCalendar; + memory.wrappedJSObject.deleteCalendar = function (calendar, listener) { + deleteCalled = true; + return oldDeleteCalendar.apply(this, arguments); + }; + } + + // For better readability + const SHOULD_DELETE = true, + SHOULD_NOT_DELETE = false; + + let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + let baseCalendarCount = cal.manager.calendarCount; + let removeModes = null; + let deleteCalled = false; + + mockCalendar(memory); + + checkCounts([], SHOULD_NOT_DELETE, 1); + checkCounts(["unsubscribe"], SHOULD_NOT_DELETE, 0); + checkCounts(["unsubscribe", "delete"], SHOULD_DELETE, 0); + checkCounts( + ["unsubscribe", "delete"], + SHOULD_NOT_DELETE, + 0, + Ci.calICalendarManager.REMOVE_NO_DELETE + ); + checkCounts(["delete"], SHOULD_DELETE, 0); + + run_next_test(); +}); + +add_test(function test_calprefs() { + let prop; + let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + cal.manager.registerCalendar(memory); + let memid = memory.id; + + // First set a few values, one of each relevant type + memory.setProperty("stringpref", "abc"); + memory.setProperty("boolpref", true); + memory.setProperty("intpref", 123); + memory.setProperty("bigintpref", 1394548721296); + memory.setProperty("floatpref", 0.5); + + // Before checking the value, reinitialize the memory calendar with the + // same id to make sure the pref value isn't just cached + memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + memory.id = memid; + + // First test the standard types + prop = memory.getProperty("stringpref"); + equal(typeof prop, "string"); + equal(prop, "abc"); + + prop = memory.getProperty("boolpref"); + equal(typeof prop, "boolean"); + equal(prop, true); + + prop = memory.getProperty("intpref"); + equal(typeof prop, "number"); + equal(prop, 123); + + // These two are a special case test for bug 979262 + prop = memory.getProperty("bigintpref"); + equal(typeof prop, "number"); + equal(prop, 1394548721296); + + prop = memory.getProperty("floatpref"); + equal(typeof prop, "number"); + equal(prop, 0.5); + + // Check if changing pref types works. We need to reset the calendar again + // because retrieving the value just cached it again. + memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + memory.id = memid; + + cal.manager.setCalendarPref_(memory, "boolpref", "kinda true"); + prop = memory.getProperty("boolpref"); + equal(typeof prop, "string"); + equal(prop, "kinda true"); + + // Check if unsetting a pref works + memory.setProperty("intpref", null); + memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://")); + memory.id = memid; + prop = memory.getProperty("intpref"); + ok(prop === null); + + // We are done now, start the next test + run_next_test(); +}); diff --git a/comm/calendar/test/unit/test_calreadablestreamfactory.js b/comm/calendar/test/unit/test_calreadablestreamfactory.js new file mode 100644 index 0000000000..9da71e47ef --- /dev/null +++ b/comm/calendar/test/unit/test_calreadablestreamfactory.js @@ -0,0 +1,195 @@ +/* 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/. */ + +/** + * Tests for the ReadableStreams generated by CalReadableStreamFactory. + */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +var { CalReadableStreamFactory } = ChromeUtils.import( + "resource:///modules/CalReadableStreamFactory.jsm" +); + +/** + * @type {object} BoundedReadableStreamTestSpec + * @property {number} maxTotalItems + * @property {number} maxQueuedItems + * @property {number} actualTotalItems + * @property {number} actualChunkSize + * @property {Function} onChunk + */ + +/** + * Common test for the BoundedReadableStream. + * + * @param {BoundedReadableStreamTestSpec} spec + */ +async function doBoundedReadableStreamTest({ + maxTotalItems, + maxQueuedItems, + actualTotalItems, + actualChunkSize, + onChunk, +}) { + let totalChunks = Math.ceil(actualTotalItems / actualChunkSize); + let stream = CalReadableStreamFactory.createBoundedReadableStream(maxTotalItems, maxQueuedItems, { + start(controller) { + let i = 0; + for (i; i < totalChunks; i++) { + controller.enqueue( + Array(actualChunkSize) + .fill(null) + .map(() => new CalEvent()) + ); + } + info( + `Enqueued ${ + i * actualChunkSize + } items across ${i} chunks at a rate of ${actualChunkSize} items per chunk` + ); + }, + }); + + for await (let chunk of cal.iterate.streamValues(stream)) { + Assert.ok(Array.isArray(chunk), "chunk received is an array"); + Assert.ok( + chunk.every(item => item instanceof CalEvent), + "all chunk elements are CalEvent instances" + ); + onChunk(chunk); + } +} + +/** + * Tests the BoundedReadableStream works as expected when the total items enqueued + * and the chunk size match the limits set. + */ +add_task(async function testBoundedReadableStreamWorksWithinLimits() { + let maxTotalItems = 35; + let maxQueuedItems = 5; + let totalChunks = 35 / 5; + + let chunksRead = 0; + await doBoundedReadableStreamTest({ + maxTotalItems, + maxQueuedItems, + actualTotalItems: maxTotalItems, + actualChunkSize: maxQueuedItems, + onChunk(chunk) { + Assert.equal(chunk.length, maxQueuedItems, `chunk has ${maxQueuedItems} items`); + chunksRead++; + }, + }); + Assert.equal(chunksRead, totalChunks, `received ${totalChunks} chunks from stream`); +}); + +/** + * Tests that the stream automatically closes when maxTotalItemsReached is true + * even if there are more items to come. + */ +add_task(async function testBoundedReadableStreamClosesIfMaxTotalItemsReached() { + let maxTotalItems = 35; + let maxQueuedItems = 5; + let items = []; + + await doBoundedReadableStreamTest({ + maxTotalItems, + maxQueuedItems, + actualTotalItems: 50, + actualChunkSize: 7, + onChunk(chunk) { + items = items.concat(chunk); + }, + }); + Assert.equal(items.length, maxTotalItems, `received ${maxTotalItems} items from stream`); +}); + +/** + * Test that chunks enqueued with smaller than the maxQueueSize value are held + * until the threshold is reached. + */ +add_task(async function testBoundedReadableStreamBuffersChunks() { + let maxTotalItems = 35; + let maxQueuedItems = 5; + let totalChunks = 35 / 5; + + let chunksRead = 0; + await doBoundedReadableStreamTest({ + maxTotalItems, + maxQueuedItems, + actualTotalItems: 35, + actualChunkSize: 1, + onChunk(chunk) { + Assert.equal(chunk.length, maxQueuedItems, `chunk has ${maxQueuedItems} items`); + chunksRead++; + }, + }); + Assert.equal(chunksRead, totalChunks, `received ${totalChunks} chunks from stream`); +}); + +/** + * Test the CombinedReadbleStream streams from all of its streams. + */ +add_task(async function testCombinedReadableStreamStreamsAll() { + let mkStream = () => + CalReadableStreamFactory.createReadableStream({ + start(controller) { + for (let i = 0; i < 5; i++) { + controller.enqueue(new CalEvent()); + } + controller.close(); + }, + }); + + let stream = CalReadableStreamFactory.createCombinedReadableStream([ + mkStream(), + mkStream(), + mkStream(), + ]); + + let items = []; + for await (let value of cal.iterate.streamValues(stream)) { + Assert.ok(value instanceof CalEvent, "value read from stream is CalEvent instance"); + items.push(value); + } + Assert.equal(items.length, 15, "read a total of 15 items from the stream"); +}); + +/** + * Test the MappedReadableStream applies the MapStreamFunction to each value + * read from the stream. + */ +add_task(async function testMappedReadableStream() { + let stream = CalReadableStreamFactory.createMappedReadableStream( + CalReadableStreamFactory.createReadableStream({ + start(controller) { + for (let i = 0; i < 10; i++) { + controller.enqueue(1); + } + controller.close(); + }, + }), + value => value * 0 + ); + + let values = []; + for await (let value of cal.iterate.streamValues(stream)) { + Assert.equal(value, 0, "read value inverted to 0"); + values.push(value); + } + Assert.equal(values.length, 10, "all 10 values were transformed"); +}); + +/** + * Test the EmptyReadableStream is already closed. + */ +add_task(async function testEmptyReadableStream() { + let stream = CalReadableStreamFactory.createEmptyReadableStream(); + let values = []; + for await (let value of cal.iterate.streamValues(stream)) { + values.push(value); + } + Assert.equal(values.length, 0, "no values were read from the empty stream"); +}); diff --git a/comm/calendar/test/unit/test_data_bags.js b/comm/calendar/test/unit/test_data_bags.js new file mode 100644 index 0000000000..dd1f2a6abd --- /dev/null +++ b/comm/calendar/test/unit/test_data_bags.js @@ -0,0 +1,151 @@ +/* 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/. */ + +function run_test() { + test_listener_set(); + test_observer_set(); + test_operation_group(); +} + +function test_listener_set() { + let set = new cal.data.ListenerSet(Ci.calIOperationListener); + let listener1Id = null; + let listener2Id = null; + + let listener1 = cal.createAdapter("calIOperationListener", { + onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) { + listener1Id = aId; + }, + }); + let listener2 = cal.createAdapter("calIOperationListener", { + onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) { + listener2Id = aId; + }, + }); + + set.add(listener1); + set.add(listener2); + set.notify("onOperationComplete", [null, null, null, "test", null]); + equal(listener1Id, "test"); + equal(listener2Id, "test"); + + set.delete(listener2); + listener1Id = listener2Id = null; + set.notify("onOperationComplete", [null, null, null, "test2", null]); + equal(listener1Id, "test2"); + strictEqual(listener2Id, null); + + // Re-adding the listener may lead to an endless loop if the notify + // function uses a live list of observers. + let called = 0; + let listener3 = cal.createAdapter("calIOperationListener", { + onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) { + set.delete(listener3); + if (called == 0) { + set.add(listener3); + } + called++; + }, + }); + + set.add(listener3); + set.notify("onOperationComplete", [null, null, null, "test3", null]); + equal(called, 1); +} + +function test_observer_set() { + let set = new cal.data.ObserverSet(Ci.calIObserver); + let listenerCountBegin1 = 0; + let listenerCountBegin2 = 0; + let listenerCountEnd1 = 0; + let listenerCountEnd2 = 0; + + let listener1 = cal.createAdapter("calIObserver", { + onStartBatch() { + listenerCountBegin1++; + }, + onEndBatch() { + listenerCountEnd1++; + }, + }); + let listener2 = cal.createAdapter("calIObserver", { + onStartBatch() { + listenerCountBegin2++; + }, + onEndBatch() { + listenerCountEnd2++; + }, + }); + + set.add(listener1); + equal(listenerCountBegin1, 0); + equal(listenerCountEnd1, 0); + equal(set.batchCount, 0); + + set.notify("onStartBatch"); + equal(listenerCountBegin1, 1); + equal(listenerCountEnd1, 0); + equal(set.batchCount, 1); + + set.add(listener2); + equal(listenerCountBegin1, 1); + equal(listenerCountEnd1, 0); + equal(listenerCountBegin2, 1); + equal(listenerCountEnd2, 0); + equal(set.batchCount, 1); + + set.add(listener1); + equal(listenerCountBegin1, 1); + equal(listenerCountEnd1, 0); + equal(listenerCountBegin2, 1); + equal(listenerCountEnd2, 0); + equal(set.batchCount, 1); + + set.notify("onEndBatch"); + equal(listenerCountBegin1, 1); + equal(listenerCountEnd1, 1); + equal(listenerCountBegin2, 1); + equal(listenerCountEnd2, 1); + equal(set.batchCount, 0); +} + +function test_operation_group() { + let calledCancel = false; + let calledOperationCancel = null; + let group = new cal.data.OperationGroup(); + ok(group.id.endsWith("-0")); + ok(group.isPending); + equal(group.status, Cr.NS_OK); + ok(group.isEmpty); + + let operation = { + id: 123, + isPending: true, + cancel: status => { + calledOperationCancel = status; + }, + }; + + group.add(operation); + ok(!group.isEmpty); + + group.notifyCompleted(Cr.NS_ERROR_FAILURE); + ok(!group.isPending); + equal(group.status, Cr.NS_ERROR_FAILURE); + strictEqual(calledOperationCancel, null); + + group.remove(operation); + ok(group.isEmpty); + + group = new cal.data.OperationGroup(() => { + calledCancel = true; + }); + ok(group.id.endsWith("-1")); + group.add(operation); + + group.cancel(); + equal(group.status, Ci.calIErrors.OPERATION_CANCELLED); + equal(calledOperationCancel, Ci.calIErrors.OPERATION_CANCELLED); + ok(calledCancel); +} diff --git a/comm/calendar/test/unit/test_datetime.js b/comm/calendar/test/unit/test_datetime.js new file mode 100644 index 0000000000..a754e570a8 --- /dev/null +++ b/comm/calendar/test/unit/test_datetime.js @@ -0,0 +1,99 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + function getMozTimezone(tzid) { + return cal.timezoneService.getTimezone(tzid); + } + + let date = cal.createDateTime(); + date.resetTo(2005, 10, 13, 10, 0, 0, getMozTimezone("/mozilla.org/20050126_1/America/Bogota")); + + equal(date.hour, 10); + equal(date.icalString, "20051113T100000"); + + let date_floating = date.getInTimezone(cal.dtz.floating); + equal(date_floating.hour, 10); + + let date_utc = date.getInTimezone(cal.dtz.UTC); + equal(date_utc.hour, 15); + equal(date_utc.icalString, "20051113T150000Z"); + + date.hour = 25; + equal(date.hour, 1); + equal(date.day, 14); + + // Test nativeTime on dates + // setting .isDate to be true on a date should not change its nativeTime + // bug 315954, + date.hour = 0; + let date_allday = date.clone(); + date_allday.isDate = true; + equal(date.nativeTime, date_allday.nativeTime); + + // Daylight savings test + date.resetTo(2006, 2, 26, 1, 0, 0, getMozTimezone("/mozilla.org/20050126_1/Europe/Amsterdam")); + + equal(date.weekday, 0); + equal(date.timezoneOffset, 1 * 3600); + + date.day += 1; + equal(date.timezoneOffset, 2 * 3600); + + // Bug 398724 - Problems with floating all-day items + let event = new CalEvent( + "BEGIN:VEVENT\nUID:45674d53-229f-48c6-9f3b-f2b601e7ae4d\nSUMMARY:New Event\nDTSTART;VALUE=DATE:20071003\nDTEND;VALUE=DATE:20071004\nEND:VEVENT" + ); + ok(event.startDate.timezone.isFloating); + ok(event.endDate.timezone.isFloating); + + // Bug 392853 - Same times, different timezones, but subtractDate says times are PT0S apart + const zeroLength = cal.createDuration(); + const a = cal.dtz.jsDateToDateTime(new Date()); + a.timezone = getMozTimezone("/mozilla.org/20071231_1/Europe/Berlin"); + + let b = a.clone(); + b.timezone = getMozTimezone("/mozilla.org/20071231_1/America/New_York"); + + let duration = a.subtractDate(b); + notEqual(duration.compare(zeroLength), 0); + notEqual(a.compare(b), 0); + + // Should lead to zero length duration + b = a.getInTimezone(getMozTimezone("/mozilla.org/20071231_1/America/New_York")); + duration = a.subtractDate(b); + equal(duration.compare(zeroLength), 0); + equal(a.compare(b), 0); + + // Check that we can get the same timezone with several aliases + equal(getMozTimezone("/mozilla.org/xyz/Asia/Calcutta").tzid, "Asia/Calcutta"); + equal(getMozTimezone("Asia/Calcutta").tzid, "Asia/Calcutta"); + equal(getMozTimezone("Asia/Kolkata").tzid, "Asia/Kolkata"); + + // A newly created date should be in UTC, as should its clone + let utc = cal.createDateTime(); + equal(utc.timezone.tzid, "UTC"); + equal(utc.clone().timezone.tzid, "UTC"); + equal(utc.timezoneOffset, 0); + + // Bug 794477 - setting jsdate across compartments needs to work + let someDate = new Date(); + let createdDate = cal.dtz.jsDateToDateTime(someDate).getInTimezone(cal.dtz.defaultTimezone); + someDate.setMilliseconds(0); + equal(someDate.getTime(), cal.dtz.dateTimeToJsDate(createdDate).getTime()); + + // Comparing a date-time with a date of the same day should be 0 + equal(cal.createDateTime("20120101T120000").compare(cal.createDateTime("20120101")), 0); + equal(cal.createDateTime("20120101").compare(cal.createDateTime("20120101T120000")), 0); +} diff --git a/comm/calendar/test/unit/test_datetime_before_1970.js b/comm/calendar/test/unit/test_datetime_before_1970.js new file mode 100644 index 0000000000..a5e5dd0054 --- /dev/null +++ b/comm/calendar/test/unit/test_datetime_before_1970.js @@ -0,0 +1,31 @@ +/* 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/. */ + +function run_test() { + // Bug 769938 - dates before 1970 are not handled correctly + // due to signed vs. unsigned mismatch in PRTime in xpconnect + + let dateTime1950 = cal.createDateTime(); + dateTime1950.year = 1950; + equal(dateTime1950.year, 1950); + + let dateTime1955 = cal.dtz.jsDateToDateTime(new Date(Date.UTC(1955, 6, 15))); + equal(dateTime1955.year, 1955); + + let dateTime1965 = cal.createDateTime(); + dateTime1965.nativeTime = -150000000000000; + equal(dateTime1965.year, 1965); + equal(dateTime1965.nativeTime, -150000000000000); + + let dateTime1990 = cal.createDateTime(); + dateTime1990.year = 1990; + + let dateTime2050 = cal.createDateTime(); + dateTime2050.year = 2050; + + ok(dateTime1950.nativeTime < dateTime1955.nativeTime); + ok(dateTime1955.nativeTime < dateTime1965.nativeTime); + ok(dateTime1965.nativeTime < dateTime1990.nativeTime); + ok(dateTime1990.nativeTime < dateTime2050.nativeTime); +} diff --git a/comm/calendar/test/unit/test_datetimeformatter.js b/comm/calendar/test/unit/test_datetimeformatter.js new file mode 100644 index 0000000000..d297b25d0b --- /dev/null +++ b/comm/calendar/test/unit/test_datetimeformatter.js @@ -0,0 +1,604 @@ +/* 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 { formatter } = cal.dtz; + +const { CalTimezone } = ChromeUtils.import("resource:///modules/CalTimezone.jsm"); +const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); + +function run_test() { + do_calendar_startup(run_next_test); +} + +// This test assumes the timezone of your system is not set to Pacific/Fakaofo or equivalent. + +// Time format is platform dependent, so we use alternative result sets here in 'expected'. +// The first two meet configurations running for automated tests, +// the first one is for Windows, the second one for Linux and Mac, unless otherwise noted. +// If you get a failure for this test, add your pattern here. + +add_task(async function formatDate_test() { + let data = [ + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Fakaofo", + dateformat: 0, // long + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Fakaofo", + dateformat: 1, // short + }, + expected: ["4/1/2017", "4/1/17"], + }, + ]; + + let dateformat = Services.prefs.getIntPref("calendar.date.format", 0); + let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + + let i = 0; + for (let test of data) { + i++; + Services.prefs.setIntPref("calendar.date.format", test.input.dateformat); + let zone = + test.input.timezone == "floating" + ? cal.dtz.floating + : cal.timezoneService.getTimezone(test.input.timezone); + let date = cal.createDateTime(test.input.datetime).getInTimezone(zone); + + let formatted = formatter.formatDate(date); + ok( + test.expected.includes(formatted), + "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')" + ); + } + // let's reset the preferences + Services.prefs.setStringPref("calendar.timezone.local", tzlocal); + Services.prefs.setIntPref("calendar.date.format", dateformat); +}); + +add_task(async function formatDateShort_test() { + let data = [ + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Fakaofo", + }, + expected: ["4/1/2017", "4/1/17"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Kiritimati", + }, + expected: ["4/1/2017", "4/1/17"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "UTC", + }, + expected: ["4/1/2017", "4/1/17"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "floating", + }, + expected: ["4/1/2017", "4/1/17"], + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Fakaofo", + }, + expected: ["4/1/2017", "4/1/17"], + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Kiritimati", + }, + expected: ["4/1/2017", "4/1/17"], + }, + { + input: { + datetime: "20170401", + timezone: "UTC", + }, + expected: ["4/1/2017", "4/1/17"], + }, + { + input: { + datetime: "20170401", + timezone: "floating", + }, + expected: ["4/1/2017", "4/1/17"], + }, + ]; + + let dateformat = Services.prefs.getIntPref("calendar.date.format", 0); + let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + // we make sure to have set long format + Services.prefs.setIntPref("calendar.date.format", 0); + + let i = 0; + for (let test of data) { + i++; + + let zone = + test.input.timezone == "floating" + ? cal.dtz.floating + : cal.timezoneService.getTimezone(test.input.timezone); + let date = cal.createDateTime(test.input.datetime).getInTimezone(zone); + + let formatted = formatter.formatDateShort(date); + ok( + test.expected.includes(formatted), + "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')" + ); + } + // let's reset the preferences + Services.prefs.setStringPref("calendar.timezone.local", tzlocal); + Services.prefs.setIntPref("calendar.date.format", dateformat); +}); + +add_task(async function formatDateLong_test() { + let data = [ + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Fakaofo", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Kiritimati", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "UTC", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "floating", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Fakaofo", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Kiritimati", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401", + timezone: "UTC", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + { + input: { + datetime: "20170401", + timezone: "floating", + }, + expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"], + }, + ]; + + let dateformat = Services.prefs.getIntPref("calendar.date.format", 0); + let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + // we make sure to have set short format + Services.prefs.setIntPref("calendar.date.format", 1); + + let i = 0; + for (let test of data) { + i++; + + let zone = + test.input.timezone == "floating" + ? cal.dtz.floating + : cal.timezoneService.getTimezone(test.input.timezone); + let date = cal.createDateTime(test.input.datetime).getInTimezone(zone); + + let formatted = formatter.formatDateLong(date); + ok( + test.expected.includes(formatted), + "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')" + ); + } + // let's reset the preferences + Services.prefs.setStringPref("calendar.timezone.local", tzlocal); + Services.prefs.setIntPref("calendar.date.format", dateformat); +}); + +add_task(async function formatDateWithoutYear_test() { + let data = [ + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Fakaofo", + }, + expected: "Apr 1", + }, + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Kiritimati", + }, + expected: "Apr 1", + }, + { + input: { + datetime: "20170401T180000", + timezone: "UTC", + }, + expected: "Apr 1", + }, + { + input: { + datetime: "20170401T180000", + timezone: "floating", + }, + expected: "Apr 1", + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Fakaofo", + }, + expected: "Apr 1", + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Kiritimati", + }, + expected: "Apr 1", + }, + { + input: { + datetime: "20170401", + timezone: "UTC", + }, + expected: "Apr 1", + }, + { + input: { + datetime: "20170401", + timezone: "floating", + }, + expected: "Apr 1", + }, + ]; + + let dateformat = Services.prefs.getIntPref("calendar.date.format", 0); + let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + // we make sure to have set short format + Services.prefs.setIntPref("calendar.date.format", 1); + + let i = 0; + for (let test of data) { + i++; + + let zone = + test.input.timezone == "floating" + ? cal.dtz.floating + : cal.timezoneService.getTimezone(test.input.timezone); + let date = cal.createDateTime(test.input.datetime).getInTimezone(zone); + + equal(formatter.formatDateWithoutYear(date), test.expected, "(test #" + i + ")"); + } + // let's reset the preferences + Services.prefs.setStringPref("calendar.timezone.local", tzlocal); + Services.prefs.setIntPref("calendar.date.format", dateformat); +}); + +add_task(async function formatDateLongWithoutYear_test() { + let data = [ + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Fakaofo", + }, + expected: "Saturday, April 1", + }, + { + input: { + datetime: "20170401T180000", + timezone: "Pacific/Kiritimati", + }, + expected: "Saturday, April 1", + }, + { + input: { + datetime: "20170401T180000", + timezone: "UTC", + }, + expected: "Saturday, April 1", + }, + { + input: { + datetime: "20170401T180000", + timezone: "floating", + }, + expected: "Saturday, April 1", + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Fakaofo", + }, + expected: "Saturday, April 1", + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Kiritimati", + }, + expected: "Saturday, April 1", + }, + { + input: { + datetime: "20170401", + timezone: "UTC", + }, + expected: "Saturday, April 1", + }, + { + input: { + datetime: "20170401", + timezone: "floating", + }, + expected: "Saturday, April 1", + }, + ]; + + let dateformat = Services.prefs.getIntPref("calendar.date.format", 0); + let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + // we make sure to have set short format + Services.prefs.setIntPref("calendar.date.format", 1); + + let i = 0; + for (let test of data) { + i++; + + let zone = + test.input.timezone == "floating" + ? cal.dtz.floating + : cal.timezoneService.getTimezone(test.input.timezone); + let date = cal.createDateTime(test.input.datetime).getInTimezone(zone); + + equal(formatter.formatDateLongWithoutYear(date), test.expected, "(test #" + i + ")"); + } + // let's reset the preferences + Services.prefs.setStringPref("calendar.timezone.local", tzlocal); + Services.prefs.setIntPref("calendar.date.format", dateformat); +}); + +add_task(async function formatTime_test() { + let data = [ + { + input: { + datetime: "20170401T090000", + timezone: "Pacific/Fakaofo", + }, + expected: ["9:00 AM", "09:00"], // Windows+Mac, Linux. + }, + { + input: { + datetime: "20170401T090000", + timezone: "Pacific/Kiritimati", + }, + expected: ["9:00 AM", "09:00"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "UTC", + }, + expected: ["6:00 PM", "18:00"], + }, + { + input: { + datetime: "20170401T180000", + timezone: "floating", + }, + expected: ["6:00 PM", "18:00"], + }, + { + input: { + datetime: "20170401", + timezone: "Pacific/Fakaofo", + }, + expected: "All Day", + }, + ]; + + let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo"); + + let i = 0; + for (let test of data) { + i++; + + let zone = + test.input.timezone == "floating" + ? cal.dtz.floating + : cal.timezoneService.getTimezone(test.input.timezone); + let date = cal.createDateTime(test.input.datetime).getInTimezone(zone); + + let formatted = formatter.formatTime(date); + ok( + test.expected.includes(formatted), + "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')" + ); + } + // let's reset the preferences + Services.prefs.setStringPref("calendar.timezone.local", tzlocal); +}); + +add_task(function formatTime_test_with_arbitrary_timezone() { + // Create a timezone with an arbitrary offset and a time zone ID we can be + // reasonably sure Gecko won't recognize so we can be sure that we aren't + // relying on the time zone ID to be valid. + const tzdef = + "BEGIN:VTIMEZONE\n" + + "TZID:Nowhere/Middle\n" + + "BEGIN:STANDARD\n" + + "DTSTART:16010101T000000\n" + + "TZOFFSETFROM:-0741\n" + + "TZOFFSETTO:-0741\n" + + "END:STANDARD\n" + + "END:VTIMEZONE"; + + const timezone = new CalTimezone( + ICAL.Timezone.fromData({ + tzid: "Nowhere/Middle", + component: tzdef, + }) + ); + + const expected = ["6:19 AM", "06:19"]; + + const dateTime = cal.createDateTime("20220916T140000Z").getInTimezone(timezone); + const formatted = formatter.formatTime(dateTime); + + ok(expected.includes(formatted), `expected '${expected}', actual result ${formatted}`); +}); + +add_task(async function formatInterval_test() { + let data = [ + //1: task-without-dates + { + input: {}, + expected: "no start or due date", + }, + //2: task-without-due-date + { + input: { start: "20220916T140000Z" }, + expected: [ + "start date Friday, September 16, 2022 2:00 PM", + "start date Friday, September 16, 2022 14:00", + ], + }, + //3: task-without-start-date + { + input: { end: "20220916T140000Z" }, + expected: [ + "due date Friday, September 16, 2022 2:00 PM", + "due date Friday, September 16, 2022 14:00", + ], + }, + //4: all-day + { + input: { + start: "20220916T140000Z", + end: "20220916T140000Z", + allDay: true, + }, + expected: "Friday, September 16, 2022", + }, + //5: all-day-between-years + { + input: { + start: "20220916T140000Z", + end: "20230916T140000Z", + allDay: true, + }, + expected: "September 16, 2022 β September 16, 2023", + }, + //6: all-day-in-month + { + input: { + start: "20220916T140000Z", + end: "20220920T140000Z", + allDay: true, + }, + expected: "September 16 β 20, 2022", + }, + //7: all-day-between-months + { + input: { + start: "20220916T140000Z", + end: "20221020T140000Z", + allDay: true, + }, + expected: "September 16 β October 20, 2022", + }, + //8: same-date-time + { + input: { + start: "20220916T140000Z", + end: "20220916T140000Z", + }, + expected: ["Friday, September 16, 2022 2:00 PM", "Friday, September 16, 2022 14:00"], + }, + //9: same-day + { + input: { + start: "20220916T140000Z", + end: "20220916T160000Z", + }, + expected: [ + "Friday, September 16, 2022 2:00 PM β 4:00 PM", + "Friday, September 16, 2022 14:00 β 16:00", + ], + }, + //10: several-days + { + input: { + start: "20220916T140000Z", + end: "20220920T160000Z", + }, + expected: [ + "Friday, September 16, 2022 2:00 PM β Tuesday, September 20, 2022 4:00 PM", + "Friday, September 16, 2022 14:00 β Tuesday, September 20, 2022 16:00", + ], + }, + ]; + + let i = 0; + for (let test of data) { + i++; + let startDate = test.input.start ? cal.createDateTime(test.input.start) : null; + let endDate = test.input.end ? cal.createDateTime(test.input.end) : null; + + if (test.input.allDay) { + startDate.isDate = true; + } + + let formatted = formatter.formatInterval(startDate, endDate); + ok( + test.expected.includes(formatted), + "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')" + ); + } +}); diff --git a/comm/calendar/test/unit/test_deleted_items.js b/comm/calendar/test/unit/test_deleted_items.js new file mode 100644 index 0000000000..d68c927dae --- /dev/null +++ b/comm/calendar/test/unit/test_deleted_items.js @@ -0,0 +1,106 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +add_setup(function () { + // The deleted items service is started automatically by the start-up + // procedure, but that doesn't happen in XPCShell tests. Add an observer + // ourselves to simulate the behaviour. + let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems); + Services.obs.addObserver(delmgr, "profile-after-change"); + + do_calendar_startup(run_next_test); +}); + +function check_delmgr_call(aFunc) { + let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems); + + return new Promise((resolve, reject) => { + delmgr.wrappedJSObject.completedNotifier.handleCompletion = aReason => { + if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) { + resolve(); + } else { + reject(aReason); + } + }; + aFunc(); + }); +} + +add_task(async function test_deleted_items() { + let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems); + + // No items have been deleted, retrieving one should return null. + equal(delmgr.getDeletedDate("random"), null); + equal(delmgr.getDeletedDate("random", "random"), null); + + // Make sure the cache is initially flushed and that this doesn't throw an + // error. + await check_delmgr_call(() => delmgr.flush()); + + let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-storage-calendar://")); + cal.manager.registerCalendar(memory); + + let item = new CalEvent(); + item.id = "test-item-1"; + item.startDate = cal.dtz.now(); + item.endDate = cal.dtz.now(); + + // Add the item, it still shouldn't be in the deleted database. + await check_delmgr_call(() => memory.addItem(item)); + equal(delmgr.getDeletedDate(item.id), null); + equal(delmgr.getDeletedDate(item.id, memory.id), null); + + // We need to stop time so we have something to compare with. + let referenceDate = cal.createDateTime("20120726T112045"); + referenceDate.timezone = cal.dtz.defaultTimezone; + let futureDate = cal.createDateTime("20380101T000000"); + futureDate.timezone = cal.dtz.defaultTimezone; + let useFutureDate = false; + let oldNowFunction = cal.dtz.now; + cal.dtz.now = function () { + return (useFutureDate ? futureDate : referenceDate).clone(); + }; + + // Deleting an item should trigger it being marked for deletion. + await check_delmgr_call(() => memory.deleteItem(item)); + + // Now check if it was deleted at our reference date. + let deltime = delmgr.getDeletedDate(item.id); + notEqual(deltime, null); + equal(deltime.compare(referenceDate), 0); + + // The same with the calendar. + deltime = delmgr.getDeletedDate(item.id, memory.id); + notEqual(deltime, null); + equal(deltime.compare(referenceDate), 0); + + // Item should not be found in other calendars. + equal(delmgr.getDeletedDate(item.id, "random"), null); + + // Check if flushing works, we need to travel time for that. + useFutureDate = true; + await check_delmgr_call(() => delmgr.flush()); + equal(delmgr.getDeletedDate(item.id), null); + equal(delmgr.getDeletedDate(item.id, memory.id), null); + + // Start over with our past time. + useFutureDate = false; + + // Add, delete, add. Item should no longer be deleted. + await check_delmgr_call(() => memory.addItem(item)); + equal(delmgr.getDeletedDate(item.id), null); + await check_delmgr_call(() => memory.deleteItem(item)); + equal(delmgr.getDeletedDate(item.id).compare(referenceDate), 0); + await check_delmgr_call(() => memory.addItem(item)); + equal(delmgr.getDeletedDate(item.id), null); + + // Revert now function, in case more tests are written. + cal.dtz.now = oldNowFunction; +}); diff --git a/comm/calendar/test/unit/test_duration.js b/comm/calendar/test/unit/test_duration.js new file mode 100644 index 0000000000..cef7bbddf9 --- /dev/null +++ b/comm/calendar/test/unit/test_duration.js @@ -0,0 +1,10 @@ +/* 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/. */ + +function run_test() { + let a = cal.createDuration("PT1S"); + let b = cal.createDuration("PT3S"); + a.addDuration(b); + equal(a.icalString, "PT4S"); +} diff --git a/comm/calendar/test/unit/test_email_utils.js b/comm/calendar/test/unit/test_email_utils.js new file mode 100644 index 0000000000..a1a55e3f17 --- /dev/null +++ b/comm/calendar/test/unit/test_email_utils.js @@ -0,0 +1,265 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", +}); + +function run_test() { + test_prependMailTo(); + test_removeMailTo(); + test_getAttendeeEmail(); + test_createRecipientList(); + test_validateRecipientList(); + test_attendeeMatchesAddresses(); +} + +function test_prependMailTo() { + let data = [ + { input: "mailto:first.last@example.net", expected: "mailto:first.last@example.net" }, + { input: "MAILTO:first.last@example.net", expected: "mailto:first.last@example.net" }, + { input: "first.last@example.net", expected: "mailto:first.last@example.net" }, + { input: "first.last.example.net", expected: "first.last.example.net" }, + ]; + for (let [i, test] of Object.entries(data)) { + equal(cal.email.prependMailTo(test.input), test.expected, "(test #" + i + ")"); + } +} + +function test_removeMailTo() { + let data = [ + { input: "mailto:first.last@example.net", expected: "first.last@example.net" }, + { input: "MAILTO:first.last@example.net", expected: "first.last@example.net" }, + { input: "first.last@example.net", expected: "first.last@example.net" }, + { input: "first.last.example.net", expected: "first.last.example.net" }, + ]; + for (let [i, test] of Object.entries(data)) { + equal(cal.email.removeMailTo(test.input), test.expected, "(test #" + i + ")"); + } +} + +function test_getAttendeeEmail() { + let data = [ + { + input: { + id: "mailto:first.last@example.net", + cname: "Last, First", + email: null, + useCn: true, + }, + expected: '"Last, First" <first.last@example.net>', + }, + { + input: { + id: "mailto:first.last@example.net", + cname: "Last; First", + email: null, + useCn: true, + }, + expected: '"Last; First" <first.last@example.net>', + }, + { + input: { id: "mailto:first.last@example.net", cname: "First Last", email: null, useCn: true }, + expected: "First Last <first.last@example.net>", + }, + { + input: { + id: "mailto:first.last@example.net", + cname: "Last, First", + email: null, + useCn: false, + }, + expected: "first.last@example.net", + }, + { + input: { id: "mailto:first.last@example.net", cname: null, email: null, useCn: true }, + expected: "first.last@example.net", + }, + { + input: { + id: "urn:uuid:first.last.example.net", + cname: null, + email: "first.last@example.net", + useCn: false, + }, + expected: "first.last@example.net", + }, + { + input: { + id: "urn:uuid:first.last.example.net", + cname: null, + email: "first.last@example.net", + useCn: true, + }, + expected: "first.last@example.net", + }, + { + input: { + id: "urn:uuid:first.last.example.net", + cname: "First Last", + email: "first.last@example.net", + useCn: true, + }, + expected: "First Last <first.last@example.net>", + }, + { + input: { id: "urn:uuid:first.last.example.net", cname: null, email: null, useCn: false }, + expected: "", + }, + ]; + for (let [i, test] of Object.entries(data)) { + let attendee = new CalAttendee(); + attendee.id = test.input.id; + if (test.input.cname) { + attendee.commonName = test.input.cname; + } + if (test.input.email) { + attendee.setProperty("EMAIL", test.input.email); + } + equal( + cal.email.getAttendeeEmail(attendee, test.input.useCn), + test.expected, + "(test #" + i + ")" + ); + } +} + +function test_createRecipientList() { + let data = [ + { + input: [ + { id: "mailto:first@example.net", cname: null }, + { id: "mailto:second@example.net", cname: null }, + { id: "mailto:third@example.net", cname: null }, + ], + expected: "first@example.net, second@example.net, third@example.net", + }, + { + input: [ + { id: "mailto:first@example.net", cname: "first example" }, + { id: "mailto:second@example.net", cname: "second example" }, + { id: "mailto:third@example.net", cname: "third example" }, + ], + expected: + "first example <first@example.net>, second example <second@example.net>, " + + "third example <third@example.net>", + }, + { + input: [ + { id: "mailto:first@example.net", cname: "example, first" }, + { id: "mailto:second@example.net", cname: "example, second" }, + { id: "mailto:third@example.net", cname: "example, third" }, + ], + expected: + '"example, first" <first@example.net>, "example, second" <second@example.net>, ' + + '"example, third" <third@example.net>', + }, + { + input: [ + { id: "mailto:first@example.net", cname: null }, + { id: "urn:uuid:second.example.net", cname: null }, + { id: "mailto:third@example.net", cname: null }, + ], + expected: "first@example.net, third@example.net", + }, + { + input: [ + { id: "mailto:first@example.net", cname: "first" }, + { id: "urn:uuid:second.example.net", cname: "second" }, + { id: "mailto:third@example.net", cname: "third" }, + ], + expected: "first <first@example.net>, third <third@example.net>", + }, + ]; + + let i = 0; + for (let test of data) { + i++; + let attendees = []; + for (let att of test.input) { + let attendee = new CalAttendee(); + attendee.id = att.id; + if (att.cname) { + attendee.commonName = att.cname; + } + attendees.push(attendee); + } + equal(cal.email.createRecipientList(attendees), test.expected, "(test #" + i + ")"); + } +} + +function test_validateRecipientList() { + let data = [ + { + input: "first.last@example.net", + expected: "first.last@example.net", + }, + { + input: "first last <first.last@example.net>", + expected: "first last <first.last@example.net>", + }, + { + input: '"last, first" <first.last@example.net>', + expected: '"last, first" <first.last@example.net>', + }, + { + input: "last, first <first.last@example.net>", + expected: '"last, first" <first.last@example.net>', + }, + { + input: '"last; first" <first.last@example.net>', + expected: '"last; first" <first.last@example.net>', + }, + { + input: "first1.last1@example.net,first2.last2@example.net,first3.last2@example.net", + expected: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net", + }, + { + input: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net", + expected: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net", + }, + { + input: + 'first1.last1@example.net, first2 last2 <first2.last2@example.net>, "last3, first' + + '3" <first3.last2@example.net>', + expected: + 'first1.last1@example.net, first2 last2 <first2.last2@example.net>, "last3, fi' + + 'rst3" <first3.last2@example.net>', + }, + { + input: + 'first1.last1@example.net, last2; first2 <first2.last2@example.net>, "last3; first' + + '3" <first3.last2@example.net>', + expected: + 'first1.last1@example.net, "last2; first2" <first2.last2@example.net>, "last' + + '3; first3" <first3.last2@example.net>', + }, + { + input: + "first1 last2 <first1.last1@example.net>, last2, first2 <first2.last2@example.net>" + + ', "last3, first3" <first3.last2@example.net>', + expected: + 'first1 last2 <first1.last1@example.net>, "last2, first2" <first2.last2@examp' + + 'le.net>, "last3, first3" <first3.last2@example.net>', + }, + ]; + + for (let [i, test] of Object.entries(data)) { + equal(cal.email.validateRecipientList(test.input), test.expected, "(test #" + i + ")"); + } +} + +function test_attendeeMatchesAddresses() { + let a = new CalAttendee("ATTENDEE:mailto:horst"); + ok(cal.email.attendeeMatchesAddresses(a, ["HORST", "peter"])); + ok(!cal.email.attendeeMatchesAddresses(a, ["HORSTpeter", "peter"])); + ok(!cal.email.attendeeMatchesAddresses(a, ["peter"])); + + a = new CalAttendee('ATTENDEE;EMAIL="horst":urn:uuid:horst'); + ok(cal.email.attendeeMatchesAddresses(a, ["HORST", "peter"])); + ok(!cal.email.attendeeMatchesAddresses(a, ["HORSTpeter", "peter"])); + ok(!cal.email.attendeeMatchesAddresses(a, ["peter"])); +} diff --git a/comm/calendar/test/unit/test_extract.js b/comm/calendar/test/unit/test_extract.js new file mode 100644 index 0000000000..96b1729f5c --- /dev/null +++ b/comm/calendar/test/unit/test_extract.js @@ -0,0 +1,225 @@ +/* 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/. */ + +// This test works with code that is not timezone-aware. +/* eslint-disable no-restricted-syntax */ + +var { Extractor } = ChromeUtils.import("resource:///modules/calendar/calExtract.jsm"); + +var extractor = new Extractor("en-US", 8); + +function run_test() { + // Sanity check to make sure the base url is still right. If this fails, + // don't forget to also fix the url in base/content/calendar-extract.js. + ok(extractor.checkBundle("en-US")); + + test_event_start_end(); + test_event_start_duration(); + test_event_start_end_whitespace(); + test_event_without_date(); + test_event_next_year(); + test_task_due(); + test_overrides(); + test_event_start_dollar_sign(); +} + +function test_event_start_end() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Wednesday meetup"; + let content = "We'll meet at 2 pm and discuss until 3 pm."; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(); + let endGuess = extractor.guessEnd(guessed); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 3); + equal(guessed.hour, 14); + equal(guessed.minute, 0); + + equal(endGuess.year, 2012); + equal(endGuess.month, 10); + equal(endGuess.day, 3); + equal(endGuess.hour, 15); + equal(endGuess.minute, 0); +} + +function test_event_start_duration() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Wednesday meetup"; + let content = "We'll meet at 2 pm and discuss for 30 minutes."; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(); + let endGuess = extractor.guessEnd(guessed); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 3); + equal(guessed.hour, 14); + equal(guessed.minute, 0); + + equal(endGuess.year, 2012); + equal(endGuess.month, 10); + equal(endGuess.day, 3); + equal(endGuess.hour, 14); + equal(endGuess.minute, 30); +} + +function test_event_start_end_whitespace() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Wednesday meetup"; + let content = "We'll meet at2pm and discuss until\r\n3pm."; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(); + let endGuess = extractor.guessEnd(guessed); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 3); + equal(guessed.hour, 14); + equal(guessed.minute, 0); + + equal(endGuess.year, 2012); + equal(endGuess.month, 10); + equal(endGuess.day, 3); + equal(endGuess.hour, 15); + equal(endGuess.minute, 0); +} + +function test_event_without_date() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Meetup"; + let content = "We'll meet at 2 pm and discuss until 3 pm."; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(); + let endGuess = extractor.guessEnd(guessed); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 1); + equal(guessed.hour, 14); + equal(guessed.minute, 0); + + equal(endGuess.year, 2012); + equal(endGuess.month, 10); + equal(endGuess.day, 1); + equal(endGuess.hour, 15); + equal(endGuess.minute, 0); +} + +function test_event_next_year() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Open day"; + let content = "FYI: Next open day is planned for February 5th."; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(); + let endGuess = extractor.guessEnd(guessed); + + equal(guessed.year, 2013); + equal(guessed.month, 2); + equal(guessed.day, 5); + equal(guessed.hour, undefined); + equal(guessed.minute, undefined); + + equal(endGuess.year, undefined); + equal(endGuess.month, undefined); + equal(endGuess.day, undefined); + equal(endGuess.hour, undefined); + equal(endGuess.minute, undefined); +} + +function test_task_due() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Assignment deadline"; + let content = "This is a reminder that all assignments must be sent in by October 5th!."; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(true); + let endGuess = extractor.guessEnd(guessed, true); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 1); + equal(guessed.hour, 9); + equal(guessed.minute, 0); + + equal(endGuess.year, 2012); + equal(endGuess.month, 10); + equal(endGuess.day, 5); + equal(endGuess.hour, 0); + equal(endGuess.minute, 0); +} + +function test_overrides() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Event invitation"; + let content = "We'll meet 10:11 worromot"; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(false); + let endGuess = extractor.guessEnd(guessed, true); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 1); + equal(guessed.hour, 10); + equal(guessed.minute, 11); + + equal(endGuess.year, undefined); + equal(endGuess.month, undefined); + equal(endGuess.day, undefined); + equal(endGuess.hour, undefined); + equal(endGuess.minute, undefined); + + // recognize a custom "tomorrow" and hour.minutes pattern + let overrides = { + "from.hour.minutes": { add: "#2:#1", remove: "#1:#2" }, + "from.tomorrow": { add: "worromot" }, + }; + + Services.prefs.setStringPref("calendar.patterns.override", JSON.stringify(overrides)); + + extractor.extract(title, content, date, undefined); + guessed = extractor.guessStart(false); + endGuess = extractor.guessEnd(guessed, true); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 2); + equal(guessed.hour, 11); + equal(guessed.minute, 10); + + equal(endGuess.year, undefined); + equal(endGuess.month, undefined); + equal(endGuess.day, undefined); + equal(endGuess.hour, undefined); + equal(endGuess.minute, undefined); +} + +function test_event_start_dollar_sign() { + let date = new Date(2012, 9, 1, 9, 0); + let title = "Wednesday sale"; + let content = "Sale starts at 3 pm and prices start at 2$."; + + extractor.extract(title, content, date, undefined); + let guessed = extractor.guessStart(); + let endGuess = extractor.guessEnd(guessed); + + equal(guessed.year, 2012); + equal(guessed.month, 10); + equal(guessed.day, 3); + equal(guessed.hour, 15); + equal(guessed.minute, 0); + + equal(endGuess.year, undefined); + equal(endGuess.month, undefined); + equal(endGuess.day, undefined); + equal(endGuess.hour, undefined); + equal(endGuess.minute, undefined); +} diff --git a/comm/calendar/test/unit/test_extract_parser.js b/comm/calendar/test/unit/test_extract_parser.js new file mode 100644 index 0000000000..d1b0f48b80 --- /dev/null +++ b/comm/calendar/test/unit/test_extract_parser.js @@ -0,0 +1,160 @@ +/* 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/. */ + +/** + * Tests for the CalExtractParser module. + */ +var { CalExtractParseNode, extendParseRule, prepareArguments } = ChromeUtils.import( + "resource:///modules/calendar/extract/CalExtractParser.jsm" +); + +/** + * Tests to ensure extendParseRule() expands parse rules as we desire. + */ +add_task(function testExtendParseRule() { + let action = () => {}; + + let tests = [ + { + name: "parse rules are expanded correctly", + input: { + name: "text", + patterns: ["TEXT"], + action, + }, + expected: { + name: "text", + patterns: ["TEXT"], + action, + flags: [0], + graph: { + symbol: null, + flags: null, + descendants: [ + { + symbol: "TEXT", + flags: 0, + descendants: [], + }, + ], + }, + }, + }, + { + name: "flags are detected correctly", + input: { + name: "text", + patterns: ["CHAR+", "TEXT?", "characters*"], + action, + }, + expected: { + name: "text", + action, + patterns: ["CHAR", "TEXT", "characters"], + flags: [ + CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE, + CalExtractParseNode.FLAG_OPTIONAL, + CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE, + ], + graph: { + symbol: null, + flags: null, + descendants: [ + { + symbol: "CHAR", + flags: CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE, + descendants: [ + { + symbol: "CHAR", + }, + { + symbol: "TEXT", + flags: CalExtractParseNode.FLAG_OPTIONAL, + descendants: [ + { + symbol: "characters", + flags: CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE, + descendants: [ + { + symbol: "characters", + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + }, + ]; + + for (let test of tests) { + info(`Test extendParseRule(): ${test.name}`); + compareExtractResults(extendParseRule(test.input), test.expected); + } +}); + +/** + * Tests prepareArguments() gives the correct arguments. + */ +add_task(function testReconcileArguments() { + let tests = [ + { + name: "patterns without no flags bits are untouched", + rule: { + name: "text", + patterns: ["CHAR", "TEXT", "characters"], + flags: [0, 0, 0], + }, + matched: [ + ["CHAR", "is char"], + ["TEXT", "is text"], + ["characters", "is characters"], + ], + expected: ["is char", "is text", "is characters"], + }, + { + name: "multi patterns are turned into arrays", + rule: { + name: "text", + patterns: ["CHAR", "TEXT", "characters"], + flags: [ + CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE, + CalExtractParseNode.FLAG_OPTIONAL, + CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE, + ], + }, + matched: [ + ["CHAR", "is char"], + ["TEXT", "is text"], + ["characters", "is characters"], + ], + expected: [["is char"], "is text", ["is characters"]], + }, + { + name: "unmatched optional patterns are null", + rule: { + name: "text", + patterns: ["CHAR", "TEXT", "characters"], + flags: [ + CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE, + CalExtractParseNode.FLAG_OPTIONAL, + CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE, + ], + }, + matched: [ + ["CHAR", "is char"], + ["characters", "is characters"], + ], + expected: [["is char"], null, ["is characters"]], + }, + ]; + + for (let test of tests) { + info(`Test prepareArguments(): ${test.name}`); + compareExtractResults(prepareArguments(test.rule, test.matched), test.expected); + } +}); diff --git a/comm/calendar/test/unit/test_extract_parser_parse.js b/comm/calendar/test/unit/test_extract_parser_parse.js new file mode 100644 index 0000000000..ac029c4303 --- /dev/null +++ b/comm/calendar/test/unit/test_extract_parser_parse.js @@ -0,0 +1,1317 @@ +/* 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/. */ + +/** + * Tests for the CalExtractParser module. + */ +var { CalExtractParser } = ChromeUtils.import( + "resource:///modules/calendar/extract/CalExtractParser.jsm" +); + +/** + * Tests parsing an empty string produces an empty lit. + */ +add_task(function testParseEmptyString() { + let parser = new CalExtractParser(); + let result = parser.parse(""); + Assert.equal(result.length, 0, "parsing empty string produces empty list"); +}); + +/** + * Tests parsing with various non-flag rules works as expected. + */ +add_task(function testParseText() { + let parser = CalExtractParser.createInstance( + [ + [/^your/i, "YOUR"], + [/^(appointment|meeting|booking)/i, "EVENT"], + [/^(at|@)/i, "AT"], + [/^on/i, "ON"], + [/^\d\d-\d\d-\d\d\d\d/, "DATE"], + [/^\d\d\d\d-\d\d-\d\d/, "ISODATE"], + [/^(was|is|has been| will be)/i, "BE"], + [/^(confirmed|booked|saved|created)/i, "CONFIRM"], + [/^[A-Z][A-Za-z0-9]+/, "NOUN"], + [/^,/], + [/^\S+/, "TEXT"], + [/^\s+/], + ], + [ + { + name: "event", + patterns: ["text", "yourevent", "location", "BE", "CONFIRM"], + action: ([, title, location]) => ({ + type: "event", + title, + location, + }), + }, + { + name: "event", + patterns: ["yourevent", "location", "BE", "CONFIRM"], + action: ([title, location]) => ({ + type: "event", + title, + location, + }), + }, + { + name: "event", + patterns: ["yourevent", "ON", "date", "BE", "CONFIRM"], + action: ([title, , date]) => ({ + type: "event", + title, + date, + }), + }, + { + name: "event", + patterns: ["yourthing", "ON", "date", "BE", "CONFIRM"], + action: ([title, , date]) => ({ + type: "event", + title, + date, + }), + }, + { + name: "date", + patterns: ["DATE"], + action: ([value]) => ({ + type: "date", + value, + }), + }, + { + name: "date", + patterns: ["ISODATE"], + action: ([value]) => ({ + type: "date", + value, + }), + }, + { + name: "yourevent", + patterns: ["yourthing", "EVENT"], + action: ([value]) => value, + }, + { + name: "yourthing", + patterns: ["YOUR", "text"], + action: ([, value]) => value, + }, + { + name: "location", + patterns: ["AT", "text"], + action: ([, value]) => ({ + type: "location", + value, + }), + }, + { + name: "text", + patterns: ["TEXT"], + action: ([value]) => value, + }, + { + name: "text", + patterns: ["NOUN"], + action: ([value]) => value, + }, + ] + ); + + let tests = [ + { + input: "Hello, your banking appointment at RealBank is booked!", + expected: [ + { + type: "event", + title: { + type: "TEXT", + text: "banking", + sentence: 0, + position: 12, + }, + location: { + type: "location", + value: { + type: "NOUN", + text: "RealBank", + sentence: 0, + position: 35, + }, + }, + }, + ], + }, + { + input: "your banking appointment at RealBank is booked!", + expected: [ + { + type: "event", + title: { + type: "TEXT", + text: "banking", + sentence: 0, + position: 5, + }, + location: { + type: "location", + value: { + type: "NOUN", + text: "RealBank", + sentence: 0, + position: 28, + }, + }, + }, + ], + }, + { + input: "Your Arraignment on 09-09-2021 is confirmed!", + expected: [ + { + type: "event", + title: { + type: "NOUN", + text: "Arraignment", + sentence: 0, + position: 5, + }, + date: { + type: "date", + value: { + type: "DATE", + text: "09-09-2021", + sentence: 0, + position: 20, + }, + }, + }, + ], + }, + ]; + + for (let test of tests) { + info(`Parsing string "${test.input}"...`); + let result = parser.parse(test.input); + Assert.equal( + result.length, + test.expected.length, + `parsing "${test.input}" resulted in ${test.expected.length} sentences` + ); + info(`Comparing parse results for string "${test.input}"...`); + compareExtractResults(result, test.expected, "result"); + } +}); + +/** + * Tests parsing unknown text produces a null result for the sentence. + */ +add_task(function testParseUnknownText() { + let parser = CalExtractParser.createInstance( + [ + [/^No/, "NO"], + [/^rules/, "RULES"], + [/^for/, "FOR"], + [/^this/, "THIS"], + [/^Or/, "OR"], + [/^even/, "EVEN"], + [/^\s+/, "SPACE"], + ], + [ + { + name: "statement", + patterns: ["NO", "SPACE", "RULES", "SPACE", "FOR", "SPACE", "THIS"], + action: () => "statement", + }, + { + name: "statement", + patterns: ["OR", "SPACE", "THIS"], + action: () => "statement", + }, + ] + ); + + let result = parser.parse("No rules for this. Or this. Or even this!"); + Assert.equal(result.length, 3, "result has 3 sentences"); + Assert.equal(result[0], "statement", "first sentence parsed properly"); + Assert.equal(result[1], "statement", "second sentence parsed properly"); + Assert.equal(result[2], null, "third sentence was not parsed properly"); +}); + +/** + * Tests parsing without any parse rules produces a null result for each + * sentence. + */ +add_task(function testParseWithoutParseRules() { + let parser = CalExtractParser.createInstance( + [ + [/^[A-Za-z]+/, "TEXT"], + [/^\s+/, "SPACE"], + ], + [] + ); + let result = parser.parse("No rules for this. Or this. Or event this!"); + Assert.equal(result.length, 3, "result has 3 parsed sentences"); + Assert.ok( + result.every(val => val == null), + "all parsed results are null" + ); +}); + +/** + * Tests parsing using the "+" flag in various scenarios. + */ +add_task(function testParseWithPlusFlags() { + let parser = CalExtractParser.createInstance( + [ + [/^we\b/i, "WE"], + [/^meet\b/i, "MEET"], + [/^at\b/i, "AT"], + [/^\d/, "NUMBER"], + [/^\S+/, "TEXT"], + [/^\s+/], + ], + [ + { + name: "result", + patterns: ["subject", "text+", "meet", "time"], + action: ([subject, text, , time]) => ({ + type: "result0", + subject, + text, + time, + }), + }, + { + name: "result", + patterns: ["meet", "time", "text+"], + action: ([, time, text]) => ({ + type: "result1", + time, + text, + }), + }, + { + name: "result", + patterns: ["text+", "meet", "time"], + action: ([text, , time]) => ({ + type: "result2", + time, + text, + }), + }, + { + name: "subject", + patterns: ["WE"], + action: ([subject]) => ({ + type: "subject", + subject, + }), + }, + { + name: "meet", + patterns: ["MEET", "AT"], + action: ([meet, at]) => ({ + type: "meet", + meet, + at, + }), + }, + { + name: "time", + patterns: ["NUMBER"], + action: ([value]) => ({ + type: "time", + value, + }), + }, + { + name: "text", + patterns: ["TEXT"], + action: ([value]) => value, + }, + ] + ); + + let tests = [ + { + name: "using '+' flag can capture one pattern", + input: "We will meet at 7", + expected: [ + { + type: "result0", + subject: { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + text: [ + { + type: "TEXT", + text: "will", + sentence: 0, + position: 3, + }, + ], + time: { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 16, + }, + }, + }, + ], + }, + { + name: "using the '+' flag can capture multiple patterns", + input: "We are coming to meet at 7", + expected: [ + { + type: "result0", + subject: { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + text: [ + { + type: "TEXT", + text: "are", + sentence: 0, + position: 3, + }, + { + type: "TEXT", + text: "coming", + sentence: 0, + position: 7, + }, + { + type: "TEXT", + text: "to", + sentence: 0, + position: 14, + }, + ], + time: { + type: "time", + value: { type: "NUMBER", text: "7", sentence: 0, position: 25 }, + }, + }, + ], + }, + { + name: "using '+' fails if its pattern is unmatched", + input: "We meet at 7", + expected: [null], + }, + { + name: "'+' can be used in the first position", + input: "Well do not meet at 7", + expected: [ + { + type: "result2", + time: { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 20, + }, + }, + text: [ + { + type: "TEXT", + text: "Well", + sentence: 0, + position: 0, + }, + { + type: "TEXT", + text: "do", + sentence: 0, + position: 5, + }, + { + type: "TEXT", + text: "not", + sentence: 0, + position: 8, + }, + ], + }, + ], + }, + { + name: "'+' can be used in the last position", + input: "Meet at 7 is the plan", + expected: [ + { + type: "result1", + time: { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 8, + }, + }, + text: [ + { + type: "TEXT", + text: "is", + sentence: 0, + position: 10, + }, + { + type: "TEXT", + text: "the", + sentence: 0, + position: 13, + }, + { + type: "TEXT", + text: "plan", + sentence: 0, + position: 17, + }, + ], + }, + ], + }, + ]; + + for (let test of tests) { + info(`Running test: ${test.name}`); + let result = parser.parse(test.input); + Assert.equal( + result.length, + test.expected.length, + `parsing "${test.input}" resulted in ${test.expected.length} sentences` + ); + info(`Comparing parse results for string "${test.input}"...`); + compareExtractResults(result, test.expected, "result"); + } +}); + +/** + * Tests parsing using the "*" flag in various scenarios. + */ +add_task(function testParseWithStarFlags() { + let parser = CalExtractParser.createInstance( + [ + [/^we\b/i, "WE"], + [/^meet\b/i, "MEET"], + [/^at\b/i, "AT"], + [/^\d/, "NUMBER"], + [/^\S+/, "TEXT"], + [/^\s+/], + ], + [ + { + name: "result", + patterns: ["subject", "text*", "meet", "time"], + action: ([subject, text, , time]) => ({ + type: "result0", + subject, + text, + time, + }), + }, + { + name: "result", + patterns: ["meet", "time", "text*"], + action: ([, time, text]) => ({ + type: "result1", + time, + text, + }), + }, + { + name: "result", + patterns: ["text*", "subject", "text", "meet", "time"], + action: ([text, subject, , time]) => ({ + type: "result2", + text, + subject, + time, + }), + }, + { + name: "subject", + patterns: ["WE"], + action: ([subject]) => ({ + type: "subject", + subject, + }), + }, + { + name: "meet", + patterns: ["MEET", "AT"], + action: ([meet, at]) => ({ + type: "meet", + meet, + at, + }), + }, + { + name: "time", + patterns: ["NUMBER"], + action: ([value]) => ({ + type: "time", + value, + }), + }, + { + name: "text", + patterns: ["TEXT"], + action: ([value]) => value, + }, + ] + ); + + let tests = [ + { + name: "using '*' flag can capture one pattern", + input: "We will meet at 7", + expected: [ + { + type: "result0", + subject: { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + text: [ + { + type: "TEXT", + text: "will", + sentence: 0, + position: 3, + }, + ], + time: { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 16, + }, + }, + }, + ], + }, + { + name: "using the '*' flag can capture multiple patterns", + input: "We are coming to meet at 7", + expected: [ + { + type: "result0", + subject: { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + text: [ + { + type: "TEXT", + text: "are", + sentence: 0, + position: 3, + }, + { + type: "TEXT", + text: "coming", + sentence: 0, + position: 7, + }, + { + type: "TEXT", + text: "to", + sentence: 0, + position: 14, + }, + ], + time: { + type: "time", + value: { type: "NUMBER", text: "7", sentence: 0, position: 25 }, + }, + }, + ], + }, + { + name: "'*' capture is optional", + input: "We meet at 7", + expected: [ + { + type: "result0", + subject: { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + text: [], + time: { + type: "time", + value: { type: "NUMBER", text: "7", sentence: 0, position: 11 }, + }, + }, + ], + }, + { + name: "'*' can be used in the first position", + input: "To think we will meet at 7", + expected: [ + { + type: "result2", + text: [ + { + type: "TEXT", + text: "To", + sentence: 0, + position: 0, + }, + { + type: "TEXT", + text: "think", + sentence: 0, + position: 3, + }, + ], + subject: { + type: "subject", + subject: { + type: "WE", + text: "we", + sentence: 0, + position: 9, + }, + }, + time: { + type: "meet", + meet: { + type: "MEET", + text: "meet", + sentence: 0, + position: 17, + }, + at: { + type: "AT", + text: "at", + sentence: 0, + position: 22, + }, + }, + }, + ], + }, + { + name: "'*' can be used in the last position", + input: "Meet at 7 is the plan", + expected: [ + { + type: "result1", + time: { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 8, + }, + }, + text: [ + { + type: "TEXT", + text: "is", + sentence: 0, + position: 10, + }, + { + type: "TEXT", + text: "the", + sentence: 0, + position: 13, + }, + { + type: "TEXT", + text: "plan", + sentence: 0, + position: 17, + }, + ], + }, + ], + }, + ]; + + for (let test of tests) { + info(`Running test: ${test.name}`); + let result = parser.parse(test.input); + Assert.equal( + result.length, + test.expected.length, + `parsing "${test.input}" resulted in ${test.expected.length} sentences` + ); + info(`Comparing parse results for string "${test.input}"...`); + compareExtractResults(result, test.expected, "result"); + } +}); + +/** + * Tests parsing using the "?" flag in various scenarios. + */ +add_task(function testParseWithOptionalFlags() { + let parser = CalExtractParser.createInstance( + [ + [/^we\b/i, "WE"], + [/^meet\b/i, "MEET"], + [/^at\b/i, "AT"], + [/^\d/, "NUMBER"], + [/^\S+/, "TEXT"], + [/^\s+/], + ], + [ + { + name: "result", + patterns: ["subject", "text?", "meet", "time"], + action: ([subject, text, , time]) => ({ + type: "result0", + subject, + text, + time, + }), + }, + { + name: "result", + patterns: ["meet", "time", "text?"], + action: ([, time, text]) => ({ + type: "result1", + time, + text, + }), + }, + { + name: "result", + patterns: ["text?", "subject", "text", "meet", "time"], + action: ([text, subject, , time]) => ({ + type: "result2", + text, + subject, + time, + }), + }, + { + name: "subject", + patterns: ["WE"], + action: ([subject]) => ({ + type: "subject", + subject, + }), + }, + { + name: "meet", + patterns: ["MEET", "AT"], + action: ([meet, at]) => ({ + type: "meet", + meet, + at, + }), + }, + { + name: "time", + patterns: ["NUMBER"], + action: ([value]) => ({ + type: "time", + value, + }), + }, + { + name: "text", + patterns: ["TEXT"], + action: ([value]) => value, + }, + ] + ); + + let tests = [ + { + name: "using '?' flag can capture one pattern", + input: "We will meet at 7", + expected: [ + { + type: "result0", + subject: { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + text: { + type: "TEXT", + text: "will", + sentence: 0, + position: 3, + }, + + time: { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 16, + }, + }, + }, + ], + }, + { + name: "'?' capture is optional", + input: "We meet at 7", + expected: [ + { + type: "result0", + subject: { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + text: null, + time: { + type: "time", + value: { type: "NUMBER", text: "7", sentence: 0, position: 11 }, + }, + }, + ], + }, + { + name: "'?' can be used in the first position", + input: "Think we will meet at 7", + expected: [ + { + type: "result2", + text: { + type: "TEXT", + text: "Think", + sentence: 0, + position: 0, + }, + subject: { + type: "subject", + subject: { + type: "WE", + text: "we", + sentence: 0, + position: 6, + }, + }, + time: { + type: "meet", + meet: { + type: "MEET", + text: "meet", + sentence: 0, + position: 14, + }, + at: { + type: "AT", + text: "at", + sentence: 0, + position: 19, + }, + }, + }, + ], + }, + { + name: "'?' can be used in the last position", + input: "Meet at 7 please", + expected: [ + { + type: "result1", + time: { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 8, + }, + }, + text: { + type: "TEXT", + text: "please", + sentence: 0, + position: 10, + }, + }, + ], + }, + ]; + + for (let test of tests) { + info(`Running test: ${test.name}`); + let result = parser.parse(test.input); + Assert.equal( + result.length, + test.expected.length, + `parsing "${test.input}" resulted in ${test.expected.length} sentences` + ); + info(`Comparing parse results for string "${test.input}"...`); + compareExtractResults(result, test.expected, "result"); + } +}); + +/** + * Test the flags can be used together in the same rules. + */ +add_task(function testParseWithFlags() { + let tokens = [ + [/^we\b/i, "WE"], + [/^meet\b/i, "MEET"], + [/^at\b/i, "AT"], + [/^\d/, "NUMBER"], + [/^\S+/, "TEXT"], + [/^\s+/], + ]; + + let patterns = [ + { + name: "subject", + patterns: ["WE"], + action: ([subject]) => ({ + type: "subject", + subject, + }), + }, + { + name: "meet", + patterns: ["MEET", "AT"], + action: ([meet, at]) => ({ + type: "meet", + meet, + at, + }), + }, + { + name: "time", + patterns: ["NUMBER"], + action: ([value]) => ({ + type: "time", + value, + }), + }, + { + name: "text", + patterns: ["TEXT"], + action: ([value]) => value, + }, + ]; + + let tests = [ + { + patterns: ["subject?", "text*", "time+"], + variants: [ + { + input: "We will 7", + expected: [ + [ + { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + [ + { + type: "TEXT", + text: "will", + sentence: 0, + position: 3, + }, + ], + [ + { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 8, + }, + }, + ], + ], + ], + }, + { + input: "7", + expected: [ + [ + null, + [], + [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 0 } }], + ], + ], + }, + { + input: "we", + expected: [null], + }, + { + input: "will 7", + expected: [ + [ + null, + [{ type: "TEXT", text: "will", sentence: 0, position: 0 }], + [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 5 } }], + ], + ], + }, + { + input: "we 7", + expected: [ + [ + { type: "subject", subject: { type: "WE", text: "we", sentence: 0, position: 0 } }, + [], + [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 3 } }], + ], + ], + }, + ], + }, + { + patterns: ["subject+", "text?", "time*"], + variants: [ + { + input: "We will 7", + expected: [ + [ + [ + { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + ], + { + type: "TEXT", + text: "will", + sentence: 0, + position: 3, + }, + + [ + { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 8, + }, + }, + ], + ], + ], + }, + { + input: "7", + expected: [null], + }, + { + input: "will 7", + expected: [null], + }, + { + input: "we 7", + expected: [ + [ + [ + { + type: "subject", + subject: { + type: "WE", + text: "we", + sentence: 0, + position: 0, + }, + }, + ], + null, + [ + { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 3, + }, + }, + ], + ], + ], + }, + ], + }, + { + patterns: ["subject*", "text+", "time?"], + variants: [ + { + input: "We will 7", + expected: [ + [ + [ + { + type: "subject", + subject: { + type: "WE", + text: "We", + sentence: 0, + position: 0, + }, + }, + ], + [ + { + type: "TEXT", + text: "will", + sentence: 0, + position: 3, + }, + ], + { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 8, + }, + }, + ], + ], + }, + { + input: "will", + expected: [[[], [{ type: "TEXT", text: "will", sentence: 0, position: 0 }], null]], + }, + { + input: "will 7", + expected: [ + [ + [], + [ + { + type: "TEXT", + text: "will", + sentence: 0, + position: 0, + }, + ], + { + type: "time", + value: { + type: "NUMBER", + text: "7", + sentence: 0, + position: 5, + }, + }, + ], + ], + }, + { + input: "we will", + expected: [ + [ + [ + { + type: "subject", + subject: { + type: "WE", + text: "we", + sentence: 0, + position: 0, + }, + }, + ], + [ + { + type: "TEXT", + text: "will", + sentence: 0, + position: 3, + }, + ], + null, + ], + ], + }, + ], + }, + ]; + + for (let test of tests) { + test = tests[2]; + let rule = { + name: "result", + patterns: test.patterns, + action: args => args, + }; + let parser = CalExtractParser.createInstance(tokens, [rule, ...patterns]); + + for (let input of test.variants) { + input = test.variants[3]; + info(`Testing pattern: ${test.patterns} with input "${input.input}".`); + let result = parser.parse(input.input); + info(`Comparing parse results for string "${input.input}"...`); + compareExtractResults(result, input.expected, "result"); + } + } +}); diff --git a/comm/calendar/test/unit/test_extract_parser_service.js b/comm/calendar/test/unit/test_extract_parser_service.js new file mode 100644 index 0000000000..2118fad16b --- /dev/null +++ b/comm/calendar/test/unit/test_extract_parser_service.js @@ -0,0 +1,96 @@ +/* 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/. */ + +/** + * Tests for the CalExtractParserService. These are modified versions of the + * text_extract.js tests, for now. + */ + +// This test works with code that is not timezone-aware. +/* eslint-disable no-restricted-syntax */ + +var { CalExtractParserService } = ChromeUtils.import( + "resource:///modules/calendar/extract/CalExtractParserService.jsm" +); + +let service = new CalExtractParserService(); + +/** + * Test the extraction of a start and end time using HOUR am/pm. Note: The + * service currently only selects event information from one sentence so the + * event title is not included here for now. + */ +add_task(function test_event_start_end() { + let now = new Date(2012, 9, 1, 9, 0); + let content = "We'll meet at 2 pm and discuss until 3 pm."; + let result = service.extract(content, { + now, + }); + + info(`Comparing extracted result for string "${content}"...`); + compareExtractResults( + result, + { + type: "event-guess", + startTime: { + type: "meridiem-time", + year: 2012, + month: 10, + day: 1, + hour: 14, + minute: 0, + meridiem: "pm", + }, + endTime: { + type: "meridiem-time", + year: 2012, + month: 10, + day: 1, + hour: 15, + minute: 0, + meridiem: "pm", + }, + priority: 0, + }, + "result" + ); +}); + +/** + * Test the extraction of a start and end time using a meridiem time for start + * and a duration for the end. + */ +add_task(function test_event_start_duration() { + let now = new Date(2012, 9, 1, 9, 0); + let content = "We'll meet at 2 pm and discuss for 30 minutes."; + let result = service.extract(content, { + now, + }); + info(`Comparing extracted result for string "${content}"...`); + compareExtractResults( + result, + { + type: "event-guess", + startTime: { + type: "meridiem-time", + year: 2012, + month: 10, + day: 1, + hour: 14, + minute: 0, + meridiem: "pm", + }, + endTime: { + type: "date-time", + year: 2012, + month: 10, + day: 1, + hour: 14, + minute: 30, + }, + priority: 0, + }, + "result" + ); +}); diff --git a/comm/calendar/test/unit/test_extract_parser_tokenize.js b/comm/calendar/test/unit/test_extract_parser_tokenize.js new file mode 100644 index 0000000000..a77c72bcfc --- /dev/null +++ b/comm/calendar/test/unit/test_extract_parser_tokenize.js @@ -0,0 +1,367 @@ +/* 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/. */ + +/** + * Tests for the CalExtractParser module. + */ +var { CalExtractParser } = ChromeUtils.import( + "resource:///modules/calendar/extract/CalExtractParser.jsm" +); + +/** + * Tests tokenizing an empty string gives an empty list. + */ +add_task(function testTokenizeEmptyString() { + let parser = new CalExtractParser(); + let result = parser.tokenize(""); + Assert.equal(result.length, 0, "tokenize empty string produces empty list"); +}); + +/** + * Tests tokenisation works as expected. + */ +add_task(function testTokenizeWithRules() { + let parser = new CalExtractParser( + [ + [/^(Monday|Tuesday|Wednesday)/, "DAY"], + [/^meet/, "MEET"], + [/^[A-Za-z]+/, "TEXT"], + [/^[0-9]+/, "NUMBER"], + [/^\s+/, "SPACE"], + [/^,/, "COMMA"], + ], + [] + ); + + let text = `Hello there, can we meet on Monday? If not, then Tuesday. We can + also meet on Wednesday at 6`; + + let expected = [ + [ + { + type: "TEXT", + text: "Hello", + sentence: 0, + position: 0, + }, + { + type: "SPACE", + text: " ", + sentence: 0, + position: 5, + }, + { + type: "TEXT", + text: "there", + sentence: 0, + position: 6, + }, + { + type: "COMMA", + text: ",", + sentence: 0, + position: 11, + }, + { + type: "SPACE", + text: " ", + sentence: 0, + position: 12, + }, + { + type: "TEXT", + text: "can", + sentence: 0, + position: 13, + }, + { + type: "SPACE", + text: " ", + sentence: 0, + position: 16, + }, + { + type: "TEXT", + text: "we", + sentence: 0, + position: 17, + }, + { + type: "SPACE", + text: " ", + sentence: 0, + position: 19, + }, + { + type: "MEET", + text: "meet", + sentence: 0, + position: 20, + }, + { + type: "SPACE", + text: " ", + sentence: 0, + position: 24, + }, + { + type: "TEXT", + text: "on", + sentence: 0, + position: 25, + }, + { + type: "SPACE", + text: " ", + sentence: 0, + position: 27, + }, + { + type: "DAY", + text: "Monday", + sentence: 0, + position: 28, + }, + ], + [ + { + type: "TEXT", + text: "If", + sentence: 1, + position: 0, + }, + { + type: "SPACE", + text: " ", + sentence: 1, + position: 2, + }, + { + type: "TEXT", + text: "not", + sentence: 1, + position: 3, + }, + { + type: "COMMA", + text: ",", + sentence: 1, + position: 6, + }, + { + type: "SPACE", + text: " ", + sentence: 1, + position: 7, + }, + { + type: "TEXT", + text: "then", + sentence: 1, + position: 8, + }, + { + type: "SPACE", + text: " ", + sentence: 1, + position: 12, + }, + { + type: "DAY", + text: "Tuesday", + sentence: 1, + position: 13, + }, + ], + [ + { + type: "TEXT", + text: "We", + sentence: 2, + position: 0, + }, + { + type: "SPACE", + text: " ", + sentence: 2, + position: 2, + }, + { + type: "TEXT", + text: "can", + sentence: 2, + position: 3, + }, + { + type: "SPACE", + text: "\n ", + sentence: 2, + position: 6, + }, + { + type: "TEXT", + text: "also", + sentence: 2, + position: 21, + }, + { + type: "SPACE", + text: " ", + sentence: 2, + position: 25, + }, + { + type: "MEET", + text: "meet", + sentence: 2, + position: 26, + }, + { + type: "SPACE", + text: " ", + sentence: 2, + position: 30, + }, + { + type: "TEXT", + text: "on", + sentence: 2, + position: 31, + }, + { + type: "SPACE", + text: " ", + sentence: 2, + position: 33, + }, + { + type: "DAY", + text: "Wednesday", + sentence: 2, + position: 34, + }, + { + type: "SPACE", + text: " ", + sentence: 2, + position: 43, + }, + { + type: "TEXT", + text: "at", + sentence: 2, + position: 44, + }, + { + type: "SPACE", + text: " ", + sentence: 2, + position: 46, + }, + { + type: "NUMBER", + text: "6", + sentence: 2, + position: 47, + }, + ], + ]; + + info(`Tokenizing string "${text}"...`); + let actual = parser.tokenize(text); + Assert.equal(actual.length, expected.length, `result has ${expected.length} sentences`); + info(`Comparing results of tokenizing "${text}"...`); + for (let i = 0; i < expected.length; i++) { + compareExtractResults(actual[i], expected[i], "result"); + } +}); + +/** + * Tests tokenizing unknown text produces null. + */ +add_task(function testTokenizeUnknownText() { + let parser = new CalExtractParser([], []); + let result = parser.tokenize("text with no rules"); + Assert.equal(result.length, 1, "tokenizing unknown text produced a result"); + Assert.equal(result[0], null, "tokenizing unknown text produced a null result"); +}); + +/** + * Tests omitting some token names omits them from the result. + */ +add_task(function testTokenRulesNamesOmitted() { + let parser = new CalExtractParser([ + [/^Monday/, "DAY"], + [/^meet/, "MEET"], + [/^[A-Za-z]+/, "TEXT"], + [/^[0-9]+/, "NUMBER"], + [/^\s+/], + [/^,/], + ]); + + let text = `Hello there, can we meet on Monday?`; + let expected = [ + [ + { + type: "TEXT", + text: "Hello", + sentence: 0, + position: 0, + }, + { + type: "TEXT", + text: "there", + sentence: 0, + position: 6, + }, + { + type: "TEXT", + text: "can", + sentence: 0, + position: 13, + }, + { + type: "TEXT", + text: "we", + sentence: 0, + position: 17, + }, + { + type: "MEET", + text: "meet", + sentence: 0, + position: 20, + }, + { + type: "TEXT", + text: "on", + sentence: 0, + position: 25, + }, + { + type: "DAY", + text: "Monday", + sentence: 0, + position: 28, + }, + ], + ]; + + info(`Tokenizing string "${text}"...`); + let actual = parser.tokenize(text); + Assert.equal(actual.length, expected.length, `result has ${expected.length} sentences`); + info(`Comparing results of tokenizing string "${text}"..`); + for (let i = 0; i < expected.length; i++) { + compareExtractResults(actual[i], expected[i], "result"); + } +}); + +/** + * Tests parsing an empty string produces an empty lit. + */ +add_task(function testParseEmptyString() { + let parser = new CalExtractParser(); + let result = parser.parse(""); + Assert.equal(result.length, 0, "parsing empty string produces empty list"); +}); diff --git a/comm/calendar/test/unit/test_filter.js b/comm/calendar/test/unit/test_filter.js new file mode 100644 index 0000000000..3b76585bc6 --- /dev/null +++ b/comm/calendar/test/unit/test_filter.js @@ -0,0 +1,406 @@ +/* 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 { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm"); + +/* globals calFilter, CalReadableStreamFactory */ +Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js"); + +async function promiseItems(filter, calendar) { + return cal.iterate.streamToArray(filter.getItems(calendar)); +} + +add_task(() => new Promise(resolve => do_calendar_startup(resolve))); + +add_task(async function testDateRangeFilter() { + let calendar = CalendarTestUtils.createCalendar("test"); + + let testItems = {}; + for (let [title, startDate, endDate] of [ + ["before", "20210720", "20210721"], + ["during", "20210820", "20210821"], + ["after", "20210920", "20210921"], + ["overlaps_start", "20210720", "20210804"], + ["overlaps_end", "20210820", "20210904"], + ["overlaps_both", "20210720", "20210904"], + ]) { + let event = new CalEvent(); + event.id = cal.getUUID(); + event.title = title; + event.startDate = cal.createDateTime(startDate); + event.endDate = cal.createDateTime(endDate); + await calendar.addItem(event); + testItems[title] = event; + } + + // Create a new filter. + + let filter = new calFilter(); + filter.startDate = cal.createDateTime("20210801"); + filter.endDate = cal.createDateTime("20210831"); + + // Test dateRangeFilter. + + Assert.ok(!filter.dateRangeFilter(testItems.before), "task doesn't pass date range filter"); + Assert.ok(filter.dateRangeFilter(testItems.during), "task passes date range filter"); + Assert.ok(!filter.dateRangeFilter(testItems.after), "task doesn't pass date range filter"); + Assert.ok(filter.dateRangeFilter(testItems.overlaps_start), "task passes date range filter"); + Assert.ok(filter.dateRangeFilter(testItems.overlaps_end), "task passes date range filter"); + Assert.ok(filter.dateRangeFilter(testItems.overlaps_both), "task passes date range filter"); + + // Test isItemInFilters. + + Assert.ok(!filter.isItemInFilters(testItems.before), "task doesn't pass all filters"); + Assert.ok(filter.isItemInFilters(testItems.during), "task passes all filters"); + Assert.ok(!filter.isItemInFilters(testItems.after), "task doesn't pass all filters"); + Assert.ok(filter.isItemInFilters(testItems.overlaps_start), "task passes all filters"); + Assert.ok(filter.isItemInFilters(testItems.overlaps_end), "task passes all filters"); + Assert.ok(filter.isItemInFilters(testItems.overlaps_both), "task passes all filters"); + + // Test getItems. + + let items = await promiseItems(filter, calendar); + Assert.equal(items.length, 4, "getItems returns expected number of items"); + Assert.equal(items[0].title, "during", "correct item returned"); + Assert.equal(items[1].title, "overlaps_start", "correct item returned"); + Assert.equal(items[2].title, "overlaps_end", "correct item returned"); + Assert.equal(items[3].title, "overlaps_both", "correct item returned"); + + // Change the date of the filter and test it all again. + + filter.startDate = cal.createDateTime("20210825"); + filter.endDate = cal.createDateTime("20210905"); + + // Test dateRangeFilter. + + Assert.ok(!filter.dateRangeFilter(testItems.before), "task doesn't pass date range filter"); + Assert.ok(!filter.dateRangeFilter(testItems.during), "task passes date range filter"); + Assert.ok(!filter.dateRangeFilter(testItems.after), "task doesn't pass date range filter"); + Assert.ok(!filter.dateRangeFilter(testItems.overlaps_start), "task passes date range filter"); + Assert.ok(filter.dateRangeFilter(testItems.overlaps_end), "task passes date range filter"); + Assert.ok(filter.dateRangeFilter(testItems.overlaps_both), "task passes date range filter"); + + // Test isItemInFilters. + + Assert.ok(!filter.isItemInFilters(testItems.before), "task doesn't pass all filters"); + Assert.ok(!filter.isItemInFilters(testItems.during), "task passes all filters"); + Assert.ok(!filter.isItemInFilters(testItems.after), "task doesn't pass all filters"); + Assert.ok(!filter.isItemInFilters(testItems.overlaps_start), "task passes all filters"); + Assert.ok(filter.isItemInFilters(testItems.overlaps_end), "task passes all filters"); + Assert.ok(filter.isItemInFilters(testItems.overlaps_both), "task passes all filters"); + + // Test getItems. + + items = await promiseItems(filter, calendar); + Assert.equal(items.length, 2, "getItems returns expected number of items"); + Assert.equal(items[0].title, "overlaps_end", "correct item returned"); + Assert.equal(items[1].title, "overlaps_both", "correct item returned"); +}); + +add_task(async function testItemTypeFilter() { + let calendar = CalendarTestUtils.createCalendar("test"); + + let event = new CalEvent(); + event.id = cal.getUUID(); + event.title = "New event"; + event.startDate = cal.createDateTime("20210803T205500Z"); + event.endDate = cal.createDateTime("20210803T210200Z"); + await calendar.addItem(event); + + let task = new CalTodo(); + task.id = cal.getUUID(); + task.title = "New task"; + task.entryDate = cal.createDateTime("20210806T090000Z"); + task.dueDate = cal.createDateTime("20210810T140000Z"); + await calendar.addItem(task); + + // Create a new filter. + + let filter = new calFilter(); + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL; + filter.startDate = cal.createDateTime("20210801"); + filter.endDate = cal.createDateTime("20210831"); + + // Check both item types pass ITEM_FILTER_TYPE_ALL. + + Assert.ok(filter.itemTypeFilter(task), "task passes item type filter"); + Assert.ok(filter.itemTypeFilter(event), "event passes item type filter"); + + Assert.ok(filter.isItemInFilters(task), "task passes all filters"); + Assert.ok(filter.isItemInFilters(event), "event passes all filters"); + + let items = await promiseItems(filter, calendar); + Assert.equal(items.length, 2, "getItems returns expected number of items"); + Assert.equal(items[0].title, "New event", "correct item returned"); + Assert.equal(items[1].title, "New task", "correct item returned"); + + // Check only tasks pass ITEM_FILTER_TYPE_TODO. + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + + Assert.ok(filter.itemTypeFilter(task), "task passes item type filter"); + Assert.ok(!filter.itemTypeFilter(event), "event doesn't pass item type filter"); + + Assert.ok(filter.isItemInFilters(task), "task passes all filters"); + Assert.ok(!filter.isItemInFilters(event), "event doesn't pass all filters"); + + items = await promiseItems(filter, calendar); + Assert.equal(items.length, 1, "getItems returns expected number of items"); + Assert.equal(items[0].title, "New task", "correct item returned"); + + // Check only events pass ITEM_FILTER_TYPE_EVENT. + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + + Assert.ok(!filter.itemTypeFilter(task), "task doesn't pass item type filter"); + Assert.ok(filter.itemTypeFilter(event), "event passes item type filter"); + + Assert.ok(!filter.isItemInFilters(task), "task doesn't pass all filters"); + Assert.ok(filter.isItemInFilters(event), "event passes all filters"); + + items = await promiseItems(filter, calendar); + Assert.equal(items.length, 1, "getItems returns expected number of items"); + Assert.equal(items[0].title, "New event", "correct item returned"); + + // Check neither tasks or events pass ITEM_FILTER_TYPE_JOURNAL. + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_JOURNAL; + + Assert.ok(!filter.itemTypeFilter(event), "event doesn't pass item type filter"); + Assert.ok(!filter.itemTypeFilter(task), "task doesn't pass item type filter"); + + Assert.ok(!filter.isItemInFilters(task), "task doesn't pass all filters"); + Assert.ok(!filter.isItemInFilters(event), "event doesn't pass all filters"); + + items = await promiseItems(filter, calendar); + Assert.equal(items.length, 0, "getItems returns expected number of items"); +}); + +add_task(async function testItemTypeFilterTaskCompletion() { + let calendar = CalendarTestUtils.createCalendar("test"); + + let completeTask = new CalTodo(); + completeTask.id = cal.getUUID(); + completeTask.title = "Complete Task"; + completeTask.entryDate = cal.createDateTime("20210806T090000Z"); + completeTask.dueDate = cal.createDateTime("20210810T140000Z"); + completeTask.percentComplete = 100; + await calendar.addItem(completeTask); + + let incompleteTask = new CalTodo(); + incompleteTask.id = cal.getUUID(); + incompleteTask.title = "Incomplete Task"; + incompleteTask.entryDate = cal.createDateTime("20210806T090000Z"); + incompleteTask.dueDate = cal.createDateTime("20210810T140000Z"); + completeTask.completedDate = null; + await calendar.addItem(incompleteTask); + + let filter = new calFilter(); + filter.startDate = cal.createDateTime("20210801"); + filter.endDate = cal.createDateTime("20210831"); + + let checks = [ + { flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO, expectComplete: true, expectIncomplete: true }, + { + flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES, + expectComplete: true, + expectIncomplete: false, + }, + { + flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO, + expectComplete: false, + expectIncomplete: true, + }, + { + flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL, + expectComplete: true, + expectIncomplete: true, + }, + ]; + + for (let { flags, expectComplete, expectIncomplete } of checks) { + info(`testing with flags = ${flags}`); + filter.itemType = flags; + + Assert.equal( + filter.itemTypeFilter(completeTask), + expectComplete, + "complete task matches item type filter" + ); + Assert.equal( + filter.itemTypeFilter(incompleteTask), + expectIncomplete, + "incomplete task matches item type filter" + ); + + Assert.equal( + filter.isItemInFilters(completeTask), + expectComplete, + "complete task matches all filters" + ); + Assert.equal( + filter.isItemInFilters(incompleteTask), + expectIncomplete, + "incomplete task matches all filters" + ); + + let expectedTitles = []; + if (expectComplete) { + expectedTitles.push(completeTask.title); + } + if (expectIncomplete) { + expectedTitles.push(incompleteTask.title); + } + let items = await promiseItems(filter, calendar); + Assert.deepEqual( + items.map(i => i.title), + expectedTitles, + "getItems returns correct items" + ); + } +}); + +/** + * Tests that calFilter.getItems uses the correct flags when calling + * calICalendar.getItems. This is important because calFilter is used both by + * setting the itemType filter and with a calFilterProperties object. + */ +add_task(async function testGetItemsFilterFlags() { + let fakeCalendar = { + getItems(filter, count, rangeStart, rangeEndEx) { + Assert.equal(filter, expected.filter, "getItems called with the right filter"); + if (expected.rangeStart) { + Assert.equal( + rangeStart.compare(expected.rangeStart), + 0, + "getItems called with the right start date" + ); + } + if (expected.rangeEndEx) { + Assert.equal( + rangeEndEx.compare(expected.rangeEndEx), + 0, + "getItems called with the right end date" + ); + } + return CalReadableStreamFactory.createEmptyReadableStream(); + }, + }; + + // Test the basic item types. + // A request for TODO items requires one of the ITEM_FILTER_COMPLETED flags, + // if none are supplied then ITEM_FILTER_COMPLETED_ALL is added. + // (These flags have no effect on EVENT items.) + + let filter = new calFilter(); + let expected = { + filter: Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL, + }; + filter.getItems(fakeCalendar); + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + expected.filter = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + filter.getItems(fakeCalendar); + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + filter.getItems(fakeCalendar); + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + filter.getItems(fakeCalendar); + + // Test that we get occurrences if we have an end date. + + filter.startDate = cal.createDateTime("20220201T000000Z"); + filter.endDate = cal.createDateTime("20220301T000000Z"); + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_ALL | + Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL | + Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + expected.rangeStart = filter.startDate; + expected.rangeEndEx = filter.endDate; + filter.getItems(fakeCalendar); + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_EVENT | Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + filter.getItems(fakeCalendar); + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | + Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL | + Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + filter.getItems(fakeCalendar); + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_ALL | + Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL | + Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + filter.getItems(fakeCalendar); + + filter.startDate = null; + filter.endDate = null; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + delete expected.rangeStart; + delete expected.rangeEndEx; + filter.getItems(fakeCalendar); + + // Test that completed tasks are correctly filtered. + + filter.itemType = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES; + filter.getItems(fakeCalendar); + + filter.itemType = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO; + filter.getItems(fakeCalendar); + + filter.itemType = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + filter.getItems(fakeCalendar); + + filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + filter.getItems(fakeCalendar); + + // Using `applyFilter` needs a selected date or the test dies trying to find the + // `currentView` function, which doesn't exist in an XPCShell test. + filter.selectedDate = cal.dtz.now(); + filter.applyFilter("completed"); + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | + Ci.calICalendar.ITEM_FILTER_COMPLETED_YES | + Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + filter.getItems(fakeCalendar); + + filter.applyFilter("open"); + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO; + filter.getItems(fakeCalendar); + + filter.applyFilter(); + expected.filter = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + filter.getItems(fakeCalendar); +}); diff --git a/comm/calendar/test/unit/test_filter_mixin.js b/comm/calendar/test/unit/test_filter_mixin.js new file mode 100644 index 0000000000..0d30afb616 --- /dev/null +++ b/comm/calendar/test/unit/test_filter_mixin.js @@ -0,0 +1,1083 @@ +/* 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 { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); +const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs"); + +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +const { CalRecurrenceInfo } = ChromeUtils.import("resource:///modules/CalRecurrenceInfo.jsm"); +const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm"); + +/* globals CalendarFilteredViewMixin, CalReadableStreamFactory */ +Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js"); + +class TestCalFilter extends CalendarFilteredViewMixin(class {}) { + addedItems = []; + removedItems = []; + removedCalendarIds = []; + + clearItems() { + info("clearItems"); + this.addedItems.length = 0; + this.removedItems.length = 0; + this.removedCalendarIds.length = 0; + } + + addItems(items) { + info("addItems"); + this.addedItems.push(...items); + } + + removeItems(items) { + info("removeItems"); + this.removedItems.push(...items); + } + + removeItemsFromCalendar(calendarId) { + info("removeItemsFromCalendar"); + this.removedCalendarIds.push(calendarId); + } +} + +let testItems = {}; +let addedTestItems = {}; + +add_setup(async function () { + await new Promise(resolve => do_calendar_startup(resolve)); + + for (let [title, startDate, endDate] of [ + ["before", "20210720", "20210721"], + ["during", "20210820", "20210821"], + ["after", "20210920", "20210921"], + ["overlaps_start", "20210720", "20210804"], + ["overlaps_end", "20210820", "20210904"], + ["overlaps_both", "20210720", "20210904"], + ]) { + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = title; + item.startDate = cal.createDateTime(startDate); + item.endDate = cal.createDateTime(endDate); + testItems[title] = item; + } + + let repeatingItem = new CalEvent(); + repeatingItem.id = cal.getUUID(); + repeatingItem.title = "repeating"; + repeatingItem.startDate = cal.createDateTime("20210818T120000"); + repeatingItem.endDate = cal.createDateTime("20210818T130000"); + repeatingItem.recurrenceInfo = new CalRecurrenceInfo(repeatingItem); + repeatingItem.recurrenceInfo.appendRecurrenceItem( + cal.createRecurrenceRule("RRULE:FREQ=DAILY;INTERVAL=5;COUNT=4") + ); + testItems.repeating = repeatingItem; +}); + +add_task(async function testAddItems() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + for (let title of ["before", "after"]) { + testWidget.clearItems(); + addedTestItems[title] = await calendar.addItem(testItems[title]); + Assert.equal(testWidget.addedItems.length, 0); + } + + for (let title of ["during", "overlaps_start", "overlaps_end", "overlaps_both"]) { + testWidget.clearItems(); + addedTestItems[title] = await calendar.addItem(testItems[title]); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.addedItems[0].title, title); + } + + testWidget.clearItems(); + addedTestItems.repeating = await calendar.addItem(testItems.repeating); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "repeating"); + Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210818T120000"); + Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210818T130000"); + Assert.equal(testWidget.addedItems[1].title, "repeating"); + Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210823T120000"); + Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210823T130000"); + Assert.equal(testWidget.addedItems[2].title, "repeating"); + Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000"); + Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000"); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testRefresh() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + // Add all calendar items. + const promises = []; + for (const key in testItems) { + promises.push(calendar.addItem(testItems[key])); + } + await Promise.all(promises); + + testWidget.startDate = cal.createDateTime("20210801"); + testWidget.endDate = cal.createDateTime("20210831"); + await testWidget.refreshItems(); + + Assert.equal(testWidget.addedItems.length, 7, "getItems returns expected number of items"); + Assert.equal(testWidget.addedItems[0].title, "during", "correct item returned"); + Assert.equal(testWidget.addedItems[1].title, "overlaps_start", "correct item returned"); + Assert.equal(testWidget.addedItems[2].title, "overlaps_end", "correct item returned"); + Assert.equal(testWidget.addedItems[3].title, "overlaps_both", "correct item returned"); + Assert.equal(testWidget.addedItems[4].title, "repeating"); + Assert.equal(testWidget.addedItems[4].startDate.icalString, "20210818T120000"); + Assert.equal(testWidget.addedItems[4].endDate.icalString, "20210818T130000"); + Assert.equal(testWidget.addedItems[5].title, "repeating"); + Assert.equal(testWidget.addedItems[5].startDate.icalString, "20210823T120000"); + Assert.equal(testWidget.addedItems[5].endDate.icalString, "20210823T130000"); + Assert.equal(testWidget.addedItems[6].title, "repeating"); + Assert.equal(testWidget.addedItems[6].startDate.icalString, "20210828T120000"); + Assert.equal(testWidget.addedItems[6].endDate.icalString, "20210828T130000"); + + testWidget.startDate = cal.createDateTime("20210825"); + testWidget.endDate = cal.createDateTime("20210905"); + await testWidget.refreshItems(); + + Assert.equal(testWidget.addedItems.length, 4, "getItems returns expected number of items"); + Assert.equal(testWidget.addedItems[0].title, "overlaps_end", "correct item returned"); + Assert.equal(testWidget.addedItems[1].title, "overlaps_both", "correct item returned"); + Assert.equal(testWidget.addedItems[2].title, "repeating"); + Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000"); + Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000"); + Assert.equal(testWidget.addedItems[3].title, "repeating"); + Assert.equal(testWidget.addedItems[3].startDate.icalString, "20210902T120000"); + Assert.equal(testWidget.addedItems[3].endDate.icalString, "20210902T130000"); + + // Verify that refreshing while the widget is inactive doesn't prevent later + // attempts to refresh from succeeding. + testWidget.deactivate(); + testWidget.clearItems(); + Assert.equal( + testWidget.addedItems.length, + 0, + "there should be no items after deactivation and clearing" + ); + + await testWidget.refreshItems(); + Assert.equal(testWidget.addedItems.length, 0, "refreshing while inactive should not add items"); + + await testWidget.activate(); + Assert.equal(testWidget.addedItems.length, 4, "getItems returns expected number of items"); + Assert.equal(testWidget.addedItems[0].title, "overlaps_end", "correct item returned"); + Assert.equal(testWidget.addedItems[1].title, "overlaps_both", "correct item returned"); + Assert.equal(testWidget.addedItems[2].title, "repeating"); + Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000"); + Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000"); + Assert.equal(testWidget.addedItems[3].title, "repeating"); + Assert.equal(testWidget.addedItems[3].startDate.icalString, "20210902T120000"); + Assert.equal(testWidget.addedItems[3].endDate.icalString, "20210902T130000"); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testRemoveItems() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + for (let title of ["before", "after"]) { + testWidget.clearItems(); + await calendar.deleteItem(addedTestItems[title]); + Assert.equal(testWidget.removedItems.length, 0); + } + + for (let title of ["during", "overlaps_start", "overlaps_end", "overlaps_both"]) { + testWidget.clearItems(); + await calendar.deleteItem(addedTestItems[title]); + + Assert.equal(testWidget.removedItems.length, 1); + Assert.equal(testWidget.removedItems[0].title, title); + } + + testWidget.clearItems(); + await calendar.deleteItem(addedTestItems.repeating); + + Assert.equal(testWidget.removedItems.length, 3); + Assert.equal(testWidget.removedItems[0].title, "repeating"); + Assert.equal(testWidget.removedItems[0].startDate.icalString, "20210818T120000"); + Assert.equal(testWidget.removedItems[0].endDate.icalString, "20210818T130000"); + Assert.equal(testWidget.removedItems[1].title, "repeating"); + Assert.equal(testWidget.removedItems[1].startDate.icalString, "20210823T120000"); + Assert.equal(testWidget.removedItems[1].endDate.icalString, "20210823T130000"); + Assert.equal(testWidget.removedItems[2].title, "repeating"); + Assert.equal(testWidget.removedItems[2].startDate.icalString, "20210828T120000"); + Assert.equal(testWidget.removedItems[2].endDate.icalString, "20210828T130000"); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testModifyItem() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "change me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.addedItems[0].title, "change me"); + + let changedItem = item.clone(); + changedItem.title = "changed"; + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.addedItems[0].title, "changed"); + Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0])); + Assert.equal(testWidget.removedItems.length, 1); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + + testWidget.clearItems(); + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 1); + Assert.equal(testWidget.removedItems[0].title, "changed"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testMoveItemWithinRange() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "move me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.addedItems[0].title, "move me"); + + let changedItem = item.clone(); + changedItem.startDate = cal.createDateTime("20210805T180000"); + changedItem.endDate = cal.createDateTime("20210805T190000"); + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0])); + Assert.equal(testWidget.removedItems.length, 1); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + + testWidget.clearItems(); + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 1); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testMoveItemOutOfRange() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "move me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.addedItems[0].title, "move me"); + + let changedItem = item.clone(); + changedItem.startDate = cal.createDateTime("20210905T170000"); + changedItem.endDate = cal.createDateTime("20210905T180000"); + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 1); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + + testWidget.clearItems(); + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 0); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testMoveItemInToRange() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "move me"; + item.startDate = cal.createDateTime("20210705T170000"); + item.endDate = cal.createDateTime("20210705T180000"); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 0); + + let changedItem = item.clone(); + changedItem.startDate = cal.createDateTime("20210805T170000"); + changedItem.endDate = cal.createDateTime("20210805T180000"); + + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.equal(testWidget.removedItems.length, 0); + + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 1); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(testWidget.addedItems[0])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testModifyRecurringItem() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "change me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "change me"); + Assert.equal(testWidget.addedItems[1].title, "change me"); + Assert.equal(testWidget.addedItems[2].title, "change me"); + + let changedItem = item.clone(); + changedItem.title = "changed"; + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "changed"); + Assert.equal(testWidget.addedItems[1].title, "changed"); + Assert.equal(testWidget.addedItems[2].title, "changed"); + Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2])); + Assert.equal(testWidget.removedItems.length, 3); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + testWidget.clearItems(); + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 3); + Assert.equal(testWidget.removedItems[0].title, "changed"); + Assert.equal(testWidget.removedItems[1].title, "changed"); + Assert.equal(testWidget.removedItems[2].title, "changed"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testMoveRecurringItemWithinRange() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "move me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.equal(testWidget.addedItems[1].title, "move me"); + Assert.equal(testWidget.addedItems[2].title, "move me"); + + let changedItem = item.clone(); + changedItem.startDate = cal.createDateTime("20210805T180000"); + changedItem.endDate = cal.createDateTime("20210805T190000"); + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem(changedItem, item); + + // This maybe should call modifyItems, but instead it calls addItems and removeItems. + + Assert.equal(testWidget.removedItems.length, 3); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.equal(testWidget.removedItems[1].title, "move me"); + Assert.equal(testWidget.removedItems[2].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.equal(testWidget.addedItems[1].title, "move me"); + Assert.equal(testWidget.addedItems[2].title, "move me"); + Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210805T180000"); + Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210806T180000"); + Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210807T180000"); + Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210805T190000"); + Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210806T190000"); + Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210807T190000"); + + addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 3); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.equal(testWidget.removedItems[1].title, "move me"); + Assert.equal(testWidget.removedItems[2].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testMoveRecurringItemOutOfRange() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "move me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.equal(testWidget.addedItems[1].title, "move me"); + Assert.equal(testWidget.addedItems[2].title, "move me"); + + let changedItem = item.clone(); + changedItem.startDate = cal.createDateTime("20210905T170000"); + changedItem.endDate = cal.createDateTime("20210905T180000"); + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 3); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.equal(testWidget.removedItems[1].title, "move me"); + Assert.equal(testWidget.removedItems[2].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + testWidget.clearItems(); + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 0); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testMoveRecurringItemInToRange() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "move me"; + item.startDate = cal.createDateTime("20210705T170000"); + item.endDate = cal.createDateTime("20210705T180000"); + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 0); + + let changedItem = item.clone(); + changedItem.startDate = cal.createDateTime("20210805T170000"); + changedItem.endDate = cal.createDateTime("20210805T180000"); + + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.equal(testWidget.addedItems[1].title, "move me"); + Assert.equal(testWidget.addedItems[2].title, "move me"); + Assert.equal(testWidget.removedItems.length, 0); + + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.removedItems.length, 3); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.equal(testWidget.removedItems[1].title, "move me"); + Assert.equal(testWidget.removedItems[2].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(testWidget.addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(testWidget.addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(testWidget.addedItems[2])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testModifyOccurrence() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "change me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "change me"); + Assert.equal(testWidget.addedItems[1].title, "change me"); + Assert.equal(testWidget.addedItems[2].title, "change me"); + + let occurrences = item.recurrenceInfo.getOccurrences( + testWidget.startDate, + testWidget.endDate, + 100 + ); + let changedOccurrence = occurrences[1].clone(); + changedOccurrence.title = "changed"; + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem( + cal.itip.prepareSequence(changedOccurrence, occurrences[1]), + occurrences[1] + ); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "change me"); + Assert.equal(testWidget.addedItems[1].title, "changed"); + Assert.equal(testWidget.addedItems[2].title, "change me"); + Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2])); + Assert.equal(testWidget.removedItems.length, 3); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + testWidget.clearItems(); + await calendar.deleteItem(item); + + Assert.equal(testWidget.removedItems.length, 3); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testDeleteOccurrence() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "change me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "change me"); + Assert.equal(testWidget.addedItems[1].title, "change me"); + Assert.equal(testWidget.addedItems[2].title, "change me"); + + let changedItem = item.clone(); + let occurrences = changedItem.recurrenceInfo.getOccurrences( + testWidget.startDate, + testWidget.endDate, + 100 + ); + changedItem.recurrenceInfo.removeOccurrenceAt(occurrences[1].recurrenceId); + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 2); + Assert.equal(testWidget.addedItems[0].title, "change me"); + Assert.equal(testWidget.addedItems[1].title, "change me"); + Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[2])); + Assert.equal(testWidget.removedItems.length, 3); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + testWidget.clearItems(); + await calendar.deleteItem(item); + + Assert.equal(testWidget.removedItems.length, 3); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testMoveOccurrenceWithinRange() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "move me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + item.recurrenceInfo = new CalRecurrenceInfo(item); + item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")); + + testWidget.clearItems(); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.equal(testWidget.addedItems[1].title, "move me"); + Assert.equal(testWidget.addedItems[2].title, "move me"); + + let occurrences = item.recurrenceInfo.getOccurrences( + testWidget.startDate, + testWidget.endDate, + 100 + ); + let changedOccurrence = occurrences[1].clone(); + changedOccurrence.startDate = cal.createDateTime("20210806T173000"); + changedOccurrence.endDate = cal.createDateTime("20210806T183000"); + + let addedItems = testWidget.addedItems.slice(); + testWidget.clearItems(); + await calendar.modifyItem( + cal.itip.prepareSequence(changedOccurrence, occurrences[1]), + occurrences[1] + ); + + Assert.equal(testWidget.addedItems.length, 3); + Assert.equal(testWidget.addedItems[0].title, "move me"); + Assert.equal(testWidget.addedItems[1].title, "move me"); + Assert.equal(testWidget.addedItems[2].title, "move me"); + Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210805T170000"); + Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210806T173000"); + Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210807T170000"); + Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210805T180000"); + Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210806T183000"); + Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210807T180000"); + Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2])); + Assert.equal(testWidget.removedItems.length, 3); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + testWidget.clearItems(); + await calendar.deleteItem(item); + + Assert.equal(testWidget.removedItems.length, 3); + Assert.equal(testWidget.removedItems[0].title, "move me"); + Assert.equal(testWidget.removedItems[1].title, "move me"); + Assert.equal(testWidget.removedItems[2].title, "move me"); + Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0])); + Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1])); + Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2])); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testChangeTaskCompletion() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let incompleteTask = new CalTodo(); + incompleteTask.id = cal.getUUID(); + incompleteTask.title = "incomplete task"; + incompleteTask.startDate = cal.createDateTime("20210805T170000"); + incompleteTask.endDate = cal.createDateTime("20210805T180000"); + + // Set the widget to only show incomplete tasks. + + testWidget.itemType = + Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO; + + // Add an incomplete task to the calendar. + + testWidget.clearItems(); + incompleteTask = await calendar.addItem(incompleteTask); + + Assert.equal(testWidget.addedItems.length, 1, "incomplete item was added"); + Assert.equal(testWidget.addedItems[0].title, "incomplete task"); + + // Complete the task. It should be removed from the widget. + + let completeTask = incompleteTask.clone(); + completeTask.title = "complete task"; + completeTask.percentComplete = 100; + + testWidget.clearItems(); + completeTask = await calendar.modifyItem(completeTask, incompleteTask); + + Assert.equal(testWidget.removedItems.length, 1, "complete item was removed"); + Assert.equal(testWidget.removedItems[0].title, "incomplete task"); + + // Mark the task as incomplete again. It should be added back to the widget. + + let incompleteAgainItem = completeTask.clone(); + incompleteAgainItem.title = "incomplete again task"; + incompleteAgainItem.percentComplete = 50; + + testWidget.clearItems(); + await calendar.modifyItem(incompleteAgainItem, completeTask); + + Assert.equal(testWidget.addedItems.length, 1, "incomplete item was added"); + Assert.equal(testWidget.addedItems[0].title, "incomplete again task"); + + // Clean up. + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testDisableEnableCalendar() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + addedTestItems.during = await calendar.addItem(testItems.during); + + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.removedItems.length, 0); + Assert.equal(testWidget.removedCalendarIds.length, 0); + + // Test disabling and enabling the calendar. + + testWidget.clearItems(); + calendar.setProperty("disabled", true); + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]); + + testWidget.clearItems(); + calendar.setProperty("disabled", false); + await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1); + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.removedItems.length, 0); + Assert.equal(testWidget.removedCalendarIds.length, 0); + + // Test hiding and showing the calendar. + + testWidget.clearItems(); + calendar.setProperty("calendar-main-in-composite", false); + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]); + + testWidget.clearItems(); + calendar.setProperty("calendar-main-in-composite", true); + await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1); + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.removedItems.length, 0); + Assert.equal(testWidget.removedCalendarIds.length, 0); + + // Test disabling and enabling the calendar while it is hidden. + + testWidget.clearItems(); + calendar.setProperty("calendar-main-in-composite", false); + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]); + + testWidget.clearItems(); + calendar.setProperty("disabled", true); + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]); + + testWidget.clearItems(); + calendar.setProperty("disabled", false); + await new Promise(resolve => do_timeout(500, resolve)); + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + Assert.equal(testWidget.removedCalendarIds.length, 0); + + testWidget.clearItems(); + calendar.setProperty("calendar-main-in-composite", true); + await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1); + Assert.equal(testWidget.addedItems.length, 1); + Assert.equal(testWidget.removedItems.length, 0); + Assert.equal(testWidget.removedCalendarIds.length, 0); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testChangeWhileHidden() { + const { calendar, testWidget } = await initializeCalendarAndTestWidget(); + + let item = new CalEvent(); + item.id = cal.getUUID(); + item.title = "change me"; + item.startDate = cal.createDateTime("20210805T170000"); + item.endDate = cal.createDateTime("20210805T180000"); + + testWidget.clearItems(); + calendar.setProperty("calendar-main-in-composite", false); + item = await calendar.addItem(item); + + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + + let changedItem = item.clone(); + changedItem.title = "changed"; + await calendar.modifyItem(changedItem, item); + + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + + await calendar.deleteItem(changedItem); + + Assert.equal(testWidget.addedItems.length, 0); + Assert.equal(testWidget.removedItems.length, 0); + + CalendarTestUtils.removeCalendar(calendar); + testWidget.deactivate(); +}); + +add_task(async function testChangeWhileRefreshing() { + // Create a calendar we can control the output of. + + let pumpCalendar = { + type: "pump", + uri: Services.io.newURI("pump:test-calendar"), + getProperty(name) { + switch (name) { + case "disabled": + return false; + case "calendar-main-in-composite": + return true; + } + return null; + }, + addObserver() {}, + + getItems(filter, count, rangeStart, rangeEndEx) { + return CalReadableStreamFactory.createReadableStream({ + async start(controller) { + pumpCalendar.controller = controller; + }, + }); + }, + }; + cal.manager.registerCalendar(pumpCalendar); + + // Create a new widget and a Promise waiting for it to be ready. + + let widget = new TestCalFilter(); + widget.id = "test-filter"; + widget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL; + + let ready1 = widget.ready; + let ready1Resolved, ready1Rejected; + ready1.then( + arg => { + ready1Resolved = true; + }, + arg => { + ready1Rejected = true; + } + ); + + // Ask the calendars for items. Get a waiting Promise before and after doing so. + // These should be the same as the earlier Promise. + + Assert.equal(widget.ready, ready1, ".ready should return the same Promise"); + Assert.equal(widget.activate(), ready1, ".activate should return the same Promise"); + Assert.equal(widget.ready, ready1, ".ready should return the same Promise"); + + // Return some items from the calendar. They should be sent to addItems. + + pumpCalendar.controller.enqueue([testItems.during]); + await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item"); + Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected"); + + // Make the widget inactive. This invalidates the earlier call to `refreshItems`. + + widget.deactivate(); + + // Return some more items from the calendar. These should be ignored. + + // Even though the data is now invalid the original Promise should not have been replaced with a + // new one. + + Assert.equal(widget.ready, ready1, ".ready should return the same Promise"); + + pumpCalendar.controller.enqueue([testItems.after]); + pumpCalendar.controller.close(); + + // We're testing that nothing happens. Give it time to potentially happen. + await new Promise(resolve => do_timeout(500, resolve)); + + Assert.equal(widget.addedItems.length, 1, "no more items added"); + Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected"); + Assert.equal(ready1Resolved, undefined, "Promise did not yet resolve"); + Assert.equal(ready1Rejected, undefined, "Promise did not yet reject"); + + // Make the widget active again so we can test some other things. + + widget.clearItems(); + + Assert.equal(widget.activate(), ready1, ".activate should return the same Promise"); + Assert.equal(widget.ready, ready1, ".ready should return the same Promise"); + + pumpCalendar.controller.enqueue([testItems.during]); + await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item"); + Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected"); + + // ... then before it finishes, force another refresh. We're still waiting for the original + // Promise because no refresh has completed yet. + // Return a different item, just to be sure we got the one we expected. + + Assert.equal(widget.refreshItems(true), ready1, ".refreshItems should return the same Promise"); + Assert.equal(widget.addedItems.length, 0, "items were cleared"); + + pumpCalendar.controller.enqueue([testItems.before]); + pumpCalendar.controller.close(); + + // Finally we have a completed refresh. The Promise should resolve now. + + await ready1; + await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item"); + Assert.equal(widget.addedItems[0].title, testItems.before.title, "added item was expected"); + + // The Promise should not be replaced until the dates or item type change, or we force a refresh. + + Assert.equal(widget.ready, ready1, ".ready should return the same Promise"); + Assert.equal(widget.refreshItems(), ready1, ".refreshItems should return the same Promise"); + + // Force refresh again. There should be a new ready Promise, since the old one was resolved and + // we forced a refresh. + + let ready2 = widget.refreshItems(true); + Assert.notEqual(ready2, ready1, ".refreshItems should return a new Promise"); + Assert.equal(widget.addedItems.length, 0, "items were cleared"); + + pumpCalendar.controller.enqueue([testItems.after]); + pumpCalendar.controller.close(); + + await ready2; + await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item"); + Assert.equal(widget.addedItems[0].title, testItems.after.title, "added item was expected"); + + // Change the item type. There should be a new ready Promise, since the old one was resolved and + // the item type changed. + + widget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + let ready3 = widget.ready; + Assert.notEqual(ready3, ready2, ".ready should return a new Promise"); + Assert.equal(widget.refreshItems(), ready3, ".refreshItems should return the same Promise"); + Assert.equal(widget.addedItems.length, 0, "items were cleared"); + + pumpCalendar.controller.enqueue([testItems.during]); + pumpCalendar.controller.close(); + + await ready3; + await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item"); + Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected"); + + // Change the start date. There should be a new ready Promise, since the old one was resolved and + // the start date changed. + + widget.startDate = cal.createDateTime("20220317"); + let ready4 = widget.ready; + Assert.notEqual(ready4, ready3, ".ready should return a new Promise"); + Assert.equal(widget.refreshItems(), ready4, ".refreshItems should return the same Promise"); + + pumpCalendar.controller.close(); + await ready4; + + // Change the end date. There should be a new ready Promise, since the old one was resolved and + // the end date changed. + + widget.endDate = cal.createDateTime("20220318"); + let ready5 = widget.ready; + Assert.notEqual(ready5, ready4, ".ready should return a new Promise"); + Assert.equal(widget.refreshItems(), ready5, ".refreshItems should return the same Promise"); + + pumpCalendar.controller.close(); + await ready5; +}); + +async function initializeCalendarAndTestWidget() { + const calendar = CalendarTestUtils.createCalendar("test", "storage"); + calendar.setProperty("calendar-main-in-composite", true); + + const testWidget = new TestCalFilter(); + testWidget.startDate = cal.createDateTime("20210801"); + testWidget.endDate = cal.createDateTime("20210831"); + testWidget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL; + await testWidget.activate(); + + return { calendar, testWidget }; +} diff --git a/comm/calendar/test/unit/test_filter_tree_view.js b/comm/calendar/test/unit/test_filter_tree_view.js new file mode 100644 index 0000000000..97313849c4 --- /dev/null +++ b/comm/calendar/test/unit/test_filter_tree_view.js @@ -0,0 +1,451 @@ +/* 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 { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); +const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs"); + +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +const { CalRecurrenceInfo } = ChromeUtils.import("resource:///modules/CalRecurrenceInfo.jsm"); +const { CalRecurrenceRule } = ChromeUtils.import("resource:///modules/CalRecurrenceRule.jsm"); + +const { TreeSelection } = ChromeUtils.importESModule( + "chrome://messenger/content/tree-selection.mjs" +); + +Services.scriptloader.loadSubScript("chrome://messenger/content/jsTreeView.js"); +Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js"); +/* globals CalendarFilteredTreeView */ +Services.scriptloader.loadSubScript( + "chrome://calendar/content/widgets/calendar-filter-tree-view.js" +); + +const testItems = {}; + +add_setup(async function () { + await new Promise(resolve => do_calendar_startup(resolve)); + + // Create events useful for testing. + for (const [title, startDate, endDate] of [ + ["one", "20221126T010000", "20221126T013000"], + ["two", "20221126T020000", "20221126T073000"], + ["three", "20221126T030000", "20221126T033000"], + ["four", "20221126T040000", "20221126T043000"], + ["five", "20221126T050000", "20221126T053000"], + ["six", "20221126T060000", "20221126T063000"], + ]) { + const item = new CalEvent(); + item.id = cal.getUUID(); + item.title = title; + item.startDate = cal.createDateTime(startDate); + item.endDate = cal.createDateTime(endDate); + testItems[title] = item; + } + + const recurring = new CalEvent(); + recurring.id = cal.getUUID(); + recurring.title = "recurring event"; + recurring.startDate = cal.createDateTime("20221124T053000"); + recurring.endDate = cal.createDateTime("20221124T063000"); + + const recurRule = cal.createRecurrenceRule(); + recurRule.type = "DAILY"; + recurRule.byCount = true; + recurRule.count = 5; + + const recurInfo = new CalRecurrenceInfo(recurring); + recurInfo.appendRecurrenceItem(recurRule); + + recurring.recurrenceInfo = recurInfo; + + testItems.recurring = recurring; +}); + +add_task(async function testAddItemsAndSort() { + const { calendar, view } = await initializeCalendarAndView(); + + assertViewContainsItemsInOrder(view); + + await calendar.addItem(testItems.one); + assertViewContainsItemsInOrder(view, "one"); + + await calendar.addItem(testItems.three); + await calendar.addItem(testItems.four); + assertViewContainsItemsInOrder(view, "one", "three", "four"); + + // Verify that items are sorted by start time by default. + await calendar.addItem(testItems.two); + assertViewContainsItemsInOrder(view, "one", "two", "three", "four"); + + // Change sort to ascending by title. + view.cycleHeader({ id: "title" }); + assertViewContainsItemsInOrder(view, "four", "one", "three", "two"); + + // Verify that items are sorted appropriately on add. + await calendar.addItem(testItems.five); + assertViewContainsItemsInOrder(view, "five", "four", "one", "three", "two"); + + // Change sort to descending by title. + view.cycleHeader({ id: "title" }); + assertViewContainsItemsInOrder(view, "two", "three", "one", "four", "five"); + + await calendar.addItem(testItems.six); + assertViewContainsItemsInOrder(view, "two", "three", "six", "one", "four", "five"); + + // Re-sort by start date for testing recurrences. + view.cycleHeader({ id: "startDate" }); + + // Verify that recurring events which occur more than once in the filter range + // show up more than once. Also verify that occurrences outside the filter + // range do not display. + await calendar.addItem(testItems.recurring); + assertViewContainsItemsInOrder( + view, + "one", + "two", + "three", + "four", + "five", + "recurring event", + "six", + "recurring event" + ); + + CalendarTestUtils.removeCalendar(calendar); + view.deactivate(); +}); + +add_task(async function testInitializeWithExistingCalenderEvents() { + const calendar = CalendarTestUtils.createCalendar("test", "storage"); + calendar.setProperty("calendar-main-in-composite", true); + + // Add items to the calendar before we initialize the view. + await calendar.addItem(testItems.one); + await calendar.addItem(testItems.three); + await calendar.addItem(testItems.four); + + const view = new CalendarFilteredTreeView(); + view.startDate = cal.createDateTime("20221126"); + view.endDate = cal.createDateTime("20221128"); + view.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + + const tree = { + _batchUpdated: false, + _batchDepth: false, + + beginUpdateBatch() {}, + endUpdateBatch() {}, + invalidateRow(index) {}, + }; + view.setTree(tree); + + // Wait for the view to fetch items and update. + await view.activate(); + + // Verify that items added to the calendar before initializing are displayed. + assertViewContainsItemsInOrder(view, "one", "three", "four"); + + // Verify that adding further items causes them to be displayed as well. + await calendar.addItem(testItems.two); + assertViewContainsItemsInOrder(view, "one", "two", "three", "four"); + + CalendarTestUtils.removeCalendar(calendar); + view.deactivate(); +}); + +add_task(async function testRemoveItems() { + const { calendar, view } = await initializeCalendarAndView(); + + // Record the calendar items so we can use them to delete. + const calendarItems = {}; + for (const key in testItems) { + calendarItems[key] = await calendar.addItem(testItems[key]); + } + + // Sanity check. + assertViewContainsItemsInOrder( + view, + "one", + "two", + "three", + "four", + "five", + "recurring event", + "six", + "recurring event" + ); + + await calendar.deleteItem(calendarItems.two); + assertViewContainsItemsInOrder( + view, + "one", + "three", + "four", + "five", + "recurring event", + "six", + "recurring event" + ); + + // Verify that all occurrences of recurring items are removed. + await calendar.deleteItem(calendarItems.recurring); + assertViewContainsItemsInOrder(view, "one", "three", "four", "five", "six"); + + await calendar.deleteItem(calendarItems.three); + await calendar.deleteItem(calendarItems.four); + assertViewContainsItemsInOrder(view, "one", "five", "six"); + + // Verify that sort order doesn't impact removal. + view.cycleHeader({ id: "title" }); + await calendar.deleteItem(calendarItems.five); + assertViewContainsItemsInOrder(view, "one", "six"); + + CalendarTestUtils.removeCalendar(calendar); + view.deactivate(); +}); + +add_task(async function testClearItems() { + const { calendar, view } = await initializeCalendarAndView(); + + // Add all calendar items. + const promises = []; + for (const key in testItems) { + promises.push(calendar.addItem(testItems[key])); + } + await Promise.all(promises); + + // Sanity check. + assertViewContainsItemsInOrder( + view, + "one", + "two", + "three", + "four", + "five", + "recurring event", + "six", + "recurring event" + ); + + // Directly call clear, as there isn't a convenient way to trigger it via the + // calendar. + view.clearItems(); + + assertViewContainsItemsInOrder(view); + + CalendarTestUtils.removeCalendar(calendar); + view.deactivate(); +}); + +add_task(async function testFilterFunction() { + const { calendar, view } = await initializeCalendarAndView(); + + // Add some items which will match the filter and some which won't. + const promises = []; + for (const key of ["one", "two", "five", "recurring"]) { + promises.push(calendar.addItem(testItems[key])); + } + await Promise.all(promises); + + // Add a selection to ensure that selections don't persist when filter changes. + view.selection.toggleSelect(0); + + // Sanity check. + assertViewContainsItemsInOrder(view, "one", "two", "five", "recurring event", "recurring event"); + Assert.ok(view.selection.isSelected(0), "item 'one' should be selected"); + + // Verify that setting filter function appropriately hides non-matching items. + view.setFilterFunction(item => { + return item.title.includes("f"); + }); + assertViewContainsItemsInOrder(view, "five"); + Assert.ok(!view.selection.isSelected(0), "item 'five' should not be selected"); + + // Verify that matching items display when added. + await calendar.addItem(testItems.four); + assertViewContainsItemsInOrder(view, "four", "five"); + + // Verify that sorting respects filter. + view.cycleHeader({ id: "title" }); + assertViewContainsItemsInOrder(view, "five", "four"); + + // Verify that non-matching items don't display when added. + await calendar.addItem(testItems.six); + assertViewContainsItemsInOrder(view, "five", "four"); + + // Verify that clearing the filter shows all items properly sorted. + view.clearFilter(); + assertViewContainsItemsInOrder( + view, + "five", + "four", + "one", + "recurring event", + "recurring event", + "six", + "two" + ); + + CalendarTestUtils.removeCalendar(calendar); + view.deactivate(); +}); + +add_task(async function testRemoveItemsFromCalendar() { + const { calendar, view } = await initializeCalendarAndView(); + + const secondCalendar = CalendarTestUtils.createCalendar("test", "storage"); + secondCalendar.setProperty("calendar-main-in-composite", true); + + const promises = []; + + // Add some items to the first calendar. + for (const key of ["one", "two", "five", "recurring"]) { + promises.push(calendar.addItem(testItems[key])); + } + + // Add the rest to the second calendar. + for (const key of ["three", "four", "six"]) { + promises.push(secondCalendar.addItem(testItems[key])); + } + + await Promise.all(promises); + + // Verify that both calendars are displayed. + assertViewContainsItemsInOrder( + view, + "one", + "two", + "three", + "four", + "five", + "recurring event", + "six", + "recurring event" + ); + + // Verify that removing items from a specific calendar removes exactly those + // events from the view. + view.removeItemsFromCalendar(calendar.id); + + assertViewContainsItemsInOrder(view, "three", "four", "six"); + + CalendarTestUtils.removeCalendar(calendar); + CalendarTestUtils.removeCalendar(secondCalendar); + view.deactivate(); +}); + +add_task(async function testSortRespectsSelection() { + const { calendar, view } = await initializeCalendarAndView(); + + // Add all calendar items. + const promises = []; + for (const key in testItems) { + promises.push(calendar.addItem(testItems[key])); + } + await Promise.all(promises); + + view.selection.toggleSelect(1); + view.selection.toggleSelect(5); + view.selection.toggleSelect(6); + + view.selection.currentIndex = 1; + + // Sanity check. + assertViewContainsItemsInOrder( + view, + "one", + "two", + "three", + "four", + "five", + "recurring event", + "six", + "recurring event" + ); + + // Sanity check selection; two, recurring event, and six should be selected, + // nothing else. + Assert.ok(view.selection.isSelected(1), "item 'two' should be selected"); + Assert.ok(view.selection.isSelected(5), "item 'recurring event' should be selected"); + Assert.ok(view.selection.isSelected(6), "item 'three' should be selected"); + Assert.equal(view.selection.currentIndex, 1, "item 'two' should be the current selection"); + for (const row of [0, 2, 3, 4, 7]) { + Assert.ok(!view.selection.isSelected(row), `row ${row} should not be selected`); + } + + // Verify that sorting the tree keeps the same events selected. + view.cycleHeader({ id: "title" }); + + assertViewContainsItemsInOrder( + view, + "five", + "four", + "one", + "recurring event", + "recurring event", + "six", + "three", + "two" + ); + + Assert.ok(view.selection.isSelected(7), "item 'two' should remain selected"); + Assert.ok(view.selection.isSelected(5), "item 'recurring event' should remain selected"); + Assert.ok(view.selection.isSelected(3), "item 'three' should remain selected"); + Assert.equal(view.selection.currentIndex, 7, "item 'two' should be the current selection"); + for (const row of [0, 1, 2, 4, 6]) { + Assert.ok(!view.selection.isSelected(row), `row ${row} should not be selected`); + } + + CalendarTestUtils.removeCalendar(calendar); + view.deactivate(); +}); + +function assertViewContainsItemsInOrder(view, ...expected) { + const actual = []; + for (let i = 0; i < view.rowCount; i++) { + actual.push(view.getCellText(i, { id: "title" })); + } + + // Check array length. We don't use Assert.equal() here in order to provide + // better debugging output. + if (actual.length != expected.length) { + Assert.report( + actual.length != expected.length, + actual, + expected, + `${JSON.stringify(actual)} should have the same length as ${JSON.stringify(expected)}` + ); + } + + Assert.deepEqual(actual, expected); +} + +async function initializeCalendarAndView() { + const calendar = CalendarTestUtils.createCalendar("test", "storage"); + calendar.setProperty("calendar-main-in-composite", true); + + const view = new CalendarFilteredTreeView(); + view.startDate = cal.createDateTime("20221126"); + view.endDate = cal.createDateTime("20221128"); + view.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT; + view.activate(); + + const tree = { + _batchUpdated: false, + _batchDepth: false, + + beginUpdateBatch() {}, + endUpdateBatch() {}, + invalidateRow(index) {}, + }; + view.setTree(tree); + + const selection = new TreeSelection(tree); + selection.view = view; + view.selection = selection; + selection.clearSelection(); + + return { calendar, view }; +} diff --git a/comm/calendar/test/unit/test_freebusy.js b/comm/calendar/test/unit/test_freebusy.js new file mode 100644 index 0000000000..73ac60fb3d --- /dev/null +++ b/comm/calendar/test/unit/test_freebusy.js @@ -0,0 +1,88 @@ +/* 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/. */ + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_freebusy(); + test_period(); +} + +function test_freebusy() { + let icsService = Cc["@mozilla.org/calendar/ics-service;1"].getService(Ci.calIICSService); + + // Bug 415987 - FREEBUSY decoding does not support comma-separated entries + // (https://bugzilla.mozilla.org/show_bug.cgi?id=415987) + let fbVal1 = "20080206T160000Z/PT1H"; + let fbVal2 = "20080206T180000Z/PT1H"; + let fbVal3 = "20080206T220000Z/PT1H"; + let data = + "BEGIN:VCALENDAR\n" + + "BEGIN:VFREEBUSY\n" + + "FREEBUSY;FBTYPE=BUSY:" + + fbVal1 + + "," + + fbVal2 + + "," + + fbVal3 + + "\n" + + "END:VFREEBUSY\n" + + "END:VCALENDAR\n"; + let fbComp = icsService.parseICS(data).getFirstSubcomponent("VFREEBUSY"); + equal(fbComp.getFirstProperty("FREEBUSY").value, fbVal1); + equal(fbComp.getNextProperty("FREEBUSY").value, fbVal2); + equal(fbComp.getNextProperty("FREEBUSY").value, fbVal3); +} + +function test_period() { + let period = Cc["@mozilla.org/calendar/period;1"].createInstance(Ci.calIPeriod); + + period.start = cal.createDateTime("20120101T010101"); + period.end = cal.createDateTime("20120101T010102"); + + equal(period.icalString, "20120101T010101/20120101T010102"); + equal(period.duration.icalString, "PT1S"); + + period.icalString = "20120101T010103/20120101T010104"; + + equal(period.start.icalString, "20120101T010103"); + equal(period.end.icalString, "20120101T010104"); + equal(period.duration.icalString, "PT1S"); + + period.icalString = "20120101T010105/PT1S"; + equal(period.start.icalString, "20120101T010105"); + equal(period.end.icalString, "20120101T010106"); + equal(period.duration.icalString, "PT1S"); + + period.makeImmutable(); + // ical.js doesn't support immutability yet + // throws( + // () => { + // period.start = cal.createDateTime("20120202T020202"); + // }, + // /0x80460002/, + // "Object is Immutable" + // ); + // throws( + // () => { + // period.end = cal.createDateTime("20120202T020202"); + // }, + // /0x80460002/, + // "Object is Immutable" + // ); + + let copy = period.clone(); + equal(copy.start.icalString, "20120101T010105"); + equal(copy.end.icalString, "20120101T010106"); + equal(copy.duration.icalString, "PT1S"); + + copy.start.icalString = "20120101T010106"; + copy.end = cal.createDateTime("20120101T010107"); + + equal(period.start.icalString, "20120101T010105"); + equal(period.end.icalString, "20120101T010106"); + equal(period.duration.icalString, "PT1S"); +} diff --git a/comm/calendar/test/unit/test_freebusy_service.js b/comm/calendar/test/unit/test_freebusy_service.js new file mode 100644 index 0000000000..e19d51f943 --- /dev/null +++ b/comm/calendar/test/unit/test_freebusy_service.js @@ -0,0 +1,201 @@ +/* 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 freebusy = Cc["@mozilla.org/calendar/freebusy-service;1"].getService(Ci.calIFreeBusyService); + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_found(); + test_noproviders(); + test_failure(); + test_cancel(); +} + +function test_found() { + _clearProviders(); + + equal(_countProviders(), 0); + + let provider1 = { + id: 1, + getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) { + aListener.onResult(null, []); + }, + }; + + let provider2 = { + id: 2, + called: false, + getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) { + ok(!this.called); + this.called = true; + + let interval = new cal.provider.FreeBusyInterval( + aCalId, + Ci.calIFreeBusyInterval.BUSY, + aStart, + aEnd + ); + aListener.onResult(null, [interval]); + }, + }; + provider2.wrappedJSObject = provider2; + + freebusy.addProvider(provider1); + equal(_countProviders(), 1); + freebusy.addProvider(provider2); + equal(_countProviders(), 2); + freebusy.removeProvider(provider1); + equal(_countProviders(), 1); + equal(_getFirstProvider().id, 2); + + let listener = { + called: false, + onResult(request, result) { + equal(result.length, 1); + equal(result[0].interval.start.icalString, "20120101T010101"); + equal(result[0].interval.end.icalString, "20120102T010101"); + equal(result[0].freeBusyType, Ci.calIFreeBusyInterval.BUSY); + + equal(result.length, 1); + ok(provider2.called); + do_test_finished(); + }, + }; + + do_test_pending(); + freebusy.getFreeBusyIntervals( + "email", + cal.createDateTime("20120101T010101"), + cal.createDateTime("20120102T010101"), + Ci.calIFreeBusyInterval.BUSY_ALL, + listener + ); +} + +function test_noproviders() { + _clearProviders(); + + let listener = { + onResult(request, result) { + ok(!this.called); + equal(result.length, 0); + equal(request.status, 0); + do_test_finished(); + }, + }; + + do_test_pending(); + freebusy.getFreeBusyIntervals( + "email", + cal.createDateTime("20120101T010101"), + cal.createDateTime("20120102T010101"), + Ci.calIFreeBusyInterval.BUSY_ALL, + listener + ); +} + +function test_failure() { + _clearProviders(); + + let provider = { + called: false, + getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) { + ok(!this.called); + this.called = true; + aListener.onResult({ status: Cr.NS_ERROR_FAILURE }, "notFound"); + }, + }; + + let listener = { + onResult(request, result) { + ok(!this.called); + equal(result.length, 0); + equal(request.status, 0); + ok(provider.called); + do_test_finished(); + }, + }; + + freebusy.addProvider(provider); + + do_test_pending(); + freebusy.getFreeBusyIntervals( + "email", + cal.createDateTime("20120101T010101"), + cal.createDateTime("20120102T010101"), + Ci.calIFreeBusyInterval.BUSY_ALL, + listener + ); +} + +function test_cancel() { + _clearProviders(); + + let provider = { + QueryInterface: ChromeUtils.generateQI(["calIFreeBusyProvider", "calIOperation"]), + getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) { + Services.tm.currentThread.dispatch( + { + run() { + dump("Cancelling freebusy query..."); + operation.cancel(); + }, + }, + Ci.nsIEventTarget.DISPATCH_NORMAL + ); + + // No listener call, we emulate a long running search + // Do return the operation though + return this; + }, + + isPending: true, + cancelCalled: false, + status: Cr.NS_OK, + cancel() { + this.cancelCalled = true; + }, + }; + + let listener = { + called: false, + onResult(request, result) { + equal(result, null); + + // If an exception occurs, the operation is not added to the opgroup + ok(!provider.cancelCalled); + do_test_finished(); + }, + }; + + freebusy.addProvider(provider); + + do_test_pending(); + let operation = freebusy.getFreeBusyIntervals( + "email", + cal.createDateTime("20120101T010101"), + cal.createDateTime("20120102T010101"), + Ci.calIFreeBusyInterval.BUSY_ALL, + listener + ); +} + +// The following functions are not in the interface description and probably +// don't need to be. Make assumptions about the implementation instead. + +function _clearProviders() { + freebusy.wrappedJSObject.mProviders = new Set(); +} + +function _countProviders() { + return freebusy.wrappedJSObject.mProviders.size; +} + +function _getFirstProvider() { + return [...freebusy.wrappedJSObject.mProviders][0].wrappedJSObject; +} diff --git a/comm/calendar/test/unit/test_hashedarray.js b/comm/calendar/test/unit/test_hashedarray.js new file mode 100644 index 0000000000..bd1fae68ad --- /dev/null +++ b/comm/calendar/test/unit/test_hashedarray.js @@ -0,0 +1,210 @@ +/* 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/calHashedArray.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + test_array_base(); + test_array_sorted(); + test_hashAccessor(); +} + +/** + * Helper function to create an item that has a sensible hash id, with the given + * title identification. + * + * @param ident The title to identify the item. + * @returns The created item. + */ +function hashedCreateItem(ident) { + let item = new CalEvent(); + item.calendar = { id: "test" }; + item.id = cal.getUUID(); + item.title = ident; + return item; +} + +/** + * Comparator function to sort the items by their title + * + * @param a Object to compare. + * @param b Object to compare with. + * @returns 0, -1, or 1 (usual comptor meanings) + */ +function titleComptor(a, b) { + if (a.title > b.title) { + return 1; + } else if (a.title < b.title) { + return -1; + } + return 0; +} + +/** + * Checks if the hashed array accessor functions work for the status of the + * items array. + * + * @param har The Hashed Array + * @param testItems The array of test items + * @param itemAccessor The accessor func to retrieve the items + * @throws Exception If the arrays are not the same. + */ +function checkConsistancy(har, testItems, itemAccessor) { + itemAccessor = + itemAccessor || + function (item) { + return item; + }; + for (let idx in testItems) { + let testItem = itemAccessor(testItems[idx]); + equal(itemAccessor(har.itemByIndex(idx)).title, testItem.title); + equal(itemAccessor(har.itemById(testItem.hashId)).title, testItem.title); + equal(har.indexOf(testItems[idx]), idx); + } +} + +/** + * Man, this function is really hard to keep general enough, I'm almost tempted + * to duplicate the code. It checks if the remove and modify operations work for + * the given hashed array. + * + * @param har The Hashed Array + * @param testItems The js array with the items + * @param postprocessFunc (optional) The function to call after each + * operation, but before checking consistency. + * @param itemAccessor (optional) The function to access the item for an + * array element. + * @param itemCreator (optional) Function to create a new item for the + * array. + */ +function testRemoveModify(har, testItems, postprocessFunc, itemAccessor, itemCreator) { + postprocessFunc = + postprocessFunc || + function (a, b) { + return [a, b]; + }; + itemCreator = itemCreator || (title => hashedCreateItem(title)); + itemAccessor = + itemAccessor || + function (item) { + return item; + }; + + // Now, delete the second item and check again + har.removeById(itemAccessor(testItems[1]).hashId); + testItems.splice(1, 1); + [har, testItems] = postprocessFunc(har, testItems); + + checkConsistancy(har, testItems, itemAccessor); + + // Try the same by index + har.removeByIndex(2); + testItems.splice(2, 1); + [har, testItems] = postprocessFunc(har, testItems); + checkConsistancy(har, testItems, itemAccessor); + + // Try modifying an item + let newInstance = itemCreator("z-changed"); + itemAccessor(newInstance).id = itemAccessor(testItems[0]).id; + testItems[0] = newInstance; + har.modifyItem(newInstance); + [har, testItems] = postprocessFunc(har, testItems); + checkConsistancy(har, testItems, itemAccessor); +} + +/** + * Tests the basic cal.HashedArray + */ +function test_array_base() { + let har, testItems; + + // Test normal additions + har = new cal.HashedArray(); + testItems = ["a", "b", "c", "d"].map(hashedCreateItem); + + testItems.forEach(har.addItem, har); + checkConsistancy(har, testItems); + testRemoveModify(har, testItems); + + // Test adding in batch mode + har = new cal.HashedArray(); + testItems = ["e", "f", "g", "h"].map(hashedCreateItem); + har.startBatch(); + testItems.forEach(har.addItem, har); + har.endBatch(); + checkConsistancy(har, testItems); + testRemoveModify(har, testItems); +} + +/** + * Tests the sorted cal.SortedHashedArray + */ +function test_array_sorted() { + let har, testItems, testItemsSorted; + + function sortedPostProcess(harParam, tiParam) { + tiParam = tiParam.sort(titleComptor); + return [harParam, tiParam]; + } + + // Test normal additions + har = new cal.SortedHashedArray(titleComptor); + testItems = ["d", "c", "a", "b"].map(hashedCreateItem); + testItemsSorted = testItems.sort(titleComptor); + + testItems.forEach(har.addItem, har); + checkConsistancy(har, testItemsSorted); + testRemoveModify(har, testItemsSorted, sortedPostProcess); + + // Test adding in batch mode + har = new cal.SortedHashedArray(titleComptor); + testItems = ["e", "f", "g", "h"].map(hashedCreateItem); + testItemsSorted = testItems.sort(titleComptor); + har.startBatch(); + testItems.forEach(har.addItem, har); + har.endBatch(); + checkConsistancy(har, testItemsSorted); + testRemoveModify(har, testItemsSorted, sortedPostProcess); +} + +/** + * Tests cal.SortedHashedArray with a custom hashAccessor. + */ +function test_hashAccessor() { + let har, testItems, testItemsSorted; + let comptor = (a, b) => titleComptor(a.item, b.item); + + har = new cal.SortedHashedArray(comptor); + har.hashAccessor = function (obj) { + return obj.item.hashId; + }; + + function itemAccessor(obj) { + if (!obj) { + do_throw("WTF?"); + } + return obj.item; + } + + function itemCreator(title) { + return { item: hashedCreateItem(title) }; + } + + function sortedPostProcess(harParam, tiParam) { + tiParam = tiParam.sort(comptor); + return [harParam, tiParam]; + } + + testItems = ["d", "c", "a", "b"].map(itemCreator); + + testItemsSorted = testItems.sort(comptor); + testItems.forEach(har.addItem, har); + checkConsistancy(har, testItemsSorted, itemAccessor); + testRemoveModify(har, testItemsSorted, sortedPostProcess, itemAccessor, itemCreator); +} diff --git a/comm/calendar/test/unit/test_ics.js b/comm/calendar/test/unit/test_ics.js new file mode 100644 index 0000000000..e63361fc35 --- /dev/null +++ b/comm/calendar/test/unit/test_ics.js @@ -0,0 +1,235 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalRelation: "resource:///modules/CalRelation.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +function run_test() { + test_folding(); + test_icalProps(); + test_roundtrip(); + test_duration(); + test_serialize(); +} + +var test_data = [ + { + expectedDateProps: { + month: 10, + day: 25, + year: 2004, + isDate: true, + }, + expectedProps: { + title: "Christmas", + id: "20041119T052239Z-1000472-1-5c0746bb-Oracle", + priority: 0, + status: "CONFIRMED", + }, + ics: + "BEGIN:VCALENDAR\n" + + "PRODID:-//ORACLE//NONSGML CSDK 9.0.5 - CalDAVServlet 9.0.5//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VEVENT\n" + + "UID:20041119T052239Z-1000472-1-5c0746bb-Oracle\n" + + "ORGANIZER;X-ORACLE-GUID=E9359406791C763EE0305794071A39A4;CN=Simon Vaillan\n" + + " court:mailto:simon.vaillancourt@oracle.com\n" + + "SEQUENCE:0\n" + + "DTSTAMP:20041124T010028Z\n" + + "CREATED:20041119T052239Z\n" + + "X-ORACLE-EVENTINSTANCE-GUID:I1+16778354+1+1+438153759\n" + + "X-ORACLE-EVENT-GUID:E1+16778354+1+438153759\n" + + "X-ORACLE-EVENTTYPE:DAY EVENT\n" + + "TRANSP:TRANSPARENT\n" + + "SUMMARY:Christmas\n" + + "STATUS:CONFIRMED\n" + + "PRIORITY:0\n" + + "DTSTART;VALUE=DATE:20041125\n" + + "DTEND;VALUE=DATE:20041125\n" + + "CLASS:PUBLIC\n" + + "ATTENDEE;X-ORACLE-GUID=E92F51FB4A48E91CE0305794071A149C;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=James Stevens;PARTSTAT=NEEDS-ACTION:mailto:james.stevens@o\n" + + " racle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E9359406791C763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=FALSE;CN=Simon Vaillancourt;PARTSTAT=ACCEPTED:mailto:simon.vaillan\n" + + " court@oracle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E9359406791D763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Bernard Desruisseaux;PARTSTAT=NEEDS-ACTION:mailto:bernard.\n" + + " desruisseaux@oracle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E9359406791E763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Mario Bonin;PARTSTAT=NEEDS-ACTION:mailto:mario.bonin@oracl\n" + + " e.com\n" + + "ATTENDEE;X-ORACLE-GUID=E9359406791F763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Jeremy Chone;PARTSTAT=NEEDS-ACTION:mailto:jeremy.chone@ora\n" + + " cle.com\n" + + "ATTENDEE;X-ORACLE-PERSONAL-COMMENT-ISDIRTY=TRUE;X-ORACLE-GUID=E9359406792\n" + + " 0763EE0305794071A39A4;CUTYPE=INDIVIDUAL;RSVP=TRUE;CN=Mike Shaver;PARTSTA\n" + + " T=NEEDS-ACTION:mailto:mike.x.shaver@oracle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067921763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=David Ball;PARTSTAT=NEEDS-ACTION:mailto:david.ball@oracle.\n" + + " com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067922763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Marten Haring;PARTSTAT=NEEDS-ACTION:mailto:marten.den.hari\n" + + " ng@oracle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067923763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Peter Egyed;PARTSTAT=NEEDS-ACTION:mailto:peter.egyed@oracl\n" + + " e.com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067924763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Francois Perrault;PARTSTAT=NEEDS-ACTION:mailto:francois.pe\n" + + " rrault@oracle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067925763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Vladimir Vukicevic;PARTSTAT=NEEDS-ACTION:mailto:vladimir.v\n" + + " ukicevic@oracle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067926763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Cyrus Daboo;PARTSTAT=NEEDS-ACTION:mailto:daboo@isamet.com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067927763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Lisa Dusseault;PARTSTAT=NEEDS-ACTION:mailto:lisa@osafounda\n" + + " tion.org\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067928763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Dan Mosedale;PARTSTAT=NEEDS-ACTION:mailto:dan.mosedale@ora\n" + + " cle.com\n" + + "ATTENDEE;X-ORACLE-GUID=E93594067929763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" + + " ;RSVP=TRUE;CN=Stuart Parmenter;PARTSTAT=NEEDS-ACTION:mailto:stuart.parme\n" + + " nter@oracle.com\n" + + "END:VEVENT\n" + + "END:VCALENDAR\n", + }, + { + expectedProps: { "x-magic": "mymagicstring" }, + ics: + "BEGIN:VEVENT\n" + + "UID:1\n" + + "DTSTART:20070521T100000Z\n" + + "X-MAGIC:mymagicstring\n" + + "END:VEVENT", + }, +]; + +function test_roundtrip() { + function checkEvent(data, event) { + checkRoundtrip(data.expectedProps, event); + + // Checking dates + if ("expectedDateProps" in data) { + checkProps(data.expectedDateProps, event.startDate); + checkProps(data.expectedDateProps, event.endDate); + } + } + + for (let data of test_data) { + // First round, use the icalString setter which uses synchronous parsing + dump("Checking" + data.ics + "\n"); + let event = createEventFromIcalString(data.ics); + checkEvent(data, event); + + // Now, try the same thing with asynchronous parsing. We need a copy of + // the data variable, otherwise javascript will mix the data between + // foreach loop iterations. + do_test_pending(); + let thisdata = data; + cal.icsService.parseICSAsync(data.ics, { + onParsingComplete(rc, rootComp) { + try { + ok(Components.isSuccessCode(rc)); + let event2 = new CalEvent(); + event2.icalComponent = rootComp; + checkEvent(thisdata, event2); + do_test_finished(); + } catch (e) { + do_throw(e + "\n"); + do_test_finished(); + } + }, + }); + } +} + +function test_folding() { + // check folding + const id = + "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-id-provoking-folding"; + let todo = new CalTodo(), + todo_ = new CalTodo(); + todo.id = id; + todo_.icalString = todo.icalString; + equal(todo.id, todo_.id); + equal(todo_.icalComponent.getFirstProperty("UID").value, id); +} + +function test_icalProps() { + checkIcalProp("ATTACH", new CalAttachment()); + checkIcalProp("ATTENDEE", new CalAttendee()); + checkIcalProp("RELATED-TO", new CalRelation()); +} + +/* + * Helper functions + */ + +function checkIcalProp(aPropName, aObj) { + let prop1 = cal.icsService.createIcalProperty(aPropName); + let prop2 = cal.icsService.createIcalProperty(aPropName); + prop1.value = "foo"; + prop2.value = "bar"; + prop1.setParameter("X-FOO", "BAR"); + + if (aObj.setParameter) { + aObj.icalProperty = prop1; + equal(aObj.getParameter("X-FOO"), "BAR"); + aObj.icalProperty = prop2; + equal(aObj.getParameter("X-FOO"), null); + } else if (aObj.setProperty) { + aObj.icalProperty = prop1; + equal(aObj.getProperty("X-FOO"), "BAR"); + aObj.icalProperty = prop2; + equal(aObj.getProperty("X-FOO"), null); + } +} + +function checkProps(expectedProps, obj) { + for (let key in expectedProps) { + equal(obj[key], expectedProps[key]); + } +} + +function checkRoundtrip(expectedProps, obj) { + let icsdata = obj.icalString; + for (let key in expectedProps) { + // Need translation + let icskey = key; + switch (key) { + case "id": + icskey = "uid"; + break; + case "title": + icskey = "summary"; + break; + } + ok(icsdata.includes(icskey.toUpperCase())); + ok(icsdata.includes(expectedProps[key])); + } +} + +function test_duration() { + let e = new CalEvent(); + e.startDate = cal.createDateTime(); + e.endDate = null; + equal(e.duration.icalString, "PT0S"); +} + +function test_serialize() { + let e = new CalEvent(); + let prop = cal.icsService.createIcalComponent("VTODO"); + + throws(() => { + e.icalComponent = prop; + }, /Illegal value/); +} diff --git a/comm/calendar/test/unit/test_ics_parser.js b/comm/calendar/test/unit/test_ics_parser.js new file mode 100644 index 0000000000..cd53823935 --- /dev/null +++ b/comm/calendar/test/unit/test_ics_parser.js @@ -0,0 +1,220 @@ +/* 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/. */ + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_roundtrip(); + test_async(); + test_failures(); + test_fake_parent(); + test_props_comps(); + test_timezone(); +} + +function test_props_comps() { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + let str = [ + "BEGIN:VCALENDAR", + "X-WR-CALNAME:CALNAME", + "BEGIN:VJOURNAL", + "LOCATION:BEFORE TIME", + "END:VJOURNAL", + "BEGIN:VEVENT", + "UID:123", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + parser.parseString(str); + + let props = parser.getProperties(); + equal(props.length, 1); + equal(props[0].propertyName, "X-WR-CALNAME"); + equal(props[0].value, "CALNAME"); + + let comps = parser.getComponents(); + equal(comps.length, 1); + equal(comps[0].componentType, "VJOURNAL"); + equal(comps[0].location, "BEFORE TIME"); +} + +function test_failures() { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + + do_test_pending(); + parser.parseString("BOGUS", { + onParsingComplete(rc, opparser) { + dump("Note: The previous error message is expected ^^\n"); + equal(rc, Cr.NS_ERROR_FAILURE); + do_test_finished(); + }, + }); + + // No real error here, but there is a message... + parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + let str = ["BEGIN:VWORLD", "BEGIN:VEVENT", "UID:123", "END:VEVENT", "END:VWORLD"].join("\r\n"); + dump("Note: The following error message is expected:\n"); + parser.parseString(str); + equal(parser.getComponents().length, 0); + equal(parser.getItems().length, 0); +} + +function test_fake_parent() { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + + let str = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "UID:123", + "RECURRENCE-ID:20120101T010101", + "DTSTART:20120101T010102", + "LOCATION:HELL", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + + parser.parseString(str); + + let items = parser.getItems(); + equal(items.length, 1); + let item = items[0].QueryInterface(Ci.calIEvent); + + equal(item.id, "123"); + ok(!!item.recurrenceInfo); + equal(item.startDate.icalString, "20120101T010101"); + equal(item.getProperty("X-MOZ-FAKED-MASTER"), "1"); + + let rinfo = item.recurrenceInfo; + + equal(rinfo.countRecurrenceItems(), 1); + let excs = rinfo.getOccurrences(cal.createDateTime("20120101T010101"), null, 0); + equal(excs.length, 1); + let exc = excs[0].QueryInterface(Ci.calIEvent); + equal(exc.startDate.icalString, "20120101T010102"); + + equal(parser.getParentlessItems()[0], exc); +} + +function test_async() { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + let str = [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:1", + "DTSTART:20120101T010101", + "DUE:20120101T010102", + "END:VTODO", + "BEGIN:VTODO", + "UID:2", + "DTSTART:20120101T010103", + "DUE:20120101T010104", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n"); + + do_test_pending(); + parser.parseString(str, { + onParsingComplete(rc, opparser) { + let items = parser.getItems(); + equal(items.length, 2); + let item = items[0]; + ok(item.isTodo()); + + equal(item.entryDate.icalString, "20120101T010101"); + equal(item.dueDate.icalString, "20120101T010102"); + + item = items[1]; + ok(item.isTodo()); + + equal(item.entryDate.icalString, "20120101T010103"); + equal(item.dueDate.icalString, "20120101T010104"); + + do_test_finished(); + }, + }); +} + +function test_timezone() { + // TODO +} + +function test_roundtrip() { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + let str = [ + "BEGIN:VCALENDAR", + "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN", + "VERSION:2.0", + "X-PROP:VAL", + "BEGIN:VTODO", + "UID:1", + "DTSTART:20120101T010101", + "DUE:20120101T010102", + "END:VTODO", + "BEGIN:VJOURNAL", + "LOCATION:BEFORE TIME", + "END:VJOURNAL", + "END:VCALENDAR", + "", + ].join("\r\n"); + + parser.parseString(str); + + let items = parser.getItems(); + serializer.addItems(items); + + parser.getProperties().forEach(serializer.addProperty, serializer); + parser.getComponents().forEach(serializer.addComponent, serializer); + + equal( + serializer.serializeToString().split("\r\n").sort().join("\r\n"), + str.split("\r\n").sort().join("\r\n") + ); + + // Test parseFromStream + parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + let stream = serializer.serializeToInputStream(); + + parser.parseFromStream(stream); + + items = parser.getItems(); + let comps = parser.getComponents(); + let props = parser.getProperties(); + equal(items.length, 1); + equal(comps.length, 1); + equal(props.length, 1); + + let everything = items[0].icalString + .split("\r\n") + .concat(comps[0].serializeToICS().split("\r\n")); + everything.push(props[0].icalString.split("\r\n")[0]); + everything.sort(); + + equal(everything.join("\r\n"), str.split("\r\n").concat([""]).sort().join("\r\n")); + + // Test serializeToStream/parseFromStream + parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + let pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe); + pipe.init(true, true, 0, 0, null); + + serializer.serializeToStream(pipe.outputStream); + parser.parseFromStream(pipe.inputStream); + + items = parser.getItems(); + comps = parser.getComponents(); + props = parser.getProperties(); + equal(items.length, 1); + equal(comps.length, 1); + equal(props.length, 1); + + everything = items[0].icalString.split("\r\n").concat(comps[0].serializeToICS().split("\r\n")); + everything.push(props[0].icalString.split("\r\n")[0]); + everything.sort(); + + equal(everything.join("\r\n"), str.split("\r\n").concat([""]).sort().join("\r\n")); +} diff --git a/comm/calendar/test/unit/test_ics_service.js b/comm/calendar/test/unit/test_ics_service.js new file mode 100644 index 0000000000..1b69802406 --- /dev/null +++ b/comm/calendar/test/unit/test_ics_service.js @@ -0,0 +1,289 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalRelation: "resource:///modules/CalRelation.jsm", +}); + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_iterator(); + test_icalcomponent(); + test_icsservice(); + test_icalstring(); + test_param(); + test_icalproperty(); +} + +function test_icalstring() { + function checkComp(createFunc, icalString, members, properties) { + let thing = createFunc(icalString); + equal(ics_unfoldline(thing.icalString), icalString + "\r\n"); + + if (members) { + for (let k in members) { + equal(thing[k], members[k]); + } + } + + if (properties) { + for (let k in properties) { + if ("getParameter" in thing) { + equal(thing.getParameter(k), properties[k]); + } else if ("getProperty" in thing) { + equal(thing.getProperty(k), properties[k]); + } + } + } + return thing; + } + + let attach = checkComp( + icalString => new CalAttachment(icalString), + "ATTACH;ENCODING=BASE64;FMTTYPE=text/calendar;FILENAME=test.ics:http://example.com/test.ics", + { formatType: "text/calendar", encoding: "BASE64" }, + { FILENAME: "test.ics" } + ); + equal(attach.uri.spec, "http://example.com/test.ics"); + + checkComp( + icalString => new CalAttendee(icalString), + "ATTENDEE;RSVP=TRUE;CN=Name;PARTSTAT=ACCEPTED;CUTYPE=RESOURCE;ROLE=REQ-PARTICIPANT;X-THING=BAR:mailto:test@example.com", + { + id: "mailto:test@example.com", + commonName: "Name", + rsvp: "TRUE", + isOrganizer: false, + role: "REQ-PARTICIPANT", + participationStatus: "ACCEPTED", + userType: "RESOURCE", + }, + { "X-THING": "BAR" } + ); + + checkComp( + icalString => new CalRelation(icalString), + "RELATED-TO;RELTYPE=SIBLING;FOO=BAR:VALUE", + { relType: "SIBLING", relId: "VALUE" }, + { FOO: "BAR" } + ); + + let rrule = checkComp( + cal.createRecurrenceRule.bind(cal), + "RRULE:FREQ=WEEKLY;COUNT=5;INTERVAL=2;BYDAY=MO", + { count: 5, isByCount: true, type: "WEEKLY", interval: 2 } + ); + equal(rrule.getComponent("BYDAY").toString(), [2].toString()); + + let rdate = checkComp(cal.createRecurrenceDate.bind(cal), "RDATE:20120101T000000", { + isNegative: false, + }); + equal(rdate.date.compare(cal.createDateTime("20120101T000000")), 0); + + /* TODO consider removing period support, ics throws badarg + let rdateperiod = checkComp(cal.createRecurrenceDate.bind(cal), + "RDATE;VALUE=PERIOD;20120101T000000Z/20120102T000000Z"); + equal(rdate.date.compare(cal.createDateTime("20120101T000000Z")), 0); + */ + + let exdate = checkComp(cal.createRecurrenceDate.bind(cal), "EXDATE:20120101T000000", { + isNegative: true, + }); + equal(exdate.date.compare(cal.createDateTime("20120101T000000")), 0); +} + +function test_icsservice() { + function checkProp(createFunc, icalString, members, parameters) { + let thing = createFunc(icalString); + equal(ics_unfoldline(thing.icalString), icalString + "\r\n"); + + for (let k in members) { + equal(thing[k], members[k]); + } + + for (let k in parameters) { + equal(thing.getParameter(k), parameters[k]); + } + return thing; + } + + // Test ::createIcalPropertyFromString + checkProp( + cal.icsService.createIcalPropertyFromString.bind(cal.icsService), + "ATTACH;ENCODING=BASE64;FMTTYPE=text/calendar;FILENAME=test.ics:http://example.com/test.ics", + { value: "http://example.com/test.ics", propertyName: "ATTACH" }, + { ENCODING: "BASE64", FMTTYPE: "text/calendar", FILENAME: "test.ics" } + ); + + checkProp( + cal.icsService.createIcalPropertyFromString.bind(cal.icsService), + "DESCRIPTION:new\\nlines\\nare\\ngreat\\,eh?", + { + value: "new\nlines\nare\ngreat,eh?", + valueAsIcalString: "new\\nlines\\nare\\ngreat\\,eh?", + }, + {} + ); + + // Test ::createIcalProperty + let attach2 = cal.icsService.createIcalProperty("ATTACH"); + equal(attach2.propertyName, "ATTACH"); + attach2.value = "http://example.com/"; + equal(attach2.icalString, "ATTACH:http://example.com/\r\n"); +} + +function test_icalproperty() { + let comp = cal.icsService.createIcalComponent("VEVENT"); + let prop = cal.icsService.createIcalProperty("PROP"); + prop.value = "VAL"; + + comp.addProperty(prop); + equal(prop.parent.toString(), comp.toString()); + equal(prop.valueAsDatetime, null); + + prop = cal.icsService.createIcalProperty("DESCRIPTION"); + prop.value = "A\nB"; + equal(prop.value, "A\nB"); + equal(prop.valueAsIcalString, "A\\nB"); + equal(prop.valueAsDatetime, null); + + prop = cal.icsService.createIcalProperty("DESCRIPTION"); + prop.valueAsIcalString = "A\\nB"; + equal(prop.value, "A\nB"); + equal(prop.valueAsIcalString, "A\\nB"); + equal(prop.valueAsDatetime, null); + + prop = cal.icsService.createIcalProperty("DESCRIPTION"); + prop.value = "A\\nB"; + equal(prop.value, "A\\nB"); + equal(prop.valueAsIcalString, "A\\\\nB"); + equal(prop.valueAsDatetime, null); + + prop = cal.icsService.createIcalProperty("GEO"); + prop.value = "43.4913662534171;12.085559129715"; + equal(prop.value, "43.4913662534171;12.085559129715"); + equal(prop.valueAsIcalString, "43.4913662534171;12.085559129715"); +} + +function test_icalcomponent() { + let event = cal.icsService.createIcalComponent("VEVENT"); + let alarm = cal.icsService.createIcalComponent("VALARM"); + event.addSubcomponent(alarm); + + // Check that the parent works and does not appear on cloned instances + let alarm2 = alarm.clone(); + equal(alarm.parent.toString(), event.toString()); + equal(alarm2.parent, null); + + function check_getset(key, value) { + dump("Checking " + key + " = " + value + "\n"); + event[key] = value; + let valuestring = value.icalString || value; + equal(event[key].icalString || event[key], valuestring); + equal(event.serializeToICS().match(new RegExp(valuestring, "g")).length, 1); + event[key] = value; + equal(event.serializeToICS().match(new RegExp(valuestring, "g")).length, 1); + } + + let props = [ + ["uid", "123"], + ["prodid", "//abc/123"], + ["version", "2.0"], + ["method", "REQUEST"], + ["status", "TENTATIVE"], + ["summary", "sum"], + ["description", "descr"], + ["location", "here"], + ["categories", "cat"], + ["URL", "url"], + ["priority", 5], + ["startTime", cal.createDateTime("20120101T010101")], + ["endTime", cal.createDateTime("20120101T010102")], + /* TODO readonly, how to set... ["duration", cal.createDuration("PT2S")], */ + ["dueTime", cal.createDateTime("20120101T010103")], + ["stampTime", cal.createDateTime("20120101T010104")], + ["createdTime", cal.createDateTime("20120101T010105")], + ["completedTime", cal.createDateTime("20120101T010106")], + ["lastModified", cal.createDateTime("20120101T010107")], + ["recurrenceId", cal.createDateTime("20120101T010108")], + ]; + + for (let prop of props) { + check_getset(...prop); + } +} + +function test_param() { + let prop = cal.icsService.createIcalProperty("DTSTART"); + prop.value = "20120101T010101"; + equal(prop.icalString, "DTSTART:20120101T010101\r\n"); + prop.setParameter("VALUE", "TEXT"); + equal(prop.icalString, "DTSTART;VALUE=TEXT:20120101T010101\r\n"); + prop.removeParameter("VALUE"); + equal(prop.icalString, "DTSTART:20120101T010101\r\n"); + + prop.setParameter("X-FOO", "BAR"); + equal(prop.icalString, "DTSTART;X-FOO=BAR:20120101T010101\r\n"); + prop.removeParameter("X-FOO", "BAR"); + equal(prop.icalString, "DTSTART:20120101T010101\r\n"); +} + +function test_iterator() { + // Property iterator + let comp = cal.icsService.createIcalComponent("VEVENT"); + let propNames = ["X-ONE", "X-TWO"]; + for (let i = 0; i < propNames.length; i++) { + let prop = cal.icsService.createIcalProperty(propNames[i]); + prop.value = "" + (i + 1); + comp.addProperty(prop); + } + + for (let prop = comp.getFirstProperty("ANY"); prop; prop = comp.getNextProperty("ANY")) { + equal(prop.propertyName, propNames.shift()); + equal(prop.parent.toString(), comp.toString()); + } + propNames = ["X-ONE", "X-TWO"]; + for (let prop = comp.getNextProperty("ANY"); prop; prop = comp.getNextProperty("ANY")) { + equal(prop.propertyName, propNames.shift()); + equal(prop.parent.toString(), comp.toString()); + } + + // Property iterator with multiple values + // eslint-disable-next-line no-useless-concat + comp = cal.icsService.parseICS("BEGIN:VEVENT\r\n" + "CATEGORIES:a,b,c\r\n" + "END:VEVENT"); + let propValues = ["a", "b", "c"]; + for ( + let prop = comp.getFirstProperty("CATEGORIES"); + prop; + prop = comp.getNextProperty("CATEGORIES") + ) { + equal(prop.propertyName, "CATEGORIES"); + equal(prop.value, propValues.shift()); + equal(prop.parent.toString(), comp.toString()); + } + + // Param iterator + let dtstart = cal.icsService.createIcalProperty("DTSTART"); + let params = ["X-ONE", "X-TWO"]; + for (let i = 0; i < params.length; i++) { + dtstart.setParameter(params[i], "" + (i + 1)); + } + + for (let prop = dtstart.getFirstParameterName(); prop; prop = dtstart.getNextParameterName()) { + equal(prop, params.shift()); + } + + // Now try again, but start with next. Should act like first + params = ["X-ONE", "X-TWO"]; + for (let param = dtstart.getNextParameterName(); param; param = dtstart.getNextParameterName()) { + equal(param, params.shift()); + } +} diff --git a/comm/calendar/test/unit/test_imip.js b/comm/calendar/test/unit/test_imip.js new file mode 100644 index 0000000000..ba9e5f5c6b --- /dev/null +++ b/comm/calendar/test/unit/test_imip.js @@ -0,0 +1,47 @@ +/* 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 { CalItipEmailTransport } = ChromeUtils.import("resource:///modules/CalItipEmailTransport.jsm"); + +function itipItemForTest(title, seq) { + let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); + itipItem.init( + [ + "BEGIN:VCALENDAR", + "METHOD:REQUEST", + "BEGIN:VEVENT", + "SUMMARY:" + title, + "SEQUENCE:" + (seq || 0), + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n") + ); + return itipItem; +} + +let transport = new CalItipEmailTransport(); + +add_task(function test_title_in_subject() { + Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", false); + let items = transport._prepareItems(itipItemForTest("foo")); + equal(items.subject, "foo"); +}); + +add_task(function test_title_in_summary() { + Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", true); + let items = transport._prepareItems(itipItemForTest("bar")); + equal(items.subject, "Invitation: bar"); +}); + +add_task(function test_updated_title_in_subject() { + Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", false); + let items = transport._prepareItems(itipItemForTest("foo", 2)); + equal(items.subject, "foo"); +}); + +add_task(function test_updated_title_in_summary() { + Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", true); + let items = transport._prepareItems(itipItemForTest("bar", 2)); + equal(items.subject, "Updated: bar"); +}); diff --git a/comm/calendar/test/unit/test_invitationutils.js b/comm/calendar/test/unit/test_invitationutils.js new file mode 100644 index 0000000000..223ff1a6e6 --- /dev/null +++ b/comm/calendar/test/unit/test_invitationutils.js @@ -0,0 +1,1654 @@ +/* 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 { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", +}); + +function run_test() { + do_calendar_startup(run_next_test); +} + +// tests for calInvitationUtils.jsm + +// Make sure that the Europe/Berlin timezone and long datetime format is set. +Services.prefs.setIntPref("calendar.date.format", 0); +Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin"); + +/** + * typedef {Object} FullIcsValue + * + * @property {Object<string, string>} params - Parameters for the ics property, + * mapping from the parameter name to its value. Each name should be in camel + * case. For example, to set "PARTSTAT=ACCEPTED" on the "attendee" property, + * use `{ partstat: "ACCEPTED" }`. + * @property {string} value - The property value. + */ + +/** + * An accepted property value. + * typedef {(FullIcsValue|string)} IcsValue + */ + +/** + * Get a ics string for an event. + * + * @param {Object<string, (IcsValue | IcsValue[])>} [eventProperties] - Object + * used to set the event properties, mapping from the ics property name to its + * value. The property name should be in camel case, so "propertyName" should + * be used for the "PROPERTY-NAME" property. The value can either be a single + * IcsValue, or a IcsValue array if you want more than one such property + * in the event (e.g. to set several "attendee" properties). If you give an + * empty value for the property, then the property will be excluded. + * For the "attendee" and "organizer" properties, "mailto:" will be prefixed + * to the value (unless it is empty). + * For the "dtstart" and "dtend" properties, the "TZID=Europe/Berlin" + * parameter will be set by default. + * Some properties will have default values set if they are not specified in + * the object. Note that to avoid a property with a default value, you must + * pass an empty value for the property. + * + * @returns {string} - The ics string. + */ +function getIcs(eventProperties) { + // we use an unfolded ics blueprint here to make replacing of properties easier + let item = ["BEGIN:VCALENDAR", "PRODID:-//Google Inc//Google Calendar V1.0//EN", "VERSION:2.0"]; + + let eventPropertyNames = eventProperties ? Object.keys(eventProperties) : []; + + // Convert camel case object property name to upper case with dashes. + let convertPropertyName = n => n.replace(/[A-Z]/, match => `-${match}`).toUpperCase(); + + let propertyToString = (name, value) => { + let propertyString = convertPropertyName(name); + let setTzid = false; + if (typeof value == "object") { + for (let paramName in value.params) { + if (paramName == "tzid") { + setTzid = true; + } + propertyString += `;${convertPropertyName(paramName)}=${value.params[paramName]}`; + } + value = value.value; + } + if (!setTzid && (name == "dtstart" || name == "dtend")) { + propertyString += ";TZID=Europe/Berlin"; + } + if (name == "organizer" || name == "attendee") { + value = `mailto:${value}`; + } + return `${propertyString}:${value}`; + }; + + let appendProperty = (name, value) => { + if (!value) { + // leave out. + return; + } + if (Array.isArray(value)) { + value.forEach(val => item.push(propertyToString(name, val))); + } else { + item.push(propertyToString(name, value)); + } + }; + + let appendPropertyWithDefault = (name, defaultValue) => { + let value = defaultValue; + let index = eventPropertyNames.findIndex(n => n == name); + if (index >= 0) { + value = eventProperties[name]; + // Remove the name to show that we have already handled it. + eventPropertyNames.splice(index, 1); + } + appendProperty(name, value); + }; + + appendPropertyWithDefault("method", "METHOD:REQUEST"); + + item = item.concat([ + "BEGIN:VTIMEZONE", + "TZID:Europe/Berlin", + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:+0100", + "TZOFFSETTO:+0200", + "TZNAME:CEST", + "DTSTART:19700329T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:+0200", + "TZOFFSETTO:+0100", + "TZNAME:CET", + "DTSTART:19701025T030000", + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", + "END:STANDARD", + "END:VTIMEZONE", + "BEGIN:VEVENT", + ]); + + for (let [name, defaultValue] of [ + ["created", "20150909T180909Z"], + ["lastModified", "20150909T181048Z"], + ["dtstamp", "20150909T181048Z"], + ["uid", "cb189fdc-ed47-4db6-a8d7-31a08802249d"], + ["summary", "Test Event"], + [ + "organizer", + { + params: { rsvp: "TRUE", cn: "Organizer", partstat: "ACCEPTED", role: "CHAIR" }, + value: "organizer@example.net", + }, + ], + [ + "attendee", + { + params: { rsvp: "TRUE", cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + ], + ["dtstart", "20150909T210000"], + ["dtend", "20150909T220000"], + ["sequence", "1"], + ["transp", "OPAQUE"], + ["location", "Room 1"], + ["description", "Let us get together"], + ["url", "http://www.example.com"], + ["attach", "http://www.example.com"], + ]) { + appendPropertyWithDefault(name, defaultValue); + } + + // Add other properties with no default. + for (let name of eventPropertyNames) { + appendProperty(name, eventProperties[name]); + } + + item.push("END:VEVENT"); + item.push("END:VCALENDAR"); + + return item.join("\r\n"); +} + +function getEvent(eventProperties) { + let item = getIcs(eventProperties); + let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); + itipItem.init(item); + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + parser.parseString(item); + return { event: parser.getItems()[0], itipItem }; +} + +add_task(async function getItipHeader_test() { + let data = [ + { + name: "Organizer sends invite", + input: { + method: "REQUEST", + attendee: "", + }, + expected: "Organizer has invited you to Test Event", + }, + { + name: "Organizer cancels event", + input: { + method: "CANCEL", + attendee: "", + }, + expected: "Organizer has canceled this event: Test Event", + }, + { + name: "Organizer declines counter proposal", + input: { + method: "DECLINECOUNTER", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "ACCEPTED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, + }, + expected: 'Organizer has declined your counterproposal for "Test Event".', + }, + { + name: "Attendee makes counter proposal", + input: { + method: "COUNTER", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, + }, + expected: 'Attendee1 <attendee1@example.net> has made a counterproposal for "Test Event":', + }, + { + name: "Attendee replies with acceptance", + input: { + method: "REPLY", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "ACCEPTED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, + }, + expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.", + }, + { + name: "Attendee replies with tentative acceptance", + input: { + method: "REPLY", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "TENTATIVE", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, + }, + expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.", + }, + { + name: "Attendee replies with declined", + input: { + method: "REPLY", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, + }, + expected: "Attendee1 <attendee1@example.net> has declined your event invitation.", + }, + { + name: "Attendee1 accepts and Attendee2 declines", + input: { + method: "REPLY", + attendee: [ + { + params: { + rsvp: "TRUE", + cn: "Attendee1", + partstat: "ACCEPTED", + role: "REQ-PARTICIPANT", + }, + value: "attendee1@example.net", + }, + { + params: { + rsvp: "TRUE", + cn: "Attendee2", + partstat: "DECLINED", + role: "REQ-PARTICIPANT", + }, + value: "attendee2@example.net", + }, + ], + }, + expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.", + }, + { + name: "Unsupported method", + input: { + method: "UNSUPPORTED", + attendee: "", + }, + expected: "Event Invitation", + }, + { + name: "No method", + input: { + method: "", + attendee: "", + }, + expected: "Event Invitation", + }, + ]; + for (let test of data) { + let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); + let item = getIcs(test.input); + itipItem.init(item); + if (test.input.attendee) { + let sender = new CalAttendee(); + sender.icalString = item.match(/^ATTENDEE.*$/m)[0]; + itipItem.sender = sender.id; + } + equal(cal.invitation.getItipHeader(itipItem), test.expected, `(test ${test.name})`); + } +}); + +function assertHiddenRow(node, hidden, testName) { + let row = node.closest("tr"); + ok(row, `Row above ${node.id} should exist (test ${testName})`); + if (hidden) { + equal( + node.textContent, + "", + `Node ${node.id} should be empty below a hidden row (test ${testName})` + ); + ok(row.hidden, `Row above ${node.id} should be hidden (test ${testName})`); + } else { + ok(!row.hidden, `Row above ${node.id} should not be hidden (test ${testName})`); + } +} + +add_task(async function createInvitationOverlay_test() { + let data = [ + { + name: "No description", + input: { description: "" }, + expected: { node: "imipHtml-description-content", hidden: true }, + }, + { + name: "Description with https link", + input: { description: "Go to https://www.example.net if you can." }, + expected: { + node: "imipHtml-description-content", + content: + 'Go to <a class="moz-txt-link-freetext" href="https://www.example.net">' + + "https://www.example.net</a> if you can.", + }, + }, + { + name: "Description plain link", + input: { description: "Go to www.example.net if you can." }, + expected: { + node: "imipHtml-description-content", + content: + 'Go to <a class="moz-txt-link-abbreviated" href="http://www.example.net">' + + "www.example.net</a> if you can.", + }, + }, + { + name: "Description with +/-", + input: { description: "Let's see if +/- still can be displayed." }, + expected: { + node: "imipHtml-description-content", + content: "Let's see if +/- still can be displayed.", + }, + }, + { + name: "Description with mailto", + input: { description: "Or write to mailto:faq@example.net instead." }, + expected: { + node: "imipHtml-description-content", + content: + 'Or write to <a class="moz-txt-link-freetext" ' + + 'href="mailto:faq@example.net">mailto:faq@example.net</a> instead.', + }, + }, + { + name: "Description with email", + input: { description: "Or write to faq@example.net instead." }, + expected: { + node: "imipHtml-description-content", + content: + 'Or write to <a class="moz-txt-link-abbreviated" ' + + 'href="mailto:faq@example.net">faq@example.net</a> instead.', + }, + }, + { + name: "Description with emoticon", + input: { description: "It's up to you ;-)" }, + expected: { + node: "imipHtml-description-content", + content: "It's up to you ;-)", + }, + }, + { + name: "Removed script injection from description", + input: { + description: + 'Let\'s see how evil we can be: <script language="JavaScript">' + + 'document.getElementById("imipHtml-description-content")' + + '.write("Script embedded!")</script>', + }, + expected: { + node: "imipHtml-description-content", + content: "Let's see how evil we can be: ", + }, + }, + { + name: "Removed img src injection from description", + input: { + description: + 'Or we can try: <img src="document.getElementById("imipHtml-' + + 'description-descr").innerText" >', + }, + expected: { + node: "imipHtml-description-content", + content: "Or we can try: ", + }, + }, + { + name: "Description with special characters", + input: { + description: + 'Check <a href="http://example.com">example.com</a> — only 3 €', + }, + expected: { + node: "imipHtml-description-content", + content: 'Check <a href="http://example.com">example.com</a> β only 3 β¬', + }, + }, + { + name: "URL", + input: { url: "http://www.example.org/event.ics" }, + expected: { + node: "imipHtml-url-content", + content: + '<a class="moz-txt-link-freetext" href="http://www.example.org/event.ics">' + + "http://www.example.org/event.ics</a>", + }, + }, + { + name: "URL attachment", + input: { attach: "http://www.example.org" }, + expected: { + node: "imipHtml-attachments-content", + content: + '<a class="moz-txt-link-freetext" href="http://www.example.org/">' + + "http://www.example.org/</a>", + }, + }, + { + name: "Non-URL attachment is ignored", + input: { + attach: { + params: { fmttype: "text/plain", encoding: "BASE64", value: "BINARY" }, + value: "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4", + }, + }, + expected: { node: "imipHtml-attachments-content", hidden: true }, + }, + { + name: "Several attachments", + input: { + attach: [ + "http://www.example.org/first/", + "http://www.example.org/second", + "file:///N:/folder/third.file", + ], + }, + expected: { + node: "imipHtml-attachments-content", + content: + '<a class="moz-txt-link-freetext" href="http://www.example.org/first/">' + + "http://www.example.org/first/</a><br>" + + '<a class="moz-txt-link-freetext" href="http://www.example.org/second">' + + "http://www.example.org/second</a><br>" + + '<a class="moz-txt-link-freetext">file:///N:/folder/third.file</a>', + }, + }, + { + name: "Attendees", + input: { + attendee: [ + { + params: { + rsvp: "TRUE", + partstat: "NEEDS-ACTION", + role: "OPT-PARTICIPANT", + cutype: "INDIVIDUAL", + cn: '"Attendee 1"', + }, + value: "attendee1@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "ACCEPTED", + role: "NON-PARTICIPANT", + cutype: "GROUP", + }, + value: "attendee2@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "TENTATIVE", + role: "REQ-PARTICIPANT", + cutype: "RESOURCE", + }, + value: "attendee3@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "DECLINED", + role: "OPT-PARTICIPANT", + delegatedFrom: '"mailto:attendee5@example.net"', + cutype: "ROOM", + }, + value: "attendee4@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "DELEGATED", + role: "OPT-PARTICIPANT", + delegatedTo: '"mailto:attendee4@example.net"', + cutype: "UNKNOWN", + }, + value: "attendee5@example.net", + }, + { + params: { rsvp: "TRUE" }, + value: "attendee6@example.net", + }, + "attendee7@example.net", + ], + }, + expected: { + node: "imipHtml-attendees-cell", + attendeesList: [ + { + name: "Attendee 1 <attendee1@example.net>", + title: + "Attendee 1 <attendee1@example.net> is an optional " + + "participant. Attendee 1 still needs to reply.", + icon: { + attendeerole: "OPT-PARTICIPANT", + usertype: "INDIVIDUAL", + partstat: "NEEDS-ACTION", + }, + }, + { + name: "attendee2@example.net", + title: + "attendee2@example.net (group) is a non-participant. " + + "attendee2@example.net has confirmed attendance.", + icon: { + attendeerole: "NON-PARTICIPANT", + usertype: "GROUP", + partstat: "ACCEPTED", + }, + }, + { + name: "attendee3@example.net", + title: + "attendee3@example.net (resource) is a required " + + "participant. attendee3@example.net has confirmed attendance " + + "tentatively.", + icon: { + attendeerole: "REQ-PARTICIPANT", + usertype: "RESOURCE", + partstat: "TENTATIVE", + }, + }, + { + name: "attendee4@example.net (delegated from attendee5@example.net)", + title: + "attendee4@example.net (room) is an optional participant. " + + "attendee4@example.net has declined attendance.", + icon: { + attendeerole: "OPT-PARTICIPANT", + usertype: "ROOM", + partstat: "DECLINED", + }, + }, + { + name: "attendee5@example.net", + title: + "attendee5@example.net is an optional participant. " + + "attendee5@example.net has delegated attendance to " + + "attendee4@example.net.", + icon: { + attendeerole: "OPT-PARTICIPANT", + usertype: "UNKNOWN", + partstat: "DELEGATED", + }, + }, + { + name: "attendee6@example.net", + title: + "attendee6@example.net is a required participant. " + + "attendee6@example.net still needs to reply.", + icon: { + attendeerole: "REQ-PARTICIPANT", + usertype: "INDIVIDUAL", + partstat: "NEEDS-ACTION", + }, + }, + { + name: "attendee7@example.net", + title: + "attendee7@example.net is a required participant. " + + "attendee7@example.net still needs to reply.", + icon: { + attendeerole: "REQ-PARTICIPANT", + usertype: "INDIVIDUAL", + partstat: "NEEDS-ACTION", + }, + }, + ], + }, + }, + { + name: "Organizer", + input: { + organizer: { + params: { + partstat: "ACCEPTED", + role: "CHAIR", + cutype: "INDIVIDUAL", + cn: '"The Organizer"', + }, + value: "organizer@example.net", + }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: { + name: "The Organizer <organizer@example.net>", + title: + "The Organizer <organizer@example.net> chairs the event. " + + "The Organizer has confirmed attendance.", + icon: { + attendeerole: "CHAIR", + usertype: "INDIVIDUAL", + partstat: "ACCEPTED", + }, + }, + }, + }, + ]; + + function assertAttendee(attendee, name, title, icon, testName) { + equal(attendee.textContent, name, `Attendee names (test ${testName})`); + equal(attendee.getAttribute("title"), title, `Title for ${name} (test ${testName})`); + let attendeeIcon = attendee.querySelector(".itip-icon"); + ok(attendeeIcon, `icon for ${name} should exist (test ${testName})`); + for (let attr in icon) { + equal( + attendeeIcon.getAttribute(attr), + icon[attr], + `${attr} for icon for ${name} (test ${testName})` + ); + } + } + + for (let test of data) { + info(`testing ${test.name}`); + let { event, itipItem } = getEvent(test.input); + let dom = cal.invitation.createInvitationOverlay(event, itipItem); + let node = dom.getElementById(test.expected.node); + ok(node, `Element with id ${test.expected.node} should exist (test ${test.name})`); + if (test.expected.hidden) { + assertHiddenRow(node, true, test.name); + continue; + } + assertHiddenRow(node, false, test.name); + + if ("attendeesList" in test.expected) { + let attendeeNodes = node.querySelectorAll(".attendee-label"); + // Assert same order. + let i; + for (i = 0; i < test.expected.attendeesList.length; i++) { + let { name, title, icon } = test.expected.attendeesList[i]; + ok( + attendeeNodes.length > i, + `Enough attendees for expected attendee #${i} ${name} (test ${test.name})` + ); + assertAttendee(attendeeNodes[i], name, title, icon, test.name); + } + equal(attendeeNodes.length, i, `Same number of attendees (test ${test.name})`); + } else if ("organizer" in test.expected) { + let { name, title, icon } = test.expected.organizer; + let organizerNode = node.querySelector(".attendee-label"); + ok(organizerNode, `Organizer node should exist (test ${test.name})`); + assertAttendee(organizerNode, name, title, icon, test.name); + } else { + equal(node.innerHTML, test.expected.content, `innerHTML (test ${test.name})`); + } + } +}); + +add_task(async function updateInvitationOverlay_test() { + let data = [ + { + name: "No description before or after", + input: { previous: { description: "" }, current: { description: "" } }, + expected: { node: "imipHtml-description-content", hidden: true }, + }, + { + name: "Same description before and after", + input: { + previous: { description: "This is the description" }, + current: { description: "This is the description" }, + }, + expected: { + node: "imipHtml-description-content", + content: [{ type: "same", text: "This is the description" }], + }, + }, + { + name: "Added description", + input: { + previous: { description: "" }, + current: { description: "Added this description" }, + }, + expected: { + node: "imipHtml-description-content", + content: [{ type: "added", text: "Added this description" }], + }, + }, + { + name: "Removed description", + input: { + previous: { description: "Removed this description" }, + current: { description: "" }, + }, + expected: { + node: "imipHtml-description-content", + content: [{ type: "removed", text: "Removed this description" }], + }, + }, + { + name: "Location", + input: { + previous: { location: "This place" }, + current: { location: "Another location" }, + }, + expected: { + node: "imipHtml-location-content", + content: [ + { type: "added", text: "Another location" }, + { type: "removed", text: "This place" }, + ], + }, + }, + { + name: "Summary", + input: { + previous: { summary: "My invitation" }, + current: { summary: "My new invitation" }, + }, + expected: { + node: "imipHtml-summary-content", + content: [ + { type: "added", text: "My new invitation" }, + { type: "removed", text: "My invitation" }, + ], + }, + }, + { + name: "When", + input: { + previous: { + dtstart: "20150909T130000", + dtend: "20150909T140000", + }, + current: { + dtstart: "20150909T140000", + dtend: "20150909T150000", + }, + }, + expected: { + node: "imipHtml-when-content", + content: [ + // Time format is platform dependent, so we use alternative result + // sets here. + // If you get a failure for this test, add your pattern here. + { + type: "added", + text: /^Wednesday, (September 0?9,|0?9 September) 2015 (2:00 PM β 3:00 PM|14:00 β 15:00)$/, + }, + { + type: "removed", + text: /^Wednesday, (September 0?9,|0?9 September) 2015 (1:00 PM β 2:00 PM|13:00 β 14:00)$/, + }, + ], + }, + }, + { + name: "Organizer same", + input: { + previous: { organizer: "organizer1@example.net" }, + current: { organizer: "organizer1@example.net" }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [{ type: "same", text: "organizer1@example.net" }], + }, + }, + { + name: "Organizer modified", + input: { + // Modify ROLE from CHAIR to REQ-PARTICIPANT. + previous: { organizer: { params: { role: "CHAIR" }, value: "organizer1@example.net" } }, + current: { + organizer: { params: { role: "REQ-PARTICIPANT" }, value: "organizer1@example.net" }, + }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [{ type: "modified", text: "organizer1@example.net" }], + }, + }, + { + name: "Organizer added", + input: { + previous: { organizer: "" }, + current: { organizer: "organizer2@example.net" }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [{ type: "added", text: "organizer2@example.net" }], + }, + }, + { + name: "Organizer removed", + input: { + previous: { organizer: "organizer2@example.net" }, + current: { organizer: "" }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [{ type: "removed", text: "organizer2@example.net" }], + }, + }, + { + name: "Organizer changed", + input: { + previous: { organizer: "organizer1@example.net" }, + current: { organizer: "organizer2@example.net" }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [ + { type: "added", text: "organizer2@example.net" }, + { type: "removed", text: "organizer1@example.net" }, + ], + }, + }, + { + name: "Attendees: modify one, remove one, add one", + input: { + previous: { + attendee: [ + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + ], + }, + current: { + attendee: [ + { + // Modify PARTSTAT from NEEDS-ACTION. + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "ACCEPTED" }, + value: "attendee2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee4@example.net", + }, + ], + }, + }, + expected: { + node: "imipHtml-attendees-cell", + attendeesList: [ + { type: "removed", text: "attendee1@example.net" }, + { type: "modified", text: "attendee2@example.net" }, + { type: "same", text: "attendee3@example.net" }, + { type: "added", text: "attendee4@example.net" }, + ], + }, + }, + { + name: "Attendees: modify one, remove three, add two", + input: { + previous: { + attendee: [ + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-remove1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "GROUP", partstat: "NEEDS-ACTION" }, + value: "attendee1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-remove2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-remove3@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + ], + }, + current: { + attendee: [ + { + // Modify CUTYPE from GROUP. + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-add1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-add2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + ], + }, + }, + expected: { + node: "imipHtml-attendees-cell", + attendeesList: [ + { type: "removed", text: "attendee-remove1@example.net" }, + { type: "modified", text: "attendee1@example.net" }, + // Added shown first, then removed, and in between the common + // attendees. + { type: "added", text: "attendee-add1@example.net" }, + { type: "added", text: "attendee-add2@example.net" }, + { type: "removed", text: "attendee-remove2@example.net" }, + { type: "removed", text: "attendee-remove3@example.net" }, + { type: "same", text: "attendee3@example.net" }, + ], + }, + }, + ]; + + function assertElement(node, text, type, testName) { + let found = node.textContent; + if (text instanceof RegExp) { + ok(text.test(found), `Text content "${found}" matches regex (test ${testName})`); + } else { + equal(text, found, `Text content matches (test ${testName})`); + } + switch (type) { + case "added": + equal(node.tagName, "INS", `Text "${text}" is inserted (test ${testName})`); + ok(node.classList.contains("added"), `Text "${text}" is added (test ${testName})`); + break; + case "removed": + equal(node.tagName, "DEL", `Text "${text}" is deleted (test ${testName})`); + ok(node.classList.contains("removed"), `Text "${text}" is removed (test ${testName})`); + break; + case "modified": + ok(node.tagName !== "DEL", `Text "${text}" is not deleted (test ${testName})`); + ok(node.tagName !== "INS", `Text "${text}" is not inserted (test ${testName})`); + ok(node.classList.contains("modified"), `Text "${text}" is modified (test ${testName})`); + break; + case "same": + // NOTE: node may be a Text node. + ok(node.tagName !== "DEL", `Text "${text}" is not deleted (test ${testName})`); + ok(node.tagName !== "INS", `Text "${text}" is not inserted (test ${testName})`); + if (node.classList) { + ok(!node.classList.contains("added"), `Text "${text}" is not added (test ${testName})`); + ok( + !node.classList.contains("removed"), + `Text "${text}" is not removed (test ${testName})` + ); + ok( + !node.classList.contains("modified"), + `Text "${text}" is not modified (test ${testName})` + ); + } + break; + default: + ok(false, `Unknown type ${type} for text "${text}" (test ${testName})`); + break; + } + } + + for (let test of data) { + info(`testing ${test.name}`); + let { event, itipItem } = getEvent(test.input.current); + let dom = cal.invitation.createInvitationOverlay(event, itipItem); + let { event: oldEvent } = getEvent(test.input.previous); + cal.invitation.updateInvitationOverlay(dom, event, itipItem, oldEvent); + + let node = dom.getElementById(test.expected.node); + ok(node, `Element with id ${test.expected.node} should exist (test ${test.name})`); + if (test.expected.hidden) { + assertHiddenRow(node, true, test.name); + continue; + } + assertHiddenRow(node, false, test.name); + + let insertBreaks = false; + let nodeList; + let expectList; + + if ("attendeesList" in test.expected) { + // Insertions, deletions and modifications are all within separate + // list-items. + nodeList = node.querySelectorAll(":scope > .attendee-list > .attendee-list-item > *"); + expectList = test.expected.attendeesList; + } else if ("organizer" in test.expected) { + nodeList = node.childNodes; + expectList = test.expected.organizer; + } else { + nodeList = node.childNodes; + expectList = test.expected.content; + insertBreaks = true; + } + + // Assert in same order. + let first = true; + let nodeIndex = 0; + for (let { text, type } of expectList) { + if (first) { + first = false; + } else if (insertBreaks) { + ok( + nodeList.length > nodeIndex, + `Enough child nodes for expected break node at index ${nodeIndex} (test ${test.name})` + ); + equal( + nodeList[nodeIndex].tagName, + "BR", + `Break node at index ${nodeIndex} (test ${test.name})` + ); + nodeIndex++; + } + + ok( + nodeList.length > nodeIndex, + `Enough child nodes for expected node at index ${nodeIndex} "${text}" (test ${test.name})` + ); + assertElement(nodeList[nodeIndex], text, type, test.name); + nodeIndex++; + } + equal(nodeList.length, nodeIndex, `Covered all nodes (test ${test.name})`); + } +}); + +add_task(async function getHeaderSection_test() { + let data = [ + { + // test #1 + input: { + toList: "recipient@example.net", + subject: "Invitation: test subject", + identity: { + fullName: "Invitation sender", + email: "sender@example.net", + replyTo: "no-reply@example.net", + organization: "Example Net", + cc: "cc@example.net", + bcc: "bcc@example.net", + }, + }, + expected: + "MIME-version: 1.0\r\n" + + "Return-path: no-reply@example.net\r\n" + + "From: Invitation sender <sender@example.net>\r\n" + + "Organization: Example Net\r\n" + + "To: recipient@example.net\r\n" + + "Subject: Invitation: test subject\r\n" + + "Cc: cc@example.net\r\n" + + "Bcc: bcc@example.net\r\n", + }, + { + // test #2 + input: { + toList: 'rec1@example.net, Recipient 2 <rec2@example.net>, "Rec, 3" <rec3@example.net>', + subject: "Invitation: test subject", + identity: { + fullName: '"invitation, sender"', + email: "sender@example.net", + replyTo: "no-reply@example.net", + organization: "Example Net", + cc: 'cc1@example.net, Cc 2 <cc2@example.net>, "Cc, 3" <cc3@example.net>', + bcc: 'bcc1@example.net, BCc 2 <bcc2@example.net>, "Bcc, 3" <bcc3@example.net>', + }, + }, + expected: + "MIME-version: 1.0\r\n" + + "Return-path: no-reply@example.net\r\n" + + 'From: "invitation, sender" <sender@example.net>\r\n' + + "Organization: Example Net\r\n" + + 'To: rec1@example.net, Recipient 2 <rec2@example.net>,\r\n "Rec, 3" <rec3@example.net>\r\n' + + "Subject: Invitation: test subject\r\n" + + 'Cc: cc1@example.net, Cc 2 <cc2@example.net>, "Cc, 3" <cc3@example.net>\r\n' + + 'Bcc: bcc1@example.net, BCc 2 <bcc2@example.net>, "Bcc, 3"\r\n <bcc3@example.net>\r\n', + }, + { + // test #3 + input: { + toList: "recipient@example.net", + subject: "Invitation: test subject", + identity: { email: "sender@example.net" }, + }, + expected: + "MIME-version: 1.0\r\n" + + "From: sender@example.net\r\n" + + "To: recipient@example.net\r\n" + + "Subject: Invitation: test subject\r\n", + }, + { + // test #4 + input: { + toList: "Max MΓΌller <mueller@example.net>", + subject: "Invitation: Diacritis check (üÀé)", + identity: { + fullName: "RenΓ©", + email: "sender@example.net", + replyTo: "Max & RenΓ© <no-reply@example.net>", + organization: "Max & RenΓ©", + cc: "RenΓ© <cc@example.net>", + bcc: "RenΓ© <bcc@example.net>", + }, + }, + expected: + "MIME-version: 1.0\r\n" + + "Return-path: =?UTF-8?B?TWF4ICYgUmVuw6k=?= <no-reply@example.net>\r\n" + + "From: =?UTF-8?B?UmVuw6k=?= <sender@example.net>\r\n" + + "Organization: =?UTF-8?B?TWF4ICYgUmVuw6k=?=\r\n" + + "To: =?UTF-8?Q?Max_M=C3=BCller?= <mueller@example.net>\r\n" + + "Subject: =?UTF-8?B?SW52aXRhdGlvbjogRGlhY3JpdGlzIGNoZWNrICjDvMOk?=\r\n =?UTF-8?B" + + "?w6kp?=\r\n" + + "Cc: =?UTF-8?B?UmVuw6k=?= <cc@example.net>\r\n" + + "Bcc: =?UTF-8?B?UmVuw6k=?= <bcc@example.net>\r\n", + }, + ]; + let i = 0; + for (let test of data) { + i++; + info(`Running test #${i}`); + let identity = MailServices.accounts.createIdentity(); + identity.email = test.input.identity.email || null; + identity.fullName = test.input.identity.fullName || null; + identity.replyTo = test.input.identity.replyTo || null; + identity.organization = test.input.identity.organization || null; + identity.doCc = test.input.identity.doCc || test.input.identity.cc; + identity.doCcList = test.input.identity.cc || null; + identity.doBcc = test.input.identity.doBcc || test.input.identity.bcc; + identity.doBccList = test.input.identity.bcc || null; + + let composeUtils = Cc["@mozilla.org/messengercompose/computils;1"].createInstance( + Ci.nsIMsgCompUtils + ); + let messageId = composeUtils.msgGenerateMessageId(identity, null); + + let header = cal.invitation.getHeaderSection( + messageId, + identity, + test.input.toList, + test.input.subject + ); + // we test Date and Message-ID headers separately to avoid false positives + ok(!!header.match(/Date:.+(?:\n|\r\n|\r)/), "(test #" + i + "): date"); + ok(!!header.match(/Message-ID:.+(?:\n|\r\n|\r)/), "(test #" + i + "): message-id"); + equal( + header.replace(/Date:.+(?:\n|\r\n|\r)/, "").replace(/Message-ID:.+(?:\n|\r\n|\r)/, ""), + test.expected.replace(/Date:.+(?:\n|\r\n|\r)/, "").replace(/Message-ID:.+(?:\n|\r\n|\r)/, ""), + "(test #" + i + "): all headers" + ); + } +}); + +add_task(async function convertFromUnicode_test() { + let data = [ + { + // test #1 + input: "mΓΌller", + expected: "mΓΒΌller", + }, + { + // test #2 + input: "muller", + expected: "muller", + }, + { + // test #3 + input: "mΓΌller\nmΓΌller", + expected: "mΓΒΌller\nmΓΒΌller", + }, + { + // test #4 + input: "mΓΌller\r\nmΓΌller", + expected: "mΓΒΌller\r\nmΓΒΌller", + }, + ]; + let i = 0; + for (let test of data) { + i++; + equal(cal.invitation.convertFromUnicode(test.input), test.expected, "(test #" + i + ")"); + } +}); + +add_task(async function encodeUTF8_test() { + let data = [ + { + // test #1 + input: "mΓΌller", + expected: "mΓΒΌller", + }, + { + // test #2 + input: "muller", + expected: "muller", + }, + { + // test #3 + input: "mΓΌller\nmΓΌller", + expected: "mΓΒΌller\r\nmΓΒΌller", + }, + { + // test #4 + input: "mΓΌller\r\nmΓΌller", + expected: "mΓΒΌller\r\nmΓΒΌller", + }, + { + // test #5 + input: "", + expected: "", + }, + ]; + let i = 0; + for (let test of data) { + i++; + equal(cal.invitation.encodeUTF8(test.input), test.expected, "(test #" + i + ")"); + } +}); + +add_task(async function encodeMimeHeader_test() { + let data = [ + { + // test #1 + input: { + header: "Max MΓΌller <m.mueller@example.net>", + isEmail: true, + }, + expected: "=?UTF-8?Q?Max_M=C3=BCller?= <m.mueller@example.net>", + }, + { + // test #2 + input: { + header: "Max Mueller <m.mueller@example.net>", + isEmail: true, + }, + expected: "Max Mueller <m.mueller@example.net>", + }, + { + // test #3 + input: { + header: "MΓΌller & MΓΌller", + isEmail: false, + }, + expected: "=?UTF-8?B?TcO8bGxlciAmIE3DvGxsZXI=?=", + }, + ]; + + let i = 0; + for (let test of data) { + i++; + equal( + cal.invitation.encodeMimeHeader(test.input.header, test.input.isEmail), + test.expected, + "(test #" + i + ")" + ); + } +}); + +add_task(async function getRfc5322FormattedDate_test() { + let data = { + input: [ + { + // test #1 + date: null, + timezone: "America/New_York", + }, + { + // test #2 + date: "Sat, 24 Jan 2015 09:24:49 +0100", + timezone: "America/New_York", + }, + { + // test #3 + date: "Sat, 24 Jan 2015 09:24:49 GMT+0100", + timezone: "America/New_York", + }, + { + // test #4 + date: "Sat, 24 Jan 2015 09:24:49 GMT", + timezone: "America/New_York", + }, + { + // test #5 + date: "Sat, 24 Jan 2015 09:24:49", + timezone: "America/New_York", + }, + { + // test #6 + date: "Sat, 24 Jan 2015 09:24:49", + timezone: null, + }, + { + // test #7 + date: "Sat, 24 Jan 2015 09:24:49", + timezone: "UTC", + }, + { + // test #8 + date: "Sat, 24 Jan 2015 09:24:49", + timezone: "floating", + }, + ], + expected: /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} [+-]\d{4}$/, + }; + + let i = 0; + let timezone = Services.prefs.getStringPref("calendar.timezone.local", null); + for (let test of data.input) { + i++; + if (test.timezone) { + Services.prefs.setStringPref("calendar.timezone.local", test.timezone); + } else { + Services.prefs.clearUserPref("calendar.timezone.local"); + } + let date = test.date ? new Date(test.date) : null; + let re = new RegExp(data.expected); + ok(re.test(cal.invitation.getRfc5322FormattedDate(date)), "(test #" + i + ")"); + } + Services.prefs.setStringPref("calendar.timezone.local", timezone); +}); + +add_task(async function parseCounter_test() { + // We are disabling this rule for a more consistent display of this data + /* eslint-disable object-curly-newline */ + let data = [ + { + name: "Basic test to check all currently supported properties", + input: { + proposed: { + method: "COUNTER", + dtstart: "20150910T210000", + dtend: "20150910T220000", + location: "Room 2", + summary: "Test Event 2", + attendee: { + params: { cn: "Attendee", partstat: "DECLINED", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + dtstamp: "20150909T182048Z", + comment: "Sorry, I cannot make it that time.", + }, + }, + expected: { + // Time format is platform dependent, so we use alternative result sets here. + // The first two are configurations running for automated tests. + // If you get a failure for this test, add your pattern here. + result: { descr: "", type: "OK" }, + differences: { + summary: { + proposed: "Test Event 2", + original: "Test Event", + }, + location: { + proposed: "Room 2", + original: "Room 1", + }, + dtstart: { + proposed: + /^Thursday, (September 10,|10 September) 2015 (9:00 PM|21:00) Europe\/Berlin$/, + original: + /^Wednesday, (September 0?9,|0?9 September) 2015 (9:00 PM|21:00) Europe\/Berlin$/, + }, + dtend: { + proposed: + /^Thursday, (September 10,|10 September) 2015 (10:00 PM|22:00) Europe\/Berlin$/, + original: + /^Wednesday, (September 0?9,|0?9 September) 2015 (10:00 PM|22:00) Europe\/Berlin$/, + }, + comment: { + proposed: "Sorry, I cannot make it that time.", + original: null, + }, + }, + }, + }, + { + name: "Test with an unsupported property has been changed", + input: { + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + location: "Room 2", + attach: "http://www.example2.com", + dtstamp: "20150909T182048Z", + }, + }, + expected: { + result: { descr: "", type: "OK" }, + differences: { location: { proposed: "Room 2", original: "Room 1" } }, + }, + }, + { + name: "Proposed change not based on the latest update of the invitation", + input: { + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + location: "Room 2", + dtstamp: "20150909T171048Z", + }, + }, + expected: { + result: { + descr: "This is a counterproposal not based on the latest event update.", + type: "NOTLATESTUPDATE", + }, + differences: { location: { proposed: "Room 2", original: "Room 1" } }, + }, + }, + { + name: "Proposed change based on a meanwhile reschuled invitation", + input: { + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + location: "Room 2", + sequence: "0", + dtstamp: "20150909T182048Z", + }, + }, + expected: { + result: { + descr: "This is a counterproposal to an already rescheduled event.", + type: "OUTDATED", + }, + differences: { location: { proposed: "Room 2", original: "Room 1" } }, + }, + }, + { + name: "Proposed change for an later sequence of the event", + input: { + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + location: "Room 2", + sequence: "2", + dtstamp: "20150909T182048Z", + }, + }, + expected: { + result: { + descr: "Invalid sequence number in counterproposal.", + type: "ERROR", + }, + differences: {}, + }, + }, + { + name: "Proposal to a different event", + input: { + proposed: { + method: "COUNTER", + uid: "cb189fdc-0000-0000-0000-31a08802249d", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + location: "Room 2", + dtstamp: "20150909T182048Z", + }, + }, + expected: { + result: { + descr: "Mismatch of uid or organizer in counterproposal.", + type: "ERROR", + }, + differences: {}, + }, + }, + { + name: "Proposal with a different organizer", + input: { + proposed: { + method: "COUNTER", + organizer: { + params: { rsvp: "TRUE", cn: "Organizer", partstat: "ACCEPTED", role: "CHAIR" }, + value: "organizer2@example.net", + }, + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + dtstamp: "20150909T182048Z", + }, + }, + expected: { + result: { + descr: "Mismatch of uid or organizer in counterproposal.", + type: "ERROR", + }, + differences: {}, + }, + }, + { + name: "Counterproposal without any difference", + input: { + proposed: { method: "COUNTER" }, + }, + expected: { + result: { + descr: "No difference in counterproposal detected.", + type: "NODIFF", + }, + differences: {}, + }, + }, + ]; + /* eslint-enable object-curly-newline */ + + let getItem = function (aProperties) { + let item = getIcs(aProperties); + return createEventFromIcalString(item); + }; + + let formatDt = function (aDateTime) { + if (!aDateTime) { + return null; + } + let datetime = cal.dtz.formatter.formatDateTime(aDateTime); + return datetime + " " + aDateTime.timezone.displayName; + }; + + for (let test of data) { + info(`testing ${test.name}`); + let existingItem = getItem(); + let proposedItem = getItem(test.input.proposed); + let parsed = cal.invitation.parseCounter(proposedItem, existingItem); + + equal(parsed.result.type, test.expected.result.type, `(test ${test.name}: result.type)`); + equal(parsed.result.descr, test.expected.result.descr, `(test ${test.name}: result.descr)`); + let parsedProps = []; + let additionalProps = []; + let missingProps = []; + parsed.differences.forEach(aDiff => { + let prop = aDiff.property.toLowerCase(); + if (prop in test.expected.differences) { + let { proposed, original } = test.expected.differences[prop]; + let foundProposed = aDiff.proposed; + let foundOriginal = aDiff.original; + if (["dtstart", "dtend"].includes(prop)) { + foundProposed = formatDt(foundProposed); + foundOriginal = formatDt(foundOriginal); + ok(foundProposed, `(test ${test.name}: have proposed time value for ${prop})`); + ok(foundOriginal, `(test ${test.name}: have original time value for ${prop})`); + } + + if (proposed instanceof RegExp) { + ok( + proposed.test(foundProposed), + `(test ${test.name}: proposed "${foundProposed}" for ${prop} matches expected regex)` + ); + } else { + equal( + foundProposed, + proposed, + `(test ${test.name}: proposed for ${prop} matches expected)` + ); + } + + if (original instanceof RegExp) { + ok( + original.test(foundOriginal), + `(test ${test.name}: original "${foundOriginal}" for ${prop} matches expected regex)` + ); + } else { + equal( + foundOriginal, + original, + `(test ${test.name}: original for ${prop} matches expected)` + ); + } + + parsedProps.push(prop); + } else { + additionalProps.push(prop); + } + }); + for (let prop in test.expected.differences) { + if (!parsedProps.includes(prop)) { + missingProps.push(prop); + } + } + ok( + additionalProps.length == 0, + `(test ${test.name}: should be no additional properties: ${additionalProps})` + ); + ok( + missingProps.length == 0, + `(test ${test.name}: should be no missing properties: ${missingProps})` + ); + } +}); diff --git a/comm/calendar/test/unit/test_items.js b/comm/calendar/test/unit/test_items.js new file mode 100644 index 0000000000..fb7fa38ec5 --- /dev/null +++ b/comm/calendar/test/unit/test_items.js @@ -0,0 +1,465 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_aclmanager(); + test_calendar(); + test_immutable(); + test_attendee(); + test_attachment(); + test_lastack(); + test_categories(); + test_alarm(); + test_isEvent(); + test_isTodo(); + test_recurring_event_properties(); + test_recurring_todo_properties(); + test_recurring_event_exception_properties(); + test_recurring_todo_exception_properties(); +} + +function test_aclmanager() { + let mockCalendar = { + QueryInterface: ChromeUtils.generateQI(["calICalendar"]), + + get superCalendar() { + return this; + }, + get aclManager() { + return this; + }, + + getItemEntry(item) { + if (item.id == "withentry") { + return itemEntry; + } + return null; + }, + }; + + let itemEntry = { + QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]), + userCanModify: true, + userCanRespond: false, + userCanViewAll: true, + userCanViewDateAndTime: false, + }; + + let event = new CalEvent(); + event.id = "withentry"; + event.calendar = mockCalendar; + + equal(event.aclEntry.userCanModify, itemEntry.userCanModify); + equal(event.aclEntry.userCanRespond, itemEntry.userCanRespond); + equal(event.aclEntry.userCanViewAll, itemEntry.userCanViewAll); + equal(event.aclEntry.userCanViewDateAndTime, itemEntry.userCanViewDateAndTime); + + let parentEntry = new CalEvent(); + parentEntry.id = "parententry"; + parentEntry.calendar = mockCalendar; + parentEntry.parentItem = event; + + equal(parentEntry.aclEntry.userCanModify, itemEntry.userCanModify); + equal(parentEntry.aclEntry.userCanRespond, itemEntry.userCanRespond); + equal(parentEntry.aclEntry.userCanViewAll, itemEntry.userCanViewAll); + equal(parentEntry.aclEntry.userCanViewDateAndTime, itemEntry.userCanViewDateAndTime); + + event = new CalEvent(); + event.id = "noentry"; + event.calendar = mockCalendar; + equal(event.aclEntry, null); +} + +function test_calendar() { + let event = new CalEvent(); + let parentEntry = new CalEvent(); + + let mockCalendar = { + QueryInterface: ChromeUtils.generateQI(["calICalendar"]), + id: "one", + }; + + parentEntry.calendar = mockCalendar; + event.parentItem = parentEntry; + + notEqual(event.calendar, null); + equal(event.calendar.id, "one"); +} + +function test_attachment() { + let e = new CalEvent(); + + let a = new CalAttachment(); + a.rawData = "horst"; + + let b = new CalAttachment(); + b.rawData = "bruno"; + + e.addAttachment(a); + equal(e.getAttachments().length, 1); + + e.addAttachment(b); + equal(e.getAttachments().length, 2); + + e.removeAttachment(a); + equal(e.getAttachments().length, 1); + + e.removeAllAttachments(); + equal(e.getAttachments().length, 0); +} + +function test_attendee() { + let e = new CalEvent(); + equal(e.getAttendeeById("unknown"), null); + equal(e.getAttendees().length, 0); + + let a = new CalAttendee(); + a.id = "mailto:horst"; + + let b = new CalAttendee(); + b.id = "mailto:bruno"; + + e.addAttendee(a); + equal(e.getAttendees().length, 1); + equal(e.getAttendeeById("mailto:horst"), a); + + e.addAttendee(b); + equal(e.getAttendees().length, 2); + + let comp = e.icalComponent; + let aprop = comp.getFirstProperty("ATTENDEE"); + equal(aprop.value, "mailto:horst"); + aprop = comp.getNextProperty("ATTENDEE"); + equal(aprop.value, "mailto:bruno"); + equal(comp.getNextProperty("ATTENDEE"), null); + + e.removeAttendee(a); + equal(e.getAttendees().length, 1); + equal(e.getAttendeeById("mailto:horst"), null); + + e.removeAllAttendees(); + equal(e.getAttendees().length, 0); +} + +function test_categories() { + let e = new CalEvent(); + + equal(e.getCategories().length, 0); + + let cat = ["a", "b", "c"]; + e.setCategories(cat); + + cat[0] = "err"; + equal(e.getCategories().join(","), "a,b,c"); + + let comp = e.icalComponent; + let getter = comp.getFirstProperty.bind(comp); + + cat[0] = "a"; + while (cat.length) { + equal(cat.shift(), getter("CATEGORIES").value); + getter = comp.getNextProperty.bind(comp); + } +} + +function test_alarm() { + let e = new CalEvent(); + let alarm = new CalAlarm(); + + alarm.action = "DISPLAY"; + alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; + alarm.alarmDate = cal.createDateTime(); + + e.addAlarm(alarm); + let ecomp = e.icalComponent; + let vcomp = ecomp.getFirstSubcomponent("VALARM"); + equal(vcomp.serializeToICS(), alarm.icalString); + + let alarm2 = alarm.clone(); + + e.addAlarm(alarm2); + + equal(e.getAlarms().length, 2); + e.deleteAlarm(alarm); + equal(e.getAlarms().length, 1); + equal(e.getAlarms()[0], alarm2); + + e.clearAlarms(); + equal(e.getAlarms().length, 0); +} + +function test_immutable() { + let event = new CalEvent(); + + let date = cal.createDateTime(); + date.timezone = cal.timezoneService.getTimezone("Europe/Berlin"); + event.alarmLastAck = date; + + let org = new CalAttendee(); + org.id = "one"; + event.organizer = org; + + let alarm = new CalAlarm(); + alarm.action = "DISPLAY"; + alarm.description = "foo"; + alarm.related = Ci.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration("PT1S"); + event.addAlarm(alarm); + + event.setProperty("X-NAME", "X-VALUE"); + event.setPropertyParameter("X-NAME", "X-PARAM", "X-PARAMVAL"); + + event.setCategories(["a", "b", "c"]); + + equal(event.alarmLastAck.timezone.tzid, cal.dtz.UTC.tzid); + + event.makeImmutable(); + + // call again, should not throw + event.makeImmutable(); + + ok(!event.alarmLastAck.isMutable); + ok(!org.isMutable); + ok(!alarm.isMutable); + + throws(() => { + event.alarmLastAck = cal.createDateTime(); + }, /Can not modify immutable data container/); + throws(() => { + event.calendar = null; + }, /Can not modify immutable data container/); + throws(() => { + event.parentItem = null; + }, /Can not modify immutable data container/); + throws(() => { + event.setCategories(["d", "e", "f"]); + }, /Can not modify immutable data container/); + + let event2 = event.clone(); + event2.organizer.id = "two"; + + equal(org.id, "one"); + equal(event2.organizer.id, "two"); + + equal(event2.getProperty("X-NAME"), "X-VALUE"); + equal(event2.getPropertyParameter("X-NAME", "X-PARAM"), "X-PARAMVAL"); + + event2.setPropertyParameter("X-NAME", "X-PARAM", null); + equal(event2.getPropertyParameter("X-NAME", "X-PARAM"), null); + + // TODO more clone checks +} + +function test_lastack() { + let e = new CalEvent(); + + e.alarmLastAck = cal.createDateTime("20120101T010101"); + + // Our items don't support this yet + // equal(e.getProperty("X-MOZ-LASTACK"), "20120101T010101"); + + let comp = e.icalComponent; + let prop = comp.getFirstProperty("X-MOZ-LASTACK"); + + equal(prop.value, "20120101T010101Z"); + + prop.value = "20120101T010102Z"; + + e.icalComponent = comp; + + equal(e.alarmLastAck.icalString, "20120101T010102Z"); +} + +/** + * Test isEvent() returns the correct value for events and todos. + */ +function test_isEvent() { + let event = new CalEvent(); + let todo = new CalTodo(); + + Assert.ok(event.isEvent(), "isEvent() returns true for events"); + Assert.ok(!todo.isEvent(), "isEvent() returns false for todos"); +} + +/** + * Test isTodo() returns the correct value for events and todos. + */ +function test_isTodo() { + let todo = new CalTodo(); + let event = new CalEvent(); + + Assert.ok(todo.isTodo(), "isTodo() returns true for todos"); + Assert.ok(!event.isTodo(), "isTodo() returns false for events"); +} + +/** + * Function for testing that the "properties" property of each supplied + * calItemBase occurrence includes those inherited from the parent. + * + * @param {calItemBase[]} items - A list of item occurrences to test. + * @param {calItemBase} parent - The item to use as the parent. + * @param {object} [overrides] - A set of key value pairs than can be passed + * to indicate what to expect for some properties. + */ +function doPropertiesTest(items, parent, overrides = {}) { + let skippedProps = ["DTSTART", "DTEND"]; + let toString = value => + value && value instanceof Ci.calIDateTime ? value.icalString : value && value.toString(); + + for (let item of items) { + info(`Testing occurrence with recurrenceId="${item.recurrenceId.icalString}...`); + + let parentProperties = new Map(parent.properties); + let itemProperties = new Map(item.properties); + for (let [name, value] of parentProperties.entries()) { + if (!skippedProps.includes(name)) { + if (overrides[name]) { + Assert.equal( + toString(itemProperties.get(name)), + toString(overrides[name]), + `"${name}" value is value expected by overrides` + ); + } else { + Assert.equal( + toString(itemProperties.get(name)), + toString(value), + `"${name}" value is same as parent` + ); + } + } + } + } +} + +/** + * Test the "properties" property of a recurring CalEvent inherits parent + * properties properly. + */ +function test_recurring_event_properties() { + let event = new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + DTSTAMP:20210716T000000Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Parent Event + CATEGORIES:Business + LOCATION: Mochitest + DTSTART:20210716T000000Z + DTEND:20210716T110000Z + RRULE:FREQ=DAILY;UNTIL=20210719T110000Z + DESCRIPTION:This is the main event. + END:VEVENT + `); + let occurrences = event.recurrenceInfo.getOccurrences( + cal.createDateTime("20210701"), + cal.createDateTime("20210731"), + Infinity + ); + doPropertiesTest(occurrences, event.parentItem); +} + +/** + * Test the "properties" property of a recurring CalEvent exception inherits + * parent properties properly. + */ +function test_recurring_event_exception_properties() { + let event = new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + DTSTAMP:20210716T000000Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Parent Event + CATEGORIES:Business + LOCATION: Mochitest + DTSTART:20210716T000000Z + DTEND:20210716T110000Z + RRULE:FREQ=DAILY;UNTIL=20210719T110000Z + DESCRIPTION:This is the main event. + END:VEVENT + `); + let occurrences = event.recurrenceInfo.getOccurrences( + cal.createDateTime("20210701"), + cal.createDateTime("20210731"), + Infinity + ); + let target = occurrences[0].clone(); + let newDescription = "This is an exception."; + target.setProperty("DESCRIPTION", newDescription); + event.parentItem.recurrenceInfo.modifyException(target); + target = event.parentItem.recurrenceInfo.getExceptionFor(target.recurrenceId); + Assert.ok(target); + doPropertiesTest([target], event.parentItem, { DESCRIPTION: newDescription }); +} + +/** + * Test the "properties" property of a recurring CalTodo inherits parent + * properties properly. + */ +function test_recurring_todo_properties() { + let task = new CalTodo(CalendarTestUtils.dedent` + BEGIN:VTODO + DTSTAMP:20210716T225440Z + UID:673e125d-fe6b-465d-8a38-9c9373ca9705 + SUMMARY:Main Task + RRULE:FREQ=DAILY;UNTIL=20210719T230000Z + DTSTART;TZID=America/Port_of_Spain:20210716T190000 + PERCENT-COMPLETE:0 + LOCATION:Mochitest + DESCRIPTION:This is the main task. + END:VTODO + `); + let occurrences = task.recurrenceInfo.getOccurrences( + cal.createDateTime("20210701"), + cal.createDateTime("20210731"), + Infinity + ); + doPropertiesTest(occurrences, task.parentItem); +} + +/** + * Test the "properties" property of a recurring CalTodo exception inherits + * parent properties properly. + */ +function test_recurring_todo_exception_properties() { + let task = new CalTodo(CalendarTestUtils.dedent` + BEGIN:VTODO + DTSTAMP:20210716T225440Z + UID:673e125d-fe6b-465d-8a38-9c9373ca9705 + SUMMARY:Main Task + RRULE:FREQ=DAILY;UNTIL=20210719T230000Z + DTSTART;TZID=America/Port_of_Spain:20210716T190000 + PERCENT-COMPLETE:0 + LOCATION:Mochitest + DESCRIPTION:This is the main task. + END:VTODO + `); + let occurrences = task.recurrenceInfo.getOccurrences( + cal.createDateTime("20210701"), + cal.createDateTime("20210731"), + Infinity + ); + let target = occurrences[0].clone(); + let newDescription = "This is an exception."; + target.setProperty("DESCRIPTION", newDescription); + task.parentItem.recurrenceInfo.modifyException(target); + target = task.parentItem.recurrenceInfo.getExceptionFor(target.recurrenceId); + Assert.ok(target); + doPropertiesTest([target], task.parentItem, { DESCRIPTION: newDescription }); +} diff --git a/comm/calendar/test/unit/test_itip_message_sender.js b/comm/calendar/test/unit/test_itip_message_sender.js new file mode 100644 index 0000000000..77a110a875 --- /dev/null +++ b/comm/calendar/test/unit/test_itip_message_sender.js @@ -0,0 +1,358 @@ +/* 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"); +const { CalAttendee } = ChromeUtils.import("resource:///modules/CalAttendee.jsm"); +var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +var { CalItipMessageSender } = ChromeUtils.import("resource:///modules/CalItipMessageSender.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +const identityEmail = "user@example.com"; +const eventOrganizerEmail = "eventorganizer@example.com"; + +/** + * Creates a calendar event mimicking an event to which we have received an + * invitation. + * + * @param {string} organizerEmail - The email address of the event organizer. + * @param {string} attendeeEmail - The email address of an attendee who has + * accepted the invitation. + * @returns {calIItemBase} - The new calendar event. + */ +function createIncomingEvent(organizerEmail, attendeeEmail) { + const organizerId = cal.email.prependMailTo(organizerEmail); + const attendeeId = cal.email.prependMailTo(attendeeEmail); + + const icalString = CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20210105T000000Z + DTSTAMP:20210501T000000Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Test Invitation + DTSTART:20210105T000000Z + DTEND:20210105T100000Z + STATUS:CONFIRMED + SUMMARY:Test Event + ORGANIZER;CN=${organizerEmail}:${organizerId} + ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED; + RSVP=TRUE;CN=other@example.com;:mailto:other@example.com + ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED; + RSVP=TRUE;CN=${attendeeEmail};:${attendeeId} + X-MOZ-RECEIVED-SEQUENCE:0 + X-MOZ-RECEIVED-DTSTAMP:20210501T000000Z + X-MOZ-GENERATION:0 + END:VEVENT + `; + + return new CalEvent(icalString); +} + +let calendar; + +/** + * Ensure the calendar manager is available, initialize the calendar and + * identity we use for testing. + */ +add_setup(async function () { + do_get_profile(); + + await new Promise(resolve => do_load_calmgr(resolve)); + calendar = CalendarTestUtils.createCalendar("Test", "memory"); + + const identity = MailServices.accounts.createIdentity(); + identity.email = identityEmail; + + const account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + `${account.key}user`, + "localhost", + "none" + ); + account.addIdentity(identity); + + registerCleanupFunction(() => { + MailServices.accounts.removeIncomingServer(account.incomingServer, false); + MailServices.accounts.removeAccount(account); + }); + + calendar.setProperty("imip.identity.key", identity.key); + calendar.setProperty("organizerId", cal.email.prependMailTo(identityEmail)); +}); + +add_task(async function testAddAttendeesToOwnEvent() { + const icalString = CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20210105T000000Z + DTSTAMP:20210501T000000Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Test Invitation + DTSTART:20210105T000000Z + DTEND:20210105T100000Z + STATUS:CONFIRMED + SUMMARY:Test Event + X-MOZ-SEND-INVITATIONS:TRUE + END:VEVENT + `; + + const item = new CalEvent(icalString); + const savedItem = await calendar.addItem(item); + + // Modify the event to include an attendee not in the original, as well as the + // organizer. As of the writing of this test, this is the expected behavior + // for adding an attendee to an event which previously had none. + const newAttendeeEmail = "foo@example.com"; + const newAttendee = new CalAttendee(); + newAttendee.id = newAttendeeEmail; + + const organizer = new CalAttendee(); + organizer.isOrganizer = true; + organizer.id = identityEmail; + + const organizerAsAttendee = new CalAttendee(); + organizerAsAttendee.id = identityEmail; + + const targetItem = savedItem.clone(); + targetItem.addAttendee(newAttendee); + targetItem.addAttendee(organizer); + targetItem.addAttendee(organizerAsAttendee); + const modifiedItem = await calendar.modifyItem(targetItem, savedItem); + + // Test that a sender with an original item and for which the current user is + // both an attendee and the organizer will generate a REQUEST, but not send a + // message to the organizer. + const sender = new CalItipMessageSender(savedItem, null); + + const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem); + Assert.equal(result, 1, "return value should indicate there are pending messages"); + Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message"); + + const [msg] = sender.pendingMessages; + Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'"); + Assert.equal(msg.recipients.length, 1, "message should have one recipient"); + + const [recipient] = msg.recipients; + Assert.equal( + recipient.id, + cal.email.prependMailTo(newAttendeeEmail), + "recipient should be the non-organizer attendee" + ); + + await calendar.deleteItem(modifiedItem); + + // Now also cancel the event. No mail should be sent to self. + const targetItem2 = modifiedItem.clone(); + + targetItem2.setProperty("STATUS", "CANCELLED"); + targetItem2.setProperty("SEQUENCE", "2"); + const modifiedItem2 = await calendar.addItem(targetItem2); + const sender2 = new CalItipMessageSender(modifiedItem2, null); + + const result2 = sender2.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem2); + Assert.equal(result2, 1, "return value should indicate there are pending messages"); + Assert.equal(sender2.pendingMessageCount, 1, "there should be one pending message"); + + const [msg2] = sender2.pendingMessages; + Assert.equal(msg2.method, "CANCEL", "deletion message method should be 'CANCEL'"); + Assert.equal(msg2.recipients.length, 1, "deletion message should have one recipient"); + + const [recipient2] = msg2.recipients; + Assert.equal( + recipient2.id, + cal.email.prependMailTo(newAttendeeEmail), + "for deletion message, recipient should be the non-organizer attendee" + ); +}); + +add_task(async function testAddAdditionalAttendee() { + const icalString = CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20210105T000000Z + DTSTAMP:20210501T000000Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Test Invitation + DTSTART:20210105T000000Z + DTEND:20210105T100000Z + STATUS:CONFIRMED + SUMMARY:Test Event + ORGANIZER;CN=${identityEmail}:${cal.email.prependMailTo(identityEmail)} + ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED; + RSVP=TRUE;CN=other@example.com;:mailto:other@example.com + ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED; + RSVP=TRUE;CN=${identityEmail};:${cal.email.prependMailTo(identityEmail)} + X-MOZ-SEND-INVITATIONS:TRUE + END:VEVENT + `; + + const item = new CalEvent(icalString); + const savedItem = await calendar.addItem(item); + + // Modify the event to include an attendee not in the original. + const newAttendeeEmail = "bar@example.com"; + const newAttendee = new CalAttendee(); + newAttendee.id = newAttendeeEmail; + + const organizer = new CalAttendee(); + organizer.isOrganizer = true; + organizer.id = identityEmail; + + const organizerAsAttendee = new CalAttendee(); + organizerAsAttendee.id = identityEmail; + + const targetItem = savedItem.clone(); + targetItem.addAttendee(newAttendee); + const modifiedItem = await calendar.modifyItem(targetItem, savedItem); + + // Test that adding an attendee won't cause messages to be sent to the + // existing attendees. + const sender = new CalItipMessageSender(savedItem, null); + + const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem); + Assert.equal(result, 1, "return value should indicate there are pending messages"); + Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message"); + + const [msg] = sender.pendingMessages; + Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'"); + Assert.equal(msg.recipients.length, 1, "message should have one recipient"); + + const [recipient] = msg.recipients; + Assert.equal( + recipient.id, + cal.email.prependMailTo(newAttendeeEmail), + "recipient should be the new attendee" + ); + + await calendar.deleteItem(modifiedItem); +}); + +add_task(async function testInvitationReceived() { + const item = createIncomingEvent(eventOrganizerEmail, identityEmail); + const savedItem = await calendar.addItem(item); + + const attendeeId = cal.email.prependMailTo(identityEmail); + + // Test that a sender with no original item and for which the current user is + // an attendee but not the organizer (representing a new incoming invitation) + // generates a single pending REPLY message on ADD. + const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId); + const sender = new CalItipMessageSender(null, currentUserAsAttendee); + + const result = sender.buildOutgoingMessages(Ci.calIOperationListener.ADD, savedItem); + Assert.equal(result, 1, "return value should indicate there are pending messages"); + Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message"); + + const [msg] = sender.pendingMessages; + Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'"); + Assert.equal(msg.recipients.length, 1, "message should have one recipient"); + + const [recipient] = msg.recipients; + Assert.equal( + recipient.id, + cal.email.prependMailTo(eventOrganizerEmail), + "recipient should be the event organizer" + ); + + const attendeeList = msg.item.getAttendees(); + Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message"); + + const [attendee] = attendeeList; + Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user"); + Assert.equal( + attendee.participationStatus, + "ACCEPTED", + "current user's participation status should be 'ACCEPTED'" + ); + + await calendar.deleteItem(savedItem); +}); + +add_task(async function testParticipationStatusUpdated() { + const item = createIncomingEvent(eventOrganizerEmail, identityEmail); + const savedItem = await calendar.addItem(item); + + const attendeeId = cal.email.prependMailTo(identityEmail); + + // Modify the event to update the user's participation status. + const targetItem = savedItem.clone(); + const currentUserAsAttendee = targetItem.getAttendeeById(attendeeId); + currentUserAsAttendee.participationStatus = "TENTATIVE"; + const modifiedItem = await calendar.modifyItem(targetItem, savedItem); + + // Test that a sender for which the current user is an attendee but not the + // organizer will generate a pending REPLY message on MODIFY. + const sender = new CalItipMessageSender(savedItem, currentUserAsAttendee); + const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem); + + Assert.equal(result, 1, "return value should indicate there are pending messages"); + Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message"); + + const [msg] = sender.pendingMessages; + Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'"); + Assert.equal(msg.recipients.length, 1, "message should have one recipient"); + + const [recipient] = msg.recipients; + Assert.equal( + recipient.id, + cal.email.prependMailTo(eventOrganizerEmail), + "recipient should be the event organizer" + ); + + const attendeeList = msg.item.getAttendees(); + Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message"); + + const [attendee] = attendeeList; + Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user"); + Assert.equal( + attendee.participationStatus, + "TENTATIVE", + "current user's participation status should be 'TENTATIVE'" + ); + + await calendar.deleteItem(modifiedItem); +}); + +add_task(async function testEventDeleted() { + const item = createIncomingEvent(eventOrganizerEmail, identityEmail); + const savedItem = await calendar.addItem(item); + + const attendeeId = cal.email.prependMailTo(identityEmail); + + await calendar.deleteItem(savedItem); + const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId); + + // Test that a sender with no original item and for which the current user is + // an attendee but not the organizer (representing the user deleting an event + // from their calendar) generates a single REPLY message to the organizer on + // DELETE. + const sender = new CalItipMessageSender(null, currentUserAsAttendee); + const result = sender.buildOutgoingMessages(Ci.calIOperationListener.DELETE, savedItem); + + Assert.equal(result, 1, "return value should indicate there are pending messages"); + Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message"); + + const [msg] = sender.pendingMessages; + Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'"); + Assert.equal(msg.recipients.length, 1, "message should have one recipient"); + + const [recipient] = msg.recipients; + Assert.equal( + recipient.id, + cal.email.prependMailTo(eventOrganizerEmail), + "recipient should be the event organizer" + ); + + const attendeeList = msg.item.getAttendees(); + Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message"); + + const [attendee] = attendeeList; + Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user"); + Assert.equal( + attendee.participationStatus, + "DECLINED", + "current user's participation status should be 'DECLINED'" + ); +}); diff --git a/comm/calendar/test/unit/test_itip_utils.js b/comm/calendar/test/unit/test_itip_utils.js new file mode 100644 index 0000000000..5c4678ab1d --- /dev/null +++ b/comm/calendar/test/unit/test_itip_utils.js @@ -0,0 +1,831 @@ +/* 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 { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +var { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalItipEmailTransport: "resource:///modules/CalItipEmailTransport.jsm", +}); + +// tests for calItipUtils.jsm + +do_get_profile(); + +/* + * Helper function to get an ics for testing sequence and stamp comparison + * + * @param {String} aAttendee - A serialized ATTENDEE property + * @param {String} aSequence - A serialized SEQUENCE property + * @param {String} aDtStamp - A serialized DTSTAMP property + * @param {String} aXMozReceivedSequence - A serialized X-MOZ-RECEIVED-SEQUENCE property + * @param {String} aXMozReceivedDtStamp - A serialized X-MOZ-RECEIVED-STAMP property + */ +function getSeqStampTestIcs(aProperties) { + // we make sure to have a dtstamp property to get a valid ics + let dtStamp = "20150909T181048Z"; + let additionalProperties = ""; + aProperties.forEach(aProp => { + if (aProp.startsWith("DTSTAMP:")) { + dtStamp = aProp; + } else { + additionalProperties += "\r\n" + aProp; + } + }); + + return [ + "BEGIN:VCALENDAR", + "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN", + "VERSION:2.0", + "METHOD:REQUEST", + "BEGIN:VTIMEZONE", + "TZID:Europe/Berlin", + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:+0100", + "TZOFFSETTO:+0200", + "TZNAME:CEST", + "DTSTART:19700329T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:+0200", + "TZOFFSETTO:+0100", + "TZNAME:CET", + "DTSTART:19701025T030000", + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", + "END:STANDARD", + "END:VTIMEZONE", + "BEGIN:VEVENT", + "CREATED:20150909T180909Z", + "LAST-MODIFIED:20150909T181048Z", + dtStamp, + "UID:cb189fdc-ed47-4db6-a8d7-31a08802249d", + "SUMMARY:Test Event", + "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:organizer@example.net", + "ATTENDEE;RSVP=TRUE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:attende" + + "e@example.net" + + additionalProperties, + "DTSTART;TZID=Europe/Berlin:20150909T210000", + "DTEND;TZID=Europe/Berlin:20150909T220000", + "TRANSP:OPAQUE", + "LOCATION:Room 1", + "DESCRIPTION:Let us get together", + "URL:http://www.example.com", + "ATTACH:http://www.example.com", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); +} + +function getSeqStampTestItems(aTest) { + let items = []; + for (let input of aTest.input) { + if (input.item) { + // in this case, we need to return an event + let attendee = ""; + if ("attendee" in input.item && input.item.attendee != {}) { + let att = new CalAttendee(); + att.id = input.item.attendee.id || "mailto:otherattendee@example.net"; + if ("receivedSeq" in input.item.attendee && input.item.attendee.receivedSeq.length) { + att.setProperty("RECEIVED-SEQUENCE", input.item.attendee.receivedSeq); + } + if ("receivedStamp" in input.item.attendee && input.item.attendee.receivedStamp.length) { + att.setProperty("RECEIVED-DTSTAMP", input.item.attendee.receivedStamp); + } + } + let sequence = ""; + if ("sequence" in input.item && input.item.sequence.length) { + sequence = "SEQUENCE:" + input.item.sequence; + } + let dtStamp = "DTSTAMP:20150909T181048Z"; + if ("dtStamp" in input.item && input.item.dtStamp) { + dtStamp = "DTSTAMP:" + input.item.dtStamp; + } + let xMozReceivedSeq = ""; + if ("xMozReceivedSeq" in input.item && input.item.xMozReceivedSeq.length) { + xMozReceivedSeq = "X-MOZ-RECEIVED-SEQUENCE:" + input.item.xMozReceivedSeq; + } + let xMozReceivedStamp = ""; + if ("xMozReceivedStamp" in input.item && input.item.xMozReceivedStamp.length) { + xMozReceivedStamp = "X-MOZ-RECEIVED-DTSTAMP:" + input.item.xMozReceivedStamp; + } + let xMsAptSeq = ""; + if ("xMsAptSeq" in input.item && input.item.xMsAptSeq.length) { + xMsAptSeq = "X-MICROSOFT-CDO-APPT-SEQUENCE:" + input.item.xMsAptSeq; + } + let testItem = new CalEvent(); + testItem.icalString = getSeqStampTestIcs([ + attendee, + sequence, + dtStamp, + xMozReceivedSeq, + xMozReceivedStamp, + xMsAptSeq, + ]); + items.push(testItem); + } else { + // in this case, we need to return an attendee + let att = new CalAttendee(); + att.id = input.attendee.id || "mailto:otherattendee@example.net"; + if (input.attendee.receivedSeq && input.attendee.receivedSeq.length) { + att.setProperty("RECEIVED-SEQUENCE", input.attendee.receivedSeq); + } + if (input.attendee.receivedStamp && input.attendee.receivedStamp.length) { + att.setProperty("RECEIVED-DTSTAMP", input.attendee.receivedStamp); + } + items.push(att); + } + } + return items; +} + +add_task(function test_getMessageSender() { + let data = [ + { + input: null, + expected: null, + }, + { + input: {}, + expected: null, + }, + { + input: { author: "Sender 1 <sender1@example.net>" }, + expected: "sender1@example.net", + }, + ]; + for (let i = 1; i <= data.length; i++) { + let test = data[i - 1]; + equal(cal.itip.getMessageSender(test.input), test.expected, "(test #" + i + ")"); + } +}); + +add_task(function test_getSequence() { + // assigning an empty string results in not having the property in the ics here + let data = [ + { + input: [{ item: { sequence: "", xMozReceivedSeq: "" } }], + expected: 0, + }, + { + input: [{ item: { sequence: "0", xMozReceivedSeq: "" } }], + expected: 0, + }, + { + input: [{ item: { sequence: "", xMozReceivedSeq: "0" } }], + expected: 0, + }, + { + input: [{ item: { sequence: "1", xMozReceivedSeq: "" } }], + expected: 1, + }, + { + input: [{ item: { sequence: "", xMozReceivedSeq: "1" } }], + expected: 1, + }, + { + input: [{ attendee: { receivedSeq: "" } }], + expected: 0, + }, + { + input: [{ attendee: { receivedSeq: "0" } }], + expected: 0, + }, + { + input: [{ attendee: { receivedSeq: "1" } }], + expected: 1, + }, + ]; + for (let i = 1; i <= data.length; i++) { + let test = data[i - 1]; + let testItems = getSeqStampTestItems(test); + equal(cal.itip.getSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")"); + } +}); + +add_task(function test_getStamp() { + // assigning an empty string results in not having the property in the ics here. However, there + // must be always an dtStamp for item - if it's missing it will be set by the test code to make + // sure we get a valid ics + let data = [ + { + // !dtStamp && !xMozReceivedStamp => test default value + input: [{ item: { dtStamp: "", xMozReceivedStamp: "" } }], + expected: "20150909T181048Z", + }, + { + // dtStamp && !xMozReceivedStamp => dtStamp + input: [{ item: { dtStamp: "20150910T181048Z", xMozReceivedStamp: "" } }], + expected: "20150910T181048Z", + }, + { + // dtStamp && xMozReceivedStamp => xMozReceivedStamp + input: [{ item: { dtStamp: "20150909T181048Z", xMozReceivedStamp: "20150910T181048Z" } }], + expected: "20150910T181048Z", + }, + { + input: [{ attendee: { receivedStamp: "" } }], + expected: null, + }, + { + input: [{ attendee: { receivedStamp: "20150910T181048Z" } }], + expected: "20150910T181048Z", + }, + ]; + for (let i = 1; i <= data.length; i++) { + let test = data[i - 1]; + let result = cal.itip.getStamp(getSeqStampTestItems(test)[0]); + if (result) { + result = result.icalString; + } + equal(result, test.expected, "(test #" + i + ")"); + } +}); + +add_task(function test_compareSequence() { + // it is sufficient to test here with sequence for items - full test coverage for + // x-moz-received-sequence is already provided by test_compareSequence + let data = [ + { + // item1.seq == item2.seq + input: [{ item: { sequence: "2" } }, { item: { sequence: "2" } }], + expected: 0, + }, + { + // item1.seq > item2.seq + input: [{ item: { sequence: "3" } }, { item: { sequence: "2" } }], + expected: 1, + }, + { + // item1.seq < item2.seq + input: [{ item: { sequence: "2" } }, { item: { sequence: "3" } }], + expected: -1, + }, + { + // attendee1.seq == attendee2.seq + input: [{ attendee: { receivedSeq: "2" } }, { attendee: { receivedSeq: "2" } }], + expected: 0, + }, + { + // attendee1.seq > attendee2.seq + input: [{ attendee: { receivedSeq: "3" } }, { attendee: { receivedSeq: "2" } }], + expected: 1, + }, + { + // attendee1.seq < attendee2.seq + input: [{ attendee: { receivedSeq: "2" } }, { attendee: { receivedSeq: "3" } }], + expected: -1, + }, + { + // item.seq == attendee.seq + input: [{ item: { sequence: "2" } }, { attendee: { receivedSeq: "2" } }], + expected: 0, + }, + { + // item.seq > attendee.seq + input: [{ item: { sequence: "3" } }, { attendee: { receivedSeq: "2" } }], + expected: 1, + }, + { + // item.seq < attendee.seq + input: [{ item: { sequence: "2" } }, { attendee: { receivedSeq: "3" } }], + expected: -1, + }, + ]; + for (let i = 1; i <= data.length; i++) { + let test = data[i - 1]; + let testItems = getSeqStampTestItems(test); + equal(cal.itip.compareSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")"); + } +}); + +add_task(function test_compareStamp() { + // it is sufficient to test here with dtstamp for items - full test coverage for + // x-moz-received-stamp is already provided by test_compareStamp + let data = [ + { + // item1.stamp == item2.stamp + input: [{ item: { dtStamp: "20150910T181048Z" } }, { item: { dtStamp: "20150910T181048Z" } }], + expected: 0, + }, + { + // item1.stamp > item2.stamp + input: [{ item: { dtStamp: "20150911T181048Z" } }, { item: { dtStamp: "20150910T181048Z" } }], + expected: 1, + }, + { + // item1.stamp < item2.stamp + input: [{ item: { dtStamp: "20150910T181048Z" } }, { item: { dtStamp: "20150911T181048Z" } }], + expected: -1, + }, + { + // attendee1.stamp == attendee2.stamp + input: [ + { attendee: { receivedStamp: "20150910T181048Z" } }, + { attendee: { receivedStamp: "20150910T181048Z" } }, + ], + expected: 0, + }, + { + // attendee1.stamp > attendee2.stamp + input: [ + { attendee: { receivedStamp: "20150911T181048Z" } }, + { attendee: { receivedStamp: "20150910T181048Z" } }, + ], + expected: 1, + }, + { + // attendee1.stamp < attendee2.stamp + input: [ + { attendee: { receivedStamp: "20150910T181048Z" } }, + { attendee: { receivedStamp: "20150911T181048Z" } }, + ], + expected: -1, + }, + { + // item.stamp == attendee.stamp + input: [ + { item: { dtStamp: "20150910T181048Z" } }, + { attendee: { receivedStamp: "20150910T181048Z" } }, + ], + expected: 0, + }, + { + // item.stamp > attendee.stamp + input: [ + { item: { dtStamp: "20150911T181048Z" } }, + { attendee: { receivedStamp: "20150910T181048Z" } }, + ], + expected: 1, + }, + { + // item.stamp < attendee.stamp + input: [ + { item: { dtStamp: "20150910T181048Z" } }, + { attendee: { receivedStamp: "20150911T181048Z" } }, + ], + expected: -1, + }, + ]; + for (let i = 1; i <= data.length; i++) { + let test = data[i - 1]; + let testItems = getSeqStampTestItems(test); + equal(cal.itip.compareStamp(testItems[0], testItems[1]), test.expected, "(test #" + i + ")"); + } +}); + +add_task(function test_compare() { + // it is sufficient to test here with items only - full test coverage for attendees or + // item/attendee is already provided by test_compareSequence and test_compareStamp + let data = [ + { + // item1.seq == item2.seq && item1.stamp == item2.stamp + input: [ + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + ], + expected: 0, + }, + { + // item1.seq == item2.seq && item1.stamp > item2.stamp + input: [ + { item: { sequence: "2", dtStamp: "20150911T181048Z" } }, + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + ], + expected: 1, + }, + { + // item1.seq == item2.seq && item1.stamp < item2.stamp + input: [ + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + { item: { sequence: "2", dtStamp: "20150911T181048Z" } }, + ], + expected: -1, + }, + { + // item1.seq > item2.seq && item1.stamp == item2.stamp + input: [ + { item: { sequence: "3", dtStamp: "20150910T181048Z" } }, + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + ], + expected: 1, + }, + { + // item1.seq > item2.seq && item1.stamp > item2.stamp + input: [ + { item: { sequence: "3", dtStamp: "20150911T181048Z" } }, + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + ], + expected: 1, + }, + { + // item1.seq > item2.seq && item1.stamp < item2.stamp + input: [ + { item: { sequence: "3", dtStamp: "20150910T181048Z" } }, + { item: { sequence: "2", dtStamp: "20150911T181048Z" } }, + ], + expected: 1, + }, + { + // item1.seq < item2.seq && item1.stamp == item2.stamp + input: [ + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + { item: { sequence: "3", dtStamp: "20150910T181048Z" } }, + ], + expected: -1, + }, + { + // item1.seq < item2.seq && item1.stamp > item2.stamp + input: [ + { item: { sequence: "2", dtStamp: "20150911T181048Z" } }, + { item: { sequence: "3", dtStamp: "20150910T181048Z" } }, + ], + expected: -1, + }, + { + // item1.seq < item2.seq && item1.stamp < item2.stamp + input: [ + { item: { sequence: "2", dtStamp: "20150910T181048Z" } }, + { item: { sequence: "3", dtStamp: "20150911T181048Z" } }, + ], + expected: -1, + }, + ]; + for (let i = 1; i <= data.length; i++) { + let test = data[i - 1]; + let testItems = getSeqStampTestItems(test); + equal(cal.itip.compare(testItems[0], testItems[1]), test.expected, "(test #" + i + ")"); + } +}); + +add_task(function test_getAttendeesBySender() { + let data = [ + { + input: { + attendees: [ + { id: "mailto:user1@example.net", sentBy: null }, + { id: "mailto:user2@example.net", sentBy: null }, + ], + sender: "user1@example.net", + }, + expected: ["mailto:user1@example.net"], + }, + { + input: { + attendees: [ + { id: "mailto:user1@example.net", sentBy: null }, + { id: "mailto:user2@example.net", sentBy: null }, + ], + sender: "user3@example.net", + }, + expected: [], + }, + { + input: { + attendees: [ + { id: "mailto:user1@example.net", sentBy: "mailto:user3@example.net" }, + { id: "mailto:user2@example.net", sentBy: null }, + ], + sender: "user3@example.net", + }, + expected: ["mailto:user1@example.net"], + }, + { + input: { + attendees: [ + { id: "mailto:user1@example.net", sentBy: null }, + { id: "mailto:user2@example.net", sentBy: "mailto:user1@example.net" }, + ], + sender: "user1@example.net", + }, + expected: ["mailto:user1@example.net", "mailto:user2@example.net"], + }, + { + input: { attendees: [], sender: "user1@example.net" }, + expected: [], + }, + { + input: { + attendees: [ + { id: "mailto:user1@example.net", sentBy: null }, + { id: "mailto:user2@example.net", sentBy: null }, + ], + sender: "", + }, + expected: [], + }, + { + input: { + attendees: [ + { id: "mailto:user1@example.net", sentBy: null }, + { id: "mailto:user2@example.net", sentBy: null }, + ], + sender: null, + }, + expected: [], + }, + ]; + + for (let i = 1; i <= data.length; i++) { + let test = data[i - 1]; + let attendees = []; + for (let att of test.input.attendees) { + let attendee = new CalAttendee(); + attendee.id = att.id; + if (att.sentBy) { + attendee.setProperty("SENT-BY", att.sentBy); + } + attendees.push(attendee); + } + let detected = []; + cal.itip.getAttendeesBySender(attendees, test.input.sender).forEach(att => { + detected.push(att.id); + }); + ok( + detected.every(aId => test.expected.includes(aId)), + "(test #" + i + " ok1)" + ); + ok( + test.expected.every(aId => detected.includes(aId)), + "(test #" + i + " ok2)" + ); + } +}); + +add_task(function test_resolveDelegation() { + let data = [ + { + input: { + attendee: + 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";CN="Attendee 1":mailto:at' + + "tendee1@example.net", + attendees: [ + 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";CN="Attendee 1":mailto:at' + + "tendee1@example.net", + 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net";CN="Attendee 2":mailto:atte' + + "ndee2@example.net", + ], + }, + expected: { + delegatees: "", + delegators: "Attendee 2 <attendee2@example.net>", + }, + }, + { + input: { + attendee: + 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net":mailto:attendee1@example.net', + attendees: [ + 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net":mailto:attendee1@example.net', + 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net":mailto:attendee2@example.net', + ], + }, + expected: { + delegatees: "", + delegators: "attendee2@example.net", + }, + }, + { + input: { + attendee: + 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net";CN="Attendee 1":mailto:atte' + + "ndee1@example.net", + attendees: [ + 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net";CN="Attendee 1":mailto:atte' + + "ndee1@example.net", + 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net";CN="Attendee 2":mailto:at' + + "tendee2@example.net", + ], + }, + expected: { + delegatees: "Attendee 2 <attendee2@example.net>", + delegators: "", + }, + }, + { + input: { + attendee: + 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net":mailto:attendee1@example.net', + attendees: [ + 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net":mailto:attendee1@example.net', + 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net":mailto:attendee2@example.net', + ], + }, + expected: { + delegatees: "attendee2@example.net", + delegators: "", + }, + }, + { + input: { + attendee: "ATTENDEE:mailto:attendee1@example.net", + attendees: [ + "ATTENDEE:mailto:attendee1@example.net", + "ATTENDEE:mailto:attendee2@example.net", + ], + }, + expected: { + delegatees: "", + delegators: "", + }, + }, + { + input: { + attendee: + 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";DELEGATED-TO="mailto:atte' + + 'ndee3@example.net":mailto:attendee1@example.net', + attendees: [ + 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";DELEGATED-TO="mailto:atte' + + 'ndee3@example.net":mailto:attendee1@example.net', + 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net":mailto:attendee2@example.net', + 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net":mailto:attendee3@example.net', + ], + }, + expected: { + delegatees: "attendee3@example.net", + delegators: "attendee2@example.net", + }, + }, + ]; + let i = 0; + for (let test of data) { + i++; + let attendees = []; + for (let att of test.input.attendees) { + let attendee = new CalAttendee(); + attendee.icalString = att; + attendees.push(attendee); + } + let attendee = new CalAttendee(); + attendee.icalString = test.input.attendee; + let result = cal.itip.resolveDelegation(attendee, attendees); + equal(result.delegatees, test.expected.delegatees, "(test #" + i + " - delegatees)"); + equal(result.delegators, test.expected.delegators, "(test #" + i + " - delegators)"); + } +}); + +/** + * Tests the various ways to use the getInvitedAttendee function. + */ +add_task(async function test_getInvitedAttendee() { + class MockCalendar { + supportsScheduling = true; + + constructor(invitedAttendee) { + this.invitedAttendee = invitedAttendee; + } + + getSchedulingSupport() { + return this; + } + + getInvitedAttendee() { + return this.invitedAttendee; + } + } + + let invitedAttendee = new CalAttendee(); + invitedAttendee.id = "mailto:invited@example.com"; + + let calendar = new MockCalendar(invitedAttendee); + let event = new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20210105T000000Z + DTSTAMP:20210501T000000Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Test Invitation + DTSTART:20210105T000000Z + DTEND:20210105T100000Z + STATUS:CONFIRMED + SUMMARY:Test Event + ORGANIZER;CN=events@example.com:mailto:events@example.com + ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED; + RSVP=TRUE;CN=invited@example.com;:mailto:invited@example.com + END:VEVENT + `); + + // No calendar configured or provided. + Assert.ok( + !cal.itip.getInvitedAttendee(event), + "returns falsy when item has no calendar and none provided" + ); + + // No calendar configured but one provided. + Assert.ok( + cal.itip.getInvitedAttendee(event, calendar) == invitedAttendee, + "returns the result from the provided calendar when item has none configured" + ); + + // Calendar configured, none provided. + event.calendar = calendar; + Assert.ok( + cal.itip.getInvitedAttendee(event) == invitedAttendee, + "returns the result of the item's calendar when calendar not provided" + ); + + // Calendar configured, one provided. + Assert.ok( + !cal.itip.getInvitedAttendee(event, new MockCalendar()), + "returns the result of the provided calendar even if item's calendar is configured" + ); + + // Calendar does not implement nsISchedulingSupport. + calendar.supportsScheduling = false; + Assert.ok( + !cal.itip.getInvitedAttendee(event), + "returns falsy if the calendar does not indicate nsISchedulingSupport" + ); + + // X-MOZ-INVITED-ATTENDEE set on event. + event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:invited@example.com"); + + let attendee = cal.itip.getInvitedAttendee(event); + Assert.ok( + attendee && attendee.id == "mailto:invited@example.com", + "returns the attendee matching X-MOZ-INVITED-ATTENDEE if set" + ); + + // X-MOZ-INVITED-ATTENDEE set to non-existent attendee + event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:nobody@example.com"); + Assert.ok( + !cal.itip.getInvitedAttendee(event), + "returns falsy for non-existent X-MOZ-INVITED-ATTENDEE" + ); +}); + +/** + * Tests the getImipTransport function returns the correct calIItipTransport. + */ +add_task(function test_getImipTransport() { + let event = new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20210105T000000Z + DTSTAMP:20210501T000000Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Test Invitation + DTSTART:20210105T000000Z + DTEND:20210105T100000Z + STATUS:CONFIRMED + SUMMARY:Test Event + END:VEVENT + `); + + // Without X-MOZ-INVITED-ATTENDEE property. + let account1 = MailServices.accounts.createAccount(); + let identity1 = MailServices.accounts.createIdentity(); + identity1.email = "id1@example.com"; + account1.addIdentity(identity1); + + let calendarTransport = new CalItipEmailTransport(account1, identity1); + event.calendar = { + getProperty(key) { + switch (key) { + case "itip.transport": + return calendarTransport; + case "imip.idenity": + return identity1; + default: + return null; + } + }, + }; + + Assert.ok( + cal.itip.getImipTransport(event) == calendarTransport, + "returns the calendar's transport when no X-MOZ-INVITED-ATTENDEE property" + ); + + // With X-MOZ-INVITED-ATTENDEE property. + let account2 = MailServices.accounts.createAccount(); + let identity2 = MailServices.accounts.createIdentity(); + identity2.email = "id2@example.com"; + account2.addIdentity(identity2); + account2.incomingServer = MailServices.accounts.createIncomingServer( + "id2", + "example.com", + "imap" + ); + + event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:id2@example.com"); + + let customTransport = cal.itip.getImipTransport(event); + Assert.ok(customTransport); + + Assert.ok( + customTransport.mDefaultAccount == account2, + "returns a transport using an account for the X-MOZ-INVITED-ATTENDEE identity when set" + ); + + Assert.ok( + customTransport.mDefaultIdentity == identity2, + "returns a transport using the identity of the X-MOZ-INVITED-ATTENDEE property when set" + ); +}); diff --git a/comm/calendar/test/unit/test_l10n_utils.js b/comm/calendar/test/unit/test_l10n_utils.js new file mode 100644 index 0000000000..98d93042fd --- /dev/null +++ b/comm/calendar/test/unit/test_l10n_utils.js @@ -0,0 +1,99 @@ +/* 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/. */ + +function run_test() { + do_calendar_startup(run_next_test); +} + +// tests for calL10NUtils.jsm +/* Incomplete - still missing test coverage for: + * getAnyString + * getString + * getCalString + * getLtnString + * getDateFmtString + * formatMonth + */ + +add_task(async function calendarInfo_test() { + let data = [ + { + input: { locale: "en-US" }, + expected: { + properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"], + }, + }, + { + input: { locale: "EN-US" }, + expected: { + properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"], + }, + }, + { + input: { locale: "et" }, + expected: { + properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"], + }, + }, + { + input: { locale: null }, // this also would trigger caching tests + expected: { + properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"], + }, + }, + ]; + let useOSLocaleFormat = Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales", false); + let osprefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(Ci.mozIOSPreferences); + let appLocale = Services.locale.appLocalesAsBCP47[0]; + let rsLocale = osprefs.regionalPrefsLocales[0]; + + let i = 0; + for (let test of data) { + i++; + let info = cal.l10n.calendarInfo(test.input.locale); + equal( + Object.keys(info).length, + test.expected.properties.length, + "expected number of attributes (test #" + i + ")" + ); + for (let prop of test.expected.properties) { + ok(prop in info, prop + " exists (test #" + i + ")"); + } + + if (!test.input.locale && appLocale != rsLocale) { + // if aLocale is null we test with the current date and time formatting setting + // let's test the caching mechanism - this test section is pointless if app and + // OS locale are the same like probably on automation + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", !useOSLocaleFormat); + let info2 = cal.l10n.calendarInfo(); + equal( + Object.keys(info).length, + test.expected.properties.length, + "caching test - equal number of properties (test #" + i + ")" + ); + for (let prop of Object.keys(info)) { + ok(prop in info2, "caching test - " + prop + " exists in both objects (test #" + i + ")"); + equal( + info2[prop], + info[prop], + "caching test - value for " + prop + " is equal in both objects (test #" + i + ")" + ); + } + // we reset the cache and test again - it's suffient here to find one changed property, + // so we use locale since that must change always in that scenario + // info2 = cal.l10n.calendarInfo(null, true); + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", useOSLocaleFormat); + // This is currently disabled since the code actually doesn't reset the cache anyway. + // When re-enabling, be aware that macOS returns just "en" for rsLocale while other + // OS provide "en-US". + /* + notEqual( + info2.locale, + info.locale, + "caching retest - value for locale is different in both objects (test #" + i + ")" + ); + */ + } + } +}); diff --git a/comm/calendar/test/unit/test_lenient_parsing.js b/comm/calendar/test/unit/test_lenient_parsing.js new file mode 100644 index 0000000000..7d20584996 --- /dev/null +++ b/comm/calendar/test/unit/test_lenient_parsing.js @@ -0,0 +1,41 @@ +/* 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/. */ + +/** + * Tests that ICAL.design.strict is set to false in both the main thread and + * the ICS parsing worker. If either or both is set to true, this will fail. + */ + +add_task(async function () { + const item = await new Promise((resolve, reject) => { + Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser).parseString( + dedent` + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:An event! + DTSTART:20240331 + DTEND:20240331 + END:VEVENT + END:VCALENDAR + `, + { + QueryInterface: ChromeUtils.generateQI(["calIIcsParsingListener"]), + onParsingComplete(rv, parser) { + if (Components.isSuccessCode(rv)) { + resolve(parser.getItems()[0]); + } else { + reject(rv); + } + }, + } + ); + }); + + Assert.equal(item.startDate.year, 2024); + Assert.equal(item.startDate.month, 2); + Assert.equal(item.startDate.day, 31); + Assert.equal(item.endDate.year, 2024); + Assert.equal(item.endDate.month, 2); + Assert.equal(item.endDate.day, 31); +}); diff --git a/comm/calendar/test/unit/test_providers.js b/comm/calendar/test/unit/test_providers.js new file mode 100644 index 0000000000..b800e47727 --- /dev/null +++ b/comm/calendar/test/unit/test_providers.js @@ -0,0 +1,426 @@ +/* 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/. */ + +/* eslint no-useless-concat: "off" */ + +var icalStringArray = [ + // Comments refer to the range defined in testGetItems(). + // 1: one-hour event + "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DTEND:20020402T124500Z\n" + "END:VEVENT\n", + // 2: Test a zero-length event with DTSTART and DTEND + "BEGIN:VEVENT\n" + "DTSTART:20020402T000000Z\n" + "DTEND:20020402T000000Z\n" + "END:VEVENT\n", + // 3: Test a zero-length event with DTSTART and no DTEND + "BEGIN:VEVENT\n" + "DTSTART:20020402T000000Z\n" + "END:VEVENT\n", + // 4: Test a zero-length event with DTEND set and no DTSTART. Invalid! + "BEGIN:VEVENT\n" + "DTEND:20020402T000000Z\n" + "END:VEVENT\n", + // 5: one-hour event that is outside the range + "BEGIN:VEVENT\n" + "DTSTART:20020401T114500Z\n" + "DTEND:20020401T124500Z\n" + "END:VEVENT\n", + // 6: one-hour event that starts outside the range and ends inside. + "BEGIN:VEVENT\n" + "DTSTART:20020401T114500Z\n" + "DTEND:20020402T124500Z\n" + "END:VEVENT\n", + // 7: one-hour event that starts inside the range and ends outside. + "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DTEND:20020403T124500Z\n" + "END:VEVENT\n", + // 8: one-hour event that starts at the end of the range. + "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DTEND:20020403T124500Z\n" + "END:VEVENT\n", + // 9: allday event that starts at start of range and ends at end of range. + "BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20020402\n" + + "DTEND;VALUE=DATE:20020403\n" + + "END:VEVENT\n", + // 10: allday event that starts at end of range. + "BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20020403\n" + + "DTEND;VALUE=DATE:20020404\n" + + "END:VEVENT\n", + // 11: allday event that ends at start of range. See bug 333363. + "BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20020401\n" + + "DTEND;VALUE=DATE:20020402\n" + + "END:VEVENT\n", + // 12: daily recurring allday event. parent item in the range. + "BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20020402\n" + + "DTEND;VALUE=DATE:20020403\n" + + "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10\n" + + "END:VEVENT\n", + // 13: daily recurring allday event. First occurrence in the range. + "BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20020401\n" + + "DTEND;VALUE=DATE:20020402\n" + + "RRULE:FREQ=DAILY;COUNT=10\n" + + "END:VEVENT\n", + // 14: two-daily recurring allday event. Not in the range. + "BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20020401\n" + + "DTEND;VALUE=DATE:20020402\n" + + "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=10\n" + + "END:VEVENT\n", + // 15: daily recurring one-hour event. Parent in the range. + "BEGIN:VEVENT\n" + + "DTSTART:20020402T100000Z\n" + + "DTEND:20020402T110000Z\n" + + "RRULE:FREQ=DAILY;COUNT=10\n" + + "END:VEVENT\n", + // 16: daily recurring one-hour event. Occurrence in the range. + "BEGIN:VEVENT\n" + + "DTSTART:20020401T100000Z\n" + + "DTEND:20020401T110000Z\n" + + "RRULE:FREQ=DAILY;COUNT=10\n" + + "END:VEVENT\n", + // 17: zero-length task with DTSTART and DUE set at start of range. + "BEGIN:VTODO\n" + "DTSTART:20020402T000000Z\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n", + // 18: zero-length event with only DTSTART set at start of range. + "BEGIN:VTODO\n" + "DTSTART:20020402T000000Z\n" + "END:VTODO\n", + // 19: zero-length event with only DUE set at start of range. + "BEGIN:VTODO\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n", + // 20: one-hour todo within the range. + "BEGIN:VTODO\n" + "DTSTART:20020402T110000Z\n" + "DUE:20020402T120000Z\n" + "END:VTODO\n", + // 21: zero-length todo that starts at end of range. + "BEGIN:VTODO\n" + "DTSTART:20020403T000000Z\n" + "DUE:20020403T010000Z\n" + "END:VTODO\n", + // 22: one-hour todo that ends at start of range. + "BEGIN:VTODO\n" + "DTSTART:20020401T230000Z\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n", + // 23: daily recurring one-hour event. Parent in the range. + "BEGIN:VEVENT\n" + + "DTSTART:20020402T000000\n" + + "DTEND:20020402T010000\n" + + "RRULE:FREQ=DAILY;COUNT=10\n" + + "END:VEVENT\n", + // 24: daily recurring 24-hour event. Parent in the range. + "BEGIN:VEVENT\n" + + "DTSTART:20020402T000000\n" + + "DTEND:20020403T000000\n" + + "RRULE:FREQ=DAILY;COUNT=10\n" + + "END:VEVENT\n", + // 25: todo that has neither start nor due date set. + // Should be returned on every getItems() call. See bug 405459. + "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "END:VTODO\n", + // 26: todo that has neither start nor due date but + // a completion time set after range. See bug 405459. + "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20030404T000001\n" + "END:VTODO\n", + // 27: todo that has neither start nor due date but a + // completion time set in the range. See bug 405459. + "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20020402T120001\n" + "END:VTODO\n", + // 28: todo that has neither start nor due date but a + // completion time set before the range. See bug 405459. + "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20020402T000000\n" + "END:VTODO\n", + // 29: todo that has neither start nor due date set, + // has the status "COMPLETED" but no completion time. See bug 405459. + "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "STATUS:COMPLETED\n" + "END:VTODO\n", + // 30: one-hour event with duration (in the range). See bug 390492. + "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n", + // 31: one-hour event with duration (after the range). See bug 390492. + "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n", + // 32: one-hour event with duration (before the range). See bug 390492. + "BEGIN:VEVENT\n" + "DTSTART:20020401T230000Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n", + // 33: one-day event with duration. Starts in the range, Ends outside. See bug 390492. + "BEGIN:VEVENT\n" + "DTSTART:20020402T120000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n", + // 34: one-day event with duration. Starts before the range. Ends inside. See bug 390492. + "BEGIN:VEVENT\n" + "DTSTART:20020401T120000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n", + // 35: one-day event with duration (before the range). See bug 390492. + "BEGIN:VEVENT\n" + "DTSTART:20020401T000000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n", + // 36: one-day event with duration (after the range). See bug 390492. + "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n", +]; + +add_task(async function testIcalData() { + // First entry is test number, second item is expected result for testGetItems(). + let wantedArray = [ + [1, 1], + [2, 1], + [3, 1], + [5, 0], + [6, 1], + [7, 1], + [8, 0], + [9, 1], + [10, 0], + [11, 0], + [12, 1], + [13, 1], + [14, 0], + [15, 1], + [16, 1], + [17, 1], + [18, 1], + [19, 1], + [20, 1], + [21, 0], + [22, 0], + [23, 1], + [24, 1], + [25, 1], + [26, 1], + [27, 1], + [28, 0], + [29, 1], + [30, 1], + [31, 0], + [32, 0], + [33, 1], + [34, 1], + [35, 0], + [36, 0], + ]; + + for (let i = 0; i < wantedArray.length; i++) { + let itemArray = wantedArray[i]; + // Correct for 1 to stay in synch with test numbers. + let calItem = icalStringArray[itemArray[0] - 1]; + + let item; + if (calItem.search(/VEVENT/) != -1) { + item = createEventFromIcalString(calItem); + } else if (calItem.search(/VTODO/) != -1) { + item = createTodoFromIcalString(calItem); + } + + print("Test " + wantedArray[i][0]); + await testGetItems(item, itemArray[1]); + await testGetItem(item); + } + + /** + * Adds aItem to a calendar and performs a getItems() call using the + * following range: + * 2002/04/02 0:00 - 2002/04/03 0:00 + * The amount of returned items is compared with expected amount (aResult). + * Additionally, the properties of the returned item are compared with aItem. + */ + async function testGetItems(aItem, aResult) { + for (let calendar of [getStorageCal(), getMemoryCal()]) { + await checkCalendar(calendar, aItem, aResult); + } + } + + async function checkCalendar(calendar, aItem, aResult) { + // add item to calendar + await calendar.addItem(aItem); + + // construct range + let rangeStart = createDate(2002, 3, 2); // 3 = April + let rangeEnd = rangeStart.clone(); + rangeEnd.day += 1; + + // filter options + let filter = + Ci.calICalendar.ITEM_FILTER_TYPE_ALL | + Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES | + Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + + // implement listener + let count = 0; + for await (let items of cal.iterate.streamValues( + calendar.getItems(filter, 0, rangeStart, rangeEnd) + )) { + if (items.length) { + count += items.length; + for (let i = 0; i < items.length; i++) { + // Don't check creationDate as it changed when we added the item to the database. + compareItemsSpecific(items[i].parentItem, aItem, [ + "start", + "end", + "duration", + "title", + "priority", + "privacy", + "status", + "alarmLastAck", + "recurrenceStartDate", + ]); + } + } + } + equal(count, aResult); + } + + /** + * (1) Add aItem to a calendar. + * The properties of the added item are compared with the passed item. + * (2) Perform a getItem() call. + * The properties of the returned item are compared with the passed item. + */ + async function testGetItem(aItem) { + // get calendars + let calArray = []; + calArray.push(getStorageCal()); + calArray.push(getMemoryCal()); + for (let calendar of calArray) { + let count = 0; + let returnedItem = null; + + let aDetail = await calendar.addItem(aItem); + compareItemsSpecific(aDetail, aItem); + // perform getItem() on calendar + returnedItem = await calendar.getItem(aDetail.id); + count = returnedItem ? 1 : 0; + + equal(count, 1); + // Don't check creationDate as it changed when we added the item to the database. + compareItemsSpecific(returnedItem, aItem, [ + "start", + "end", + "duration", + "title", + "priority", + "privacy", + "status", + "alarmLastAck", + "recurrenceStartDate", + ]); + } + } +}); + +add_task(async function testMetaData() { + async function testMetaData_(aCalendar) { + dump("testMetaData_() calendar type: " + aCalendar.type + "\n"); + let event1 = createEventFromIcalString( + "BEGIN:VEVENT\n" + "DTSTART;VALUE=DATE:20020402\n" + "END:VEVENT\n" + ); + + event1.id = "item1"; + await aCalendar.addItem(event1); + + aCalendar.setMetaData("item1", "meta1"); + equal(aCalendar.getMetaData("item1"), "meta1"); + equal(aCalendar.getMetaData("unknown"), null); + + let event2 = event1.clone(); + event2.id = "item2"; + await aCalendar.addItem(event2); + + aCalendar.setMetaData("item2", "meta2-"); + equal(aCalendar.getMetaData("item2"), "meta2-"); + + aCalendar.setMetaData("item2", "meta2"); + equal(aCalendar.getMetaData("item2"), "meta2"); + + let ids = aCalendar.getAllMetaDataIds(); + let values = aCalendar.getAllMetaDataValues(); + equal(values.length, 2); + equal(ids.length, 2); + ok(ids[0] == "item1" || ids[1] == "item1"); + ok(ids[0] == "item2" || ids[1] == "item2"); + ok(values[0] == "meta1" || values[1] == "meta1"); + ok(values[0] == "meta2" || values[1] == "meta2"); + + await aCalendar.deleteItem(event1); + + equal(aCalendar.getMetaData("item1"), null); + ids = aCalendar.getAllMetaDataIds(); + values = aCalendar.getAllMetaDataValues(); + equal(values.length, 1); + equal(ids.length, 1); + ok(ids[0] == "item2"); + ok(values[0] == "meta2"); + + aCalendar.deleteMetaData("item2"); + equal(aCalendar.getMetaData("item2"), null); + values = aCalendar.getAllMetaDataValues(); + ids = aCalendar.getAllMetaDataIds(); + equal(values.length, 0); + equal(ids.length, 0); + + aCalendar.setMetaData("item2", "meta2"); + equal(aCalendar.getMetaData("item2"), "meta2"); + await new Promise(resolve => { + aCalendar.QueryInterface(Ci.calICalendarProvider).deleteCalendar(aCalendar, { + onCreateCalendar: () => {}, + onDeleteCalendar: resolve, + }); + }); + values = aCalendar.getAllMetaDataValues(); + ids = aCalendar.getAllMetaDataIds(); + equal(values.length, 0); + equal(ids.length, 0); + + aCalendar.deleteMetaData("unknown"); // check graceful return + } + + await testMetaData_(getMemoryCal()); + await testMetaData_(getStorageCal()); +}); + +/* +async function testOfflineStorage(storageGetter, isRecurring) { + let storage = storageGetter(); + print(`Running offline storage test for ${storage.type} calendar for ${isRecurring ? "recurring" : "normal"} item`); + + let event1 = createEventFromIcalString("BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20020402\n" + + "DTEND;VALUE=DATE:20020403\n" + + "SUMMARY:event1\n" + + (isRecurring ? "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10\n" : "") + + "END:VEVENT\n"); + + event1 = await storage.addItem(event1); + + // Make sure the event is really in the calendar + let result = await storage.getAllItems(); + equal(result.length, 1); + + // When searching for offline added items, there are none + let filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED; + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 0); + + // Mark the item as offline added + await storage.addOfflineItem(event1); + + // Now there should be an offline item + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 1); + + let event2 = event1.clone(); + event2.title = "event2"; + + event2 = await storage.modifyItem(event2, event1); + + await storage.modifyOfflineItem(event2); + + // The flag should still be offline added, as it was already marked as such + filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED; + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 1); + + // Reset the flag + await storage.resetItemOfflineFlag(event2); + + // No more offline items after resetting the flag + filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED; + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 0); + + // Setting modify flag without one set should actually set that flag + await storage.modifyOfflineItem(event2); + filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED; + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 0); + + filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_MODIFIED; + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 1); + + // Setting the delete flag should modify the flag accordingly + await storage.deleteOfflineItem(event2); + filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_MODIFIED; + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 0); + + filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_DELETED; + result = await storage.getItems(filter, 0, null, null); + equal(result.length, 1); + + // Setting the delete flag on an offline added item should remove it + await storage.resetItemOfflineFlag(event2); + await storage.addOfflineItem(event2); + await storage.deleteOfflineItem(event2); + result = await storage.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null); + equal(result.length, 0); +} + +add_task(testOfflineStorage.bind(null, () => getMemoryCal(), false)); +add_task(testOfflineStorage.bind(null, () => getStorageCal(), false)); +add_task(testOfflineStorage.bind(null, () => getMemoryCal(), true)); +add_task(testOfflineStorage.bind(null, () => getStorageCal(), true)); +*/ diff --git a/comm/calendar/test/unit/test_recur.js b/comm/calendar/test/unit/test_recur.js new file mode 100644 index 0000000000..32033d5b85 --- /dev/null +++ b/comm/calendar/test/unit/test_recur.js @@ -0,0 +1,1361 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +function makeEvent(str) { + return createEventFromIcalString("BEGIN:VEVENT\n" + str + "END:VEVENT"); +} + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_interface(); + test_rrule_interface(); + test_rules(); + test_failures(); + test_limit(); + test_startdate_change(); + test_idchange(); + test_rrule_icalstring(); + test_immutable(); + test_icalComponent(); +} + +function test_rules() { + function check_recur(event, expected, endDate, ignoreNextOccCheck) { + dump("Checking '" + event.getProperty("DESCRIPTION") + "'\n"); + + // Immutability is required for testing the recurrenceEndDate property. + event.makeImmutable(); + + // Get recurrence dates + let start = createDate(1990, 0, 1); + let end = createDate(2020, 0, 1); + let recdates = event.recurrenceInfo.getOccurrenceDates(start, end, 0); + let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0); + + // Check number of items + dump("Expected " + expected.length + " occurrences\n"); + dump("Got: " + recdates.map(x => x.toString()) + "\n"); + equal(recdates.length, expected.length); + let fmt = cal.dtz.formatter; + + for (let i = 0; i < expected.length; i++) { + // Check each date + let expectedDate = cal.createDateTime(expected[i]); + dump( + "Expecting instance at " + expectedDate + "(" + fmt.dayName(expectedDate.weekday) + ")\n" + ); + dump("Recdate:"); + equal(recdates[i].icalString, expected[i]); + + // Make sure occurrences are correct + dump("Occurrence:"); + occurrences[i].QueryInterface(Ci.calIEvent); + equal(occurrences[i].startDate.icalString, expected[i]); + + if (ignoreNextOccCheck) { + continue; + } + + // Make sure getNextOccurrence works correctly + let nextOcc = event.recurrenceInfo.getNextOccurrence(recdates[i]); + if (expected.length > i + 1) { + notEqual(nextOcc, null); + dump("Checking next occurrence: " + expected[i + 1] + "\n"); + nextOcc.QueryInterface(Ci.calIEvent); + equal(nextOcc.startDate.icalString, expected[i + 1]); + } else { + dump("Expecting no more occurrences, found " + (nextOcc ? nextOcc.startDate : null) + "\n"); + equal(nextOcc, null); + } + + // Make sure getPreviousOccurrence works correctly + let prevOcc = event.recurrenceInfo.getPreviousOccurrence(recdates[i]); + if (i > 0) { + dump( + "Checking previous occurrence: " + + expected[i - 1] + + ", found " + + (prevOcc ? prevOcc.startDate : prevOcc) + + "\n" + ); + notEqual(prevOcc, null); + prevOcc.QueryInterface(Ci.calIEvent); + equal(prevOcc.startDate.icalString, expected[i - 1]); + } else { + dump( + "Expecting no previous occurrences, found " + + (prevOcc ? prevOcc.startDate : prevOcc) + + "\n" + ); + equal(prevOcc, null); + } + } + + if (typeof endDate == "string") { + endDate = cal.createDateTime(endDate).nativeTime; + } + equal(event.recurrenceInfo.recurrenceEndDate, endDate); + + // Make sure recurrenceInfo.clone works correctly + test_clone(event); + } + + // Test specific items/rules + check_recur( + makeEvent( + "DESCRIPTION:Repeat every tuesday and wednesday starting " + + "Tue 2nd April 2002\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TU,WE\n" + + "DTSTART:20020402T114500\n" + + "DTEND:20020402T124500\n" + ), + [ + "20020402T114500", + "20020403T114500", + "20020409T114500", + "20020410T114500", + "20020416T114500", + "20020417T114500", + ], + "20020417T124500" + ); + + check_recur( + makeEvent( + "DESCRIPTION:Repeat every thursday starting Tue 2nd April 2002\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TH\n" + + "DTSTART:20020402T114500\n" + + "DTEND:20020402T124500\n" + ), + [ + "20020402T114500", // DTSTART part of the resulting set + "20020404T114500", + "20020411T114500", + "20020418T114500", + "20020425T114500", + "20020502T114500", + "20020509T114500", + ], + "20020509T124500" + ); + + // Bug 469840 - Recurring Sundays incorrect + check_recur( + makeEvent( + "DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;BYDAY=WE,SA,SU with DTSTART:20081217T133000\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;BYDAY=WE,SA,SU\n" + + "DTSTART:20081217T133000\n" + + "DTEND:20081217T143000\n" + ), + [ + "20081217T133000", + "20081220T133000", + "20081221T133000", + "20081231T133000", + "20090103T133000", + "20090104T133000", + ], + "20090104T143000" + ); + + check_recur( + makeEvent( + "DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;WKST=SU;BYDAY=WE,SA,SU with DTSTART:20081217T133000\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;WKST=SU;BYDAY=WE,SA,SU\n" + + "DTSTART:20081217T133000\n" + + "DTEND:20081217T143000\n" + ), + [ + "20081217T133000", + "20081220T133000", + "20081228T133000", + "20081231T133000", + "20090103T133000", + "20090111T133000", + ], + "20090111T143000" + ); + + // bug 353797: occurrences for repeating all day events should stay "all-day" + check_recur( + makeEvent( + "DESCRIPTION:Allday repeat every thursday starting Tue 2nd April 2002\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=3;BYDAY=TH\n" + + "DTSTART;VALUE=DATE:20020404\n" + + "DTEND;VALUE=DATE:20020405\n" + ), + ["20020404", "20020411", "20020418"], + "20020419" + ); + + /* Test disabled, because BYWEEKNO is known to be broken + check_recur(makeEvent("DESCRIPTION:Monday of week number 20 (where the default start of the week is Monday)\n" + + "RRULE:FREQ=YEARLY;INTERVAL=1;COUNT=6;BYDAY=MO;BYWEEKNO=20\n" + + "DTSTART:19970512T090000", + ["19970512T090000", "19980511T090000", "19990517T090000" + + "20000515T090000", "20010514T090000", "20020513T090000"]); + */ + + // bug 899326: Recurrences with BYMONTHDAY=X,X,31 don't show at all in months with less than 31 days + check_recur( + makeEvent( + "DESCRIPTION:Every 11th & 31st of every Month\n" + + "RRULE:FREQ=MONTHLY;COUNT=6;BYMONTHDAY=11,31\n" + + "DTSTART:20130731T160000\n" + + "DTEND:20130731T170000\n" + ), + [ + "20130731T160000", + "20130811T160000", + "20130831T160000", + "20130911T160000", + "20131011T160000", + "20131031T160000", + ], + "20131031T170000" + ); + + // bug 899770: Monthly Recurrences with BYDAY and BYMONTHDAY with more than 2 dates are not working + check_recur( + makeEvent( + "DESCRIPTION:Every WE & SA the 6th, 20th & 31st\n" + + "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=WE,SA;BYMONTHDAY=6,20,31\n" + + "DTSTART:20130706T160000\n" + + "DTEND:20130706T170000\n" + ), + [ + "20130706T160000", + "20130720T160000", + "20130731T160000", + "20130831T160000", + "20131106T160000", + "20131120T160000", + ], + "20131120T170000" + ); + + check_recur( + makeEvent( + "DESCRIPTION:Every day, use exdate to exclude the second day\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART:20020402T114500Z\n" + + "EXDATE:20020403T114500Z\n" + ), + ["20020402T114500Z", "20020404T114500Z"], + "20020404T114500" + ); + + // test for issue 734245 + check_recur( + makeEvent( + "DESCRIPTION:Every day, use exdate of type DATE to exclude the second day\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART:20020402T114500Z\n" + + "EXDATE;VALUE=DATE:20020403\n" + ), + ["20020402T114500Z", "20020404T114500Z"], + "20020404T114500" + ); + + check_recur( + makeEvent( + "DESCRIPTION:Use EXDATE to eliminate the base event\n" + + "RRULE:FREQ=DAILY;COUNT=1\n" + + "DTSTART:20020402T114500Z\n" + + "EXDATE:20020402T114500Z\n" + ), + [], + -9223372036854775000 + ); + + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "UID:123\n" + + "DESCRIPTION:Every day, exception put on exdated day\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART:20020402T114500Z\n" + + "EXDATE:20020403T114500Z\n" + + "END:VEVENT\n" + + "BEGIN:VEVENT\n" + + "DTSTART:20020403T114500Z\n" + + "UID:123\n" + + "RECURRENCE-ID:20020404T114500Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + ["20020402T114500Z", "20020403T114500Z"], + "20020403T114500", + true + ); // ignore next occ check, bug 455490 + + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "UID:123\n" + + "DESCRIPTION:Every day, exception put on exdated start day\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART:20020402T114500Z\n" + + "EXDATE:20020402T114500Z\n" + + "END:VEVENT\n" + + "BEGIN:VEVENT\n" + + "DTSTART:20020402T114500Z\n" + + "UID:123\n" + + "RECURRENCE-ID:20020404T114500Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + ["20020402T114500Z", "20020403T114500Z"], + "20020403T114500", + true /* ignore next occ check, bug 455490 */ + ); + + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Daily on weekdays with UNTIL\n" + + "RRULE:FREQ=DAILY;UNTIL=20111217T220000Z;BYDAY=MO,TU,WE,TH,FR\n" + + "DTSTART:20111212T220000Z\n" + + "DTEND:20111212T230000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20111212T220000Z", + "20111213T220000Z", + "20111214T220000Z", + "20111215T220000Z", + "20111216T220000Z", + ], + "20111216T230000", + false + ); + + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Daily on weekdays with UNTIL and exception\n" + + "RRULE:FREQ=DAILY;UNTIL=20111217T220000Z;BYDAY=MO,TU,WE,TH,FR\n" + + "EXDATE:20111214T220000Z\n" + + "DTSTART:20111212T220000Z\n" + + "DTEND:20111212T230000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + ["20111212T220000Z", "20111213T220000Z", "20111215T220000Z", "20111216T220000Z"], + "20111216T230000", + false + ); + + // Bug 958978: Yearly recurrence, the last day of a specified month. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Yearly the last day of February\n" + + "RRULE:FREQ=YEARLY;COUNT=6;BYMONTHDAY=-1;BYMONTH=2\n" + + "DTSTART:20140228T220000Z\n" + + "DTEND:20140228T230000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20140228T220000Z", + "20150228T220000Z", + "20160229T220000Z", + "20170228T220000Z", + "20180228T220000Z", + "20190228T220000Z", + ], + "20190228T230000", + false + ); + + // Bug 958978: Yearly recurrence, the last day of a not specified month. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Yearly the last day of April without BYMONTH=4 in the rule\n" + + "RRULE:FREQ=YEARLY;COUNT=6;BYMONTHDAY=-1\n" + + "DTSTART:20140430T220000Z\n" + + "DTEND:20140430T230000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20140430T220000Z", + "20150430T220000Z", + "20160430T220000Z", + "20170430T220000Z", + "20180430T220000Z", + "20190430T220000Z", + ], + "20190430T230000", + false + ); + + // Bug 958978 - Check a yearly recurrence on every WE and FR of January and March + // (more BYMONTH and more BYDAY). + // Check for the occurrences in the first year. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Yearly every WE and FR of January and March (more BYMONTH and more BYDAY)\n" + + "RRULE:FREQ=YEARLY;COUNT=18;BYMONTH=1,3;BYDAY=WE,FR\n" + + "DTSTART:20140101T150000Z\n" + + "DTEND:20140101T160000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20140101T150000Z", + "20140103T150000Z", + "20140108T150000Z", + "20140110T150000Z", + "20140115T150000Z", + "20140117T150000Z", + "20140122T150000Z", + "20140124T150000Z", + "20140129T150000Z", + "20140131T150000Z", + "20140305T150000Z", + "20140307T150000Z", + "20140312T150000Z", + "20140314T150000Z", + "20140319T150000Z", + "20140321T150000Z", + "20140326T150000Z", + "20140328T150000Z", + ], + "20140328T160000", + false + ); + + // Bug 958978 - Check a yearly recurrence every day of January (BYMONTH and more BYDAY). + // Check for all the occurrences in the first year. + let expectedDates = []; + for (let i = 1; i < 32; i++) { + expectedDates.push("201401" + (i < 10 ? "0" + i : i) + "T150000Z"); + } + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Yearly, every day of January (one BYMONTH and more BYDAY)\n" + + "RRULE:FREQ=YEARLY;COUNT=31;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n" + + "DTSTART:20140101T150000Z\n" + + "DTEND:20140101T160000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + expectedDates, + "20140131T160000", + false + ); + + // Bug 958974 - Monthly recurrence every WE, FR and the third MO (monthly with more bydays). + // Check the occurrences in the first month until the week with the first monday of the rule. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Monthly every Wednesday, Friday and the third Monday\n" + + "RRULE:FREQ=MONTHLY;COUNT=8;BYDAY=3MO,WE,FR\n" + + "DTSTART:20150102T080000Z\n" + + "DTEND:20150102T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150102T080000Z", + "20150107T080000Z", + "20150109T080000Z", + "20150114T080000Z", + "20150116T080000Z", + "20150119T080000Z", + "20150121T080000Z", + "20150123T080000Z", + ], + "20150123T090000", + false + ); + + // Bug 419490 - Monthly recurrence, the fifth Saturday starting from February. + // Check a monthly rule that specifies a day that is not part of the month + // the events starts in. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Monthly the fifth Saturday\n" + + "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=5SA\n" + + "DTSTART:20150202T080000Z\n" + + "DTEND:20150202T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150202T080000Z", + "20150530T080000Z", + "20150829T080000Z", + "20151031T080000Z", + "20160130T080000Z", + "20160430T080000Z", + "20160730T080000Z", + ], + "20160730T090000", + false + ); + + // Bug 419490 - Monthly recurrence, the fifth Wednesday every two months starting from February. + // Check a monthly rule that specifies a day that is not part of the month + // the events starts in. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Monthly the fifth Friday every two months\n" + + "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6;BYDAY=5FR\n" + + "DTSTART:20150202T080000Z\n" + + "DTEND:20150202T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150202T080000Z", + "20151030T080000Z", + "20160429T080000Z", + "20161230T080000Z", + "20170630T080000Z", + "20171229T080000Z", + "20180629T080000Z", + ], + "20180629T090000", + false + ); + + // Bugs 419490, 958974 - Monthly recurrence, the 2nd Monday, 5th Wednesday and the 5th to last Saturday every month starting from February. + // Check a monthly rule that specifies a day that is not part of the month + // the events starts in with positive and negative position along with other byday. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Repeat Monthly the 2nd Monday, 5th Wednesday and the 5th to last Saturday every month\n" + + "RRULE:FREQ=MONTHLY;COUNT=7;BYDAY=2MO,-5WE,5SA\n" + + "DTSTART:20150401T080000Z\n" + + "DTEND:20150401T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150401T080000Z", + "20150413T080000Z", + "20150511T080000Z", + "20150530T080000Z", + "20150608T080000Z", + "20150701T080000Z", + "20150713T080000Z", + ], + "20150713T090000", + false + ); + + // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of March. + // Check the first occurrence when we have BYDAY along with BYMONTHDAY. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" + + "RRULE:FREQ=MONTHLY;BYDAY=MO,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" + + "DTSTART:20150301T080000Z\n" + + "DTEND:20150301T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150301T080000Z", + "20150309T080000Z", + "20150313T080000Z", + "20150323T080000Z", + "20150327T080000Z", + ], + "20150327T090000", + false + ); + + // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of April. + // Check the first occurrence when we have BYDAY along with BYMONTHDAY. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" + + "RRULE:FREQ=MONTHLY;BYDAY=MO,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" + + "DTSTART:20150401T080000Z\n" + + "DTEND:20150401T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150401T080000Z", + "20150403T080000Z", + "20150413T080000Z", + "20150417T080000Z", + "20150427T080000Z", + ], + "20150427T090000", + false + ); + + // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of April. + // Check the first occurrence when we have BYDAY along with BYMONTHDAY. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" + + "RRULE:FREQ=MONTHLY;BYDAY=MO,SA;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" + + "DTSTART:20150401T080000Z\n" + + "DTEND:20150401T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150401T080000Z", + "20150411T080000Z", + "20150413T080000Z", + "20150425T080000Z", + "20150427T080000Z", + ], + "20150427T090000", + false + ); + + // Bug 1146500 - Monthly every SU and FR when are odd days starting from 28 of February (BYDAY and BYMONTHDAY). + // Check the first occurrence when we have BYDAY along with BYMONTHDAY. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Monthly recurrence, every SU and FR when are odd days starting from the 1st of March\n" + + "RRULE:FREQ=MONTHLY;BYDAY=SU,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=9\n" + + "DTSTART:20150228T080000Z\n" + + "DTEND:20150228T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20150228T080000Z", + "20150301T080000Z", + "20150313T080000Z", + "20150315T080000Z", + "20150327T080000Z", + "20150329T080000Z", + "20150403T080000Z", + "20150405T080000Z", + "20150417T080000Z", + "20150419T080000Z", + ], + "20150419T090000", + false + ); + + // Bug 1103187 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence day taken + // from the start date. Check four occurrences. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Only Monthly recurrence\n" + + "RRULE:FREQ=MONTHLY;COUNT=4\n" + + "DTSTART:20160404T080000Z\n" + + "DTEND:20160404T090000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + ["20160404T080000Z", "20160504T080000Z", "20160604T080000Z", "20160704T080000Z"], + "20160704T090000", + false + ); + + // Bug 1265554 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence on the 31st + // of the month. Check for 6 occurrences. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Only Monthly recurrence, the 31st\n" + + "RRULE:FREQ=MONTHLY;COUNT=6\n" + + "DTSTART:20160131T150000Z\n" + + "DTEND:20160131T160000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20160131T150000Z", + "20160331T150000Z", + "20160531T150000Z", + "20160731T150000Z", + "20160831T150000Z", + "20161031T150000Z", + ], + "20161031T160000", + false + ); + + // Bug 1265554 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence on the 31st + // of the month every two months. Check for 6 occurrences. + check_recur( + createEventFromIcalString( + "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DESCRIPTION:Only Monthly recurrence, the 31st every 2 months\n" + + "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6\n" + + "DTSTART:20151231T150000Z\n" + + "DTEND:20151231T160000Z\n" + + "END:VEVENT\nEND:VCALENDAR\n" + ), + [ + "20151231T150000Z", + "20160831T150000Z", + "20161031T150000Z", + "20161231T150000Z", + "20170831T150000Z", + "20171031T150000Z", + ], + "20171031T160000", + false + ); + + let item, occ1; + item = makeEvent( + "DESCRIPTION:occurrence on day 1 moved between the occurrences " + + "on days 2 and 3\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART:20020402T114500Z\n" + ); + occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0)); + occ1.QueryInterface(Ci.calIEvent); + occ1.startDate = createDate(2002, 3, 3, true, 12, 0, 0); + item.recurrenceInfo.modifyException(occ1, true); + check_recur( + item, + ["20020403T114500Z", "20020403T120000Z", "20020404T114500Z"], + "20020404T114500" + ); + + item = makeEvent( + "DESCRIPTION:occurrence on day 1 moved between the occurrences " + + "on days 2 and 3, EXDATE on day 2\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART:20020402T114500Z\n" + + "EXDATE:20020403T114500Z\n" + ); + occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0)); + occ1.QueryInterface(Ci.calIEvent); + occ1.startDate = createDate(2002, 3, 3, true, 12, 0, 0); + item.recurrenceInfo.modifyException(occ1, true); + check_recur(item, ["20020403T120000Z", "20020404T114500Z"], "20020404T114500"); + + item = makeEvent( + "DESCRIPTION:all occurrences have exceptions\n" + + "RRULE:FREQ=DAILY;COUNT=2\n" + + "DTSTART:20020402T114500Z\n" + ); + occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0)); + occ1.QueryInterface(Ci.calIEvent); + occ1.startDate = createDate(2002, 3, 2, true, 12, 0, 0); + item.recurrenceInfo.modifyException(occ1, true); + let occ2 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 3, true, 11, 45, 0)); + occ2.QueryInterface(Ci.calIEvent); + occ2.startDate = createDate(2002, 3, 3, true, 12, 0, 0); + item.recurrenceInfo.modifyException(occ2, true); + check_recur(item, ["20020402T120000Z", "20020403T120000Z"], "20020403T114500"); + + item = makeEvent( + "DESCRIPTION:rdate and exception before the recurrence start date\n" + + "RRULE:FREQ=DAILY;COUNT=2\n" + + "DTSTART:20020402T114500Z\n" + + "RDATE:20020401T114500Z\n" + ); + occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0)); + occ1.QueryInterface(Ci.calIEvent); + occ1.startDate = createDate(2002, 2, 30, true, 11, 45, 0); + item.recurrenceInfo.modifyException(occ1, true); + check_recur( + item, + ["20020330T114500Z", "20020401T114500Z", "20020403T114500Z"], + "20020403T114500" + ); + + item = makeEvent( + "DESCRIPTION:bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART:20020401T114500Z\n" + + "EXDATE;VALUE=DATE:20020402\n" + ); + check_recur(item, ["20020401T114500Z", "20020403T114500Z"], "20020403T114500"); + + item = makeEvent( + "DESCRIPTION:EXDATE with a timezone\n" + + "RRULE:FREQ=DAILY;COUNT=3\n" + + "DTSTART;TZID=Europe/Berlin:20020401T114500\n" + + "EXDATE;TZID=Europe/Berlin:20020402T114500\n" + ); + check_recur(item, ["20020401T114500", "20020403T114500"], "20020403T094500"); + + // Unsupported SECONDLY FREQ value. + item = makeEvent( + "DESCRIPTION:bug 1770984\nRRULE:FREQ=SECONDLY;COUNT=60\nDTSTART:20220606T114500Z\n" + ); + check_recur(item, [], "20220606T114500Z"); + + // Unsupported MINUTELY FREQ value. + item = makeEvent( + "DESCRIPTION:bug 1770984\nRRULE:FREQ=MINUTELY;COUNT=60\nDTSTART:20220606T114500Z\n" + ); + check_recur(item, [], "20220606T114500Z"); +} + +function test_limit() { + let item = makeEvent( + "RRULE:FREQ=DAILY;COUNT=3\n" + + "UID:1\n" + + "DTSTART:20020401T114500\n" + + "DTEND:20020401T124500\n" + ); + dump("ics: " + item.icalString + "\n"); + + let start = createDate(1990, 0, 1); + let end = createDate(2020, 0, 1); + let recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 0); + let occurrences = item.recurrenceInfo.getOccurrences(start, end, 0); + + equal(recdates.length, 3); + equal(occurrences.length, 3); + + recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 2); + occurrences = item.recurrenceInfo.getOccurrences(start, end, 2); + + equal(recdates.length, 2); + equal(occurrences.length, 2); + + recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 9); + occurrences = item.recurrenceInfo.getOccurrences(start, end, 9); + + equal(recdates.length, 3); + equal(occurrences.length, 3); +} + +function test_clone(event) { + let oldRecurItems = event.recurrenceInfo.getRecurrenceItems(); + let cloned = event.recurrenceInfo.clone(); + let newRecurItems = cloned.getRecurrenceItems(); + + // Check number of recurrence items + equal(oldRecurItems.length, newRecurItems.length); + + for (let i = 0; i < oldRecurItems.length; i++) { + // Check if recurrence item cloned correctly + equal(oldRecurItems[i].icalProperty.icalString, newRecurItems[i].icalProperty.icalString); + } +} + +function test_interface() { + let item = makeEvent( + "DTSTART:20020402T114500Z\n" + + "DTEND:20020402T124500Z\n" + + "RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n" + + "EXDATE:20020403T114500Z\r\n" + + "RDATE:20020401T114500Z\r\n" + ); + + let rinfo = item.recurrenceInfo; + ok(cal.data.compareObjects(rinfo.item, item, Ci.calIEvent)); + + // getRecurrenceItems + let ritems = rinfo.getRecurrenceItems(); + equal(ritems.length, 3); + + let checkritems = new Map( + ritems.map(ritem => [ritem.icalProperty.propertyName, ritem.icalProperty]) + ); + let rparts = new Map( + checkritems + .get("RRULE") + .value.split(";") + .map(value => value.split("=", 2)) + ); + equal(rparts.size, 3); + equal(rparts.get("FREQ"), "WEEKLY"); + equal(rparts.get("COUNT"), "6"); + equal(rparts.get("BYDAY"), "TU,WE"); + equal(checkritems.get("EXDATE").value, "20020403T114500Z"); + equal(checkritems.get("RDATE").value, "20020401T114500Z"); + + // setRecurrenceItems + let newRItems = [cal.createRecurrenceRule(), cal.createRecurrenceDate()]; + + newRItems[0].type = "DAILY"; + newRItems[0].interval = 1; + newRItems[0].count = 1; + newRItems[1].isNegative = true; + newRItems[1].date = cal.createDateTime("20020404T114500Z"); + + rinfo.setRecurrenceItems(newRItems); + let itemString = item.icalString; + + equal(itemString.match(/RRULE:[A-Z=,]*FREQ=WEEKLY/), null); + equal(itemString.match(/EXDATE[A-Z;=-]*:20020403T114500Z/, null)); + equal(itemString.match(/RDATE[A-Z;=-]*:20020401T114500Z/, null)); + notEqual(itemString.match(/RRULE:[A-Z=,]*FREQ=DAILY/), null); + notEqual(itemString.match(/EXDATE[A-Z;=-]*:20020404T114500Z/, null)); + + // This may be an implementation detail, but we don't want this breaking + rinfo.wrappedJSObject.ensureSortedRecurrenceRules(); + equal( + rinfo.wrappedJSObject.mNegativeRules[0].icalProperty.icalString, + newRItems[1].icalProperty.icalString + ); + equal( + rinfo.wrappedJSObject.mPositiveRules[0].icalProperty.icalString, + newRItems[0].icalProperty.icalString + ); + + // countRecurrenceItems + equal(2, rinfo.countRecurrenceItems()); + + // clearRecurrenceItems + rinfo.clearRecurrenceItems(); + equal(0, rinfo.countRecurrenceItems()); + + // appendRecurrenceItems / getRecurrenceItemAt / insertRecurrenceItemAt + rinfo.appendRecurrenceItem(ritems[0]); + rinfo.appendRecurrenceItem(ritems[1]); + rinfo.insertRecurrenceItemAt(ritems[2], 0); + + ok(cal.data.compareObjects(ritems[2], rinfo.getRecurrenceItemAt(0), Ci.calIRecurrenceItem)); + ok(cal.data.compareObjects(ritems[0], rinfo.getRecurrenceItemAt(1), Ci.calIRecurrenceItem)); + ok(cal.data.compareObjects(ritems[1], rinfo.getRecurrenceItemAt(2), Ci.calIRecurrenceItem)); + + // deleteRecurrenceItem + rinfo.deleteRecurrenceItem(ritems[0]); + ok(!item.icalString.includes("RRULE")); + + // deleteRecurrenceItemAt + rinfo.deleteRecurrenceItemAt(1); + itemString = item.icalString; + ok(!itemString.includes("EXDATE")); + ok(itemString.includes("RDATE")); + + // insertRecurrenceItemAt with exdate + rinfo.insertRecurrenceItemAt(ritems[1], 1); + ok(cal.data.compareObjects(ritems[1], rinfo.getRecurrenceItemAt(1), Ci.calIRecurrenceItem)); + rinfo.deleteRecurrenceItem(ritems[1]); + + // isFinite = true + ok(rinfo.isFinite); + rinfo.appendRecurrenceItem(ritems[0]); + ok(rinfo.isFinite); + + // isFinite = false + let item2 = makeEvent( + // eslint-disable-next-line no-useless-concat + "DTSTART:20020402T114500Z\n" + "DTEND:20020402T124500Z\n" + "RRULE:FREQ=WEEKLY;BYDAY=TU,WE\n" + ); + ok(!item2.recurrenceInfo.isFinite); + + // removeOccurrenceAt/restoreOccurreceAt + let occDate1 = cal.createDateTime("20020403T114500Z"); + let occDate2 = cal.createDateTime("20020404T114500Z"); + rinfo.removeOccurrenceAt(occDate1); + ok(item.icalString.includes("EXDATE")); + rinfo.restoreOccurrenceAt(occDate1); + ok(!item.icalString.includes("EXDATE")); + + // modifyException / getExceptionFor + let occ1 = rinfo.getOccurrenceFor(occDate1); + occ1.QueryInterface(Ci.calIEvent); + occ1.startDate = cal.createDateTime("20020401T114500"); + rinfo.modifyException(occ1, true); + ok(rinfo.getExceptionFor(occDate1) != null); + + // modifyException immutable + let occ2 = rinfo.getOccurrenceFor(occDate2); + occ2.makeImmutable(); + rinfo.modifyException(occ2, true); + ok(rinfo.getExceptionFor(occDate2) != null); + + // getExceptionIds + let ids = rinfo.getExceptionIds(); + equal(ids.length, 2); + ok(ids[0].compare(occDate1) == 0); + ok(ids[1].compare(occDate2) == 0); + + // removeExceptionFor + rinfo.removeExceptionFor(occDate1); + ok(rinfo.getExceptionFor(occDate1) == null); + equal(rinfo.getExceptionIds().length, 1); +} + +function test_rrule_interface() { + let item = makeEvent( + "DTSTART:20020402T114500Z\r\n" + + "DTEND:20020402T124500Z\r\n" + + "RRULE:INTERVAL=2;FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n" + ); + + let rrule = item.recurrenceInfo.getRecurrenceItemAt(0); + rrule.QueryInterface(Ci.calIRecurrenceRule); + equal(rrule.type, "WEEKLY"); + equal(rrule.interval, 2); + equal(rrule.count, 6); + ok(rrule.isByCount); + ok(!rrule.isNegative); + ok(rrule.isFinite); + equal(rrule.getComponent("BYDAY").toString(), [3, 4].toString()); + + // Now start changing things + rrule.setComponent("BYDAY", [4, 5]); + equal(rrule.icalString.match(/BYDAY=WE,TH/), "BYDAY=WE,TH"); + + rrule.count = -1; + ok(!rrule.isByCount); + ok(!rrule.isFinite); + equal(rrule.icalString.match(/COUNT=/), null); + throws(() => rrule.count, /0x80004005/); + + rrule.interval = 1; + equal(rrule.interval, 1); + equal(rrule.icalString.match(/INTERVAL=/), null); + + rrule.interval = 3; + equal(rrule.interval, 3); + equal(rrule.icalString.match(/INTERVAL=3/), "INTERVAL=3"); + + rrule.type = "MONTHLY"; + equal(rrule.type, "MONTHLY"); + equal(rrule.icalString.match(/FREQ=MONTHLY/), "FREQ=MONTHLY"); + + // untilDate (without UTC) + rrule.count = 3; + let untilDate = cal.createDateTime(); + untilDate.timezone = cal.timezoneService.getTimezone("Europe/Berlin"); + rrule.untilDate = untilDate; + ok(!rrule.isByCount); + throws(() => rrule.count, /0x80004005/); + equal(rrule.untilDate.icalString, untilDate.getInTimezone(cal.dtz.UTC).icalString); + + // untilDate (with UTC) + rrule.count = 3; + untilDate = cal.createDateTime(); + untilDate.timezone = cal.dtz.UTC; + rrule.untilDate = untilDate; + ok(!rrule.isByCount); + throws(() => rrule.count, /0x80004005/); + equal(rrule.untilDate.icalString, untilDate.icalString); +} + +function test_startdate_change() { + // Setting a start date if its missing shouldn't throw + // eslint-disable-next-line no-useless-concat + let item = makeEvent("DTEND:20020402T124500Z\r\n" + "RRULE:FREQ=DAILY\r\n"); + item.startDate = cal.createDateTime("20020502T114500Z"); + + function makeRecEvent(str) { + // eslint-disable-next-line no-useless-concat + return makeEvent("DTSTART:20020402T114500Z\r\n" + "DTEND:20020402T134500Z\r\n" + str); + } + + function changeBy(changeItem, dur) { + let newDate = changeItem.startDate.clone(); + newDate.addDuration(cal.createDuration(dur)); + changeItem.startDate = newDate; + } + + let ritem; + + // Changing an existing start date for a recurring item shouldn't either + item = makeRecEvent("RRULE:FREQ=DAILY\r\n"); + changeBy(item, "PT1H"); + + // Event with an rdate + item = makeRecEvent("RDATE:20020403T114500Z\r\n"); + changeBy(item, "PT1H"); + ritem = item.recurrenceInfo.getRecurrenceItemAt(0); + ritem.QueryInterface(Ci.calIRecurrenceDate); + equal(ritem.date.icalString, "20020403T124500Z"); + + // Event with an exdate + item = makeRecEvent("EXDATE:20020403T114500Z\r\n"); + changeBy(item, "PT1H"); + ritem = item.recurrenceInfo.getRecurrenceItemAt(0); + ritem.QueryInterface(Ci.calIRecurrenceDate); + equal(ritem.date.icalString, "20020403T124500Z"); + + // Event with an rrule with until date + item = makeRecEvent("RRULE:FREQ=WEEKLY;UNTIL=20020406T114500Z\r\n"); + changeBy(item, "PT1H"); + ritem = item.recurrenceInfo.getRecurrenceItemAt(0); + ritem.QueryInterface(Ci.calIRecurrenceRule); + equal(ritem.untilDate.icalString, "20020406T124500Z"); + + // Event with an exception item + item = makeRecEvent("RRULE:FREQ=DAILY\r\n"); + let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20020406T114500Z")); + occ.QueryInterface(Ci.calIEvent); + occ.startDate = cal.createDateTime("20020406T124500Z"); + item.recurrenceInfo.modifyException(occ, true); + changeBy(item, "PT1H"); + equal(item.startDate.icalString, "20020402T124500Z"); + occ = item.recurrenceInfo.getExceptionFor(cal.createDateTime("20020406T124500Z")); + occ.QueryInterface(Ci.calIEvent); + equal(occ.startDate.icalString, "20020406T134500Z"); +} + +function test_idchange() { + let item = makeEvent( + "UID:unchanged\r\n" + + "DTSTART:20020402T114500Z\r\n" + + "DTEND:20020402T124500Z\r\n" + + "RRULE:FREQ=DAILY\r\n" + ); + let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20020406T114500Z")); + occ.QueryInterface(Ci.calIEvent); + occ.startDate = cal.createDateTime("20020406T124500Z"); + item.recurrenceInfo.modifyException(occ, true); + equal(occ.id, "unchanged"); + + item.id = "changed"; + + occ = item.recurrenceInfo.getExceptionFor(cal.createDateTime("20020406T114500Z")); + equal(occ.id, "changed"); +} + +function test_failures() { + let item = makeEvent( + "DTSTART:20020402T114500Z\r\n" + + "DTEND:20020402T124500Z\r\n" + + "RRULE:INTERVAL=2;FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n" + ); + let rinfo = item.recurrenceInfo; + let ritem = cal.createRecurrenceDate(); + + throws(() => rinfo.getRecurrenceItemAt(-1), /Illegal value/, "Invalid Argument"); + throws(() => rinfo.getRecurrenceItemAt(1), /Illegal value/, "Invalid Argument"); + throws(() => rinfo.deleteRecurrenceItemAt(-1), /Illegal value/, "Invalid Argument"); + throws(() => rinfo.deleteRecurrenceItemAt(1), /Illegal value/, "Invalid Argument"); + throws(() => rinfo.deleteRecurrenceItem(ritem), /Illegal value/, "Invalid Argument"); + throws(() => rinfo.insertRecurrenceItemAt(ritem, -1), /Illegal value/, "Invalid Argument"); + throws(() => rinfo.insertRecurrenceItemAt(ritem, 2), /Illegal value/, "Invalid Argument"); + throws( + () => rinfo.restoreOccurrenceAt(cal.createDateTime("20080101T010101")), + /Illegal value/, + "Invalid Argument" + ); + throws(() => new CalRecurrenceInfo().isFinite, /Component not initialized/); + + // modifyException with a different parent item + let occ = rinfo.getOccurrenceFor(cal.createDateTime("20120102T114500Z")); + occ.calendar = {}; + occ.id = "1234"; + occ.parentItem = occ; + throws(() => rinfo.modifyException(occ, true), /Illegal value/, "Invalid Argument"); + + occ = rinfo.getOccurrenceFor(cal.createDateTime("20120102T114500Z")); + occ.recurrenceId = null; + throws(() => rinfo.modifyException(occ, true), /Illegal value/, "Invalid Argument"); + + // Missing DTSTART/DUE but RRULE + item = createTodoFromIcalString( + "BEGIN:VCALENDAR\r\n" + + "BEGIN:VTODO\r\n" + + "RRULE:FREQ=DAILY\r\n" + + "END:VTODO\r\n" + + "END:VCALENDAR\r\n" + ); + rinfo = item.recurrenceInfo; + equal( + rinfo.getOccurrenceDates( + cal.createDateTime("20120101T010101"), + cal.createDateTime("20120203T010101"), + 0 + ).length, + 0 + ); +} + +function test_immutable() { + let item = createTodoFromIcalString( + "BEGIN:VCALENDAR\r\n" + + "BEGIN:VTODO\r\n" + + "RRULE:FREQ=DAILY\r\n" + + "END:VTODO\r\n" + + "END:VCALENDAR\r\n" + ); + ok(item.recurrenceInfo.isMutable); + let rinfo = item.recurrenceInfo.clone(); + let ritem = cal.createRecurrenceDate(); + rinfo.makeImmutable(); + rinfo.makeImmutable(); // Doing so twice shouldn't throw + throws(() => rinfo.appendRecurrenceItem(ritem), /Can not modify immutable data container/); + ok(!rinfo.isMutable); + + item.recurrenceInfo.appendRecurrenceItem(ritem); +} + +function test_rrule_icalstring() { + let recRule = cal.createRecurrenceRule(); + recRule.type = "DAILY"; + recRule.interval = 4; + equal(recRule.icalString, "RRULE:FREQ=DAILY;INTERVAL=4\r\n"); + + recRule = cal.createRecurrenceRule(); + recRule.type = "DAILY"; + recRule.setComponent("BYDAY", [2, 3, 4, 5, 6]); + equal(recRule.icalString, "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR\r\n"); + deepEqual(recRule.getComponent("BYDAY"), [2, 3, 4, 5, 6]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "WEEKLY"; + recRule.interval = 3; + recRule.setComponent("BYDAY", [2, 4, 6]); + equal(recRule.icalString, "RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO,WE,FR\r\n"); + deepEqual(recRule.getComponent("BYDAY"), [2, 4, 6]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "MONTHLY"; + recRule.setComponent("BYDAY", [2, 3, 4, 5, 6, 7, 1]); + equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR,SA,SU\r\n"); + deepEqual(recRule.getComponent("BYDAY"), [2, 3, 4, 5, 6, 7, 1]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "MONTHLY"; + recRule.setComponent("BYDAY", [10]); + equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=1MO\r\n"); + deepEqual(recRule.getComponent("BYDAY"), [10]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "MONTHLY"; + recRule.setComponent("BYDAY", [20]); + equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=2WE\r\n"); + deepEqual(recRule.getComponent("BYDAY"), [20]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "MONTHLY"; + recRule.setComponent("BYDAY", [-22]); + equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=-2FR\r\n"); + deepEqual(recRule.getComponent("BYDAY"), [-22]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "MONTHLY"; + recRule.setComponent("BYMONTHDAY", [5]); + equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYMONTHDAY=5\r\n"); + deepEqual(recRule.getComponent("BYMONTHDAY"), [5]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "MONTHLY"; + recRule.setComponent("BYMONTHDAY", [1, 9, 17]); + equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYMONTHDAY=1,9,17\r\n"); + deepEqual(recRule.getComponent("BYMONTHDAY"), [1, 9, 17]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "YEARLY"; + recRule.setComponent("BYMONTH", [1]); + recRule.setComponent("BYMONTHDAY", [3]); + ok( + [ + "RRULE:FREQ=YEARLY;BYMONTHDAY=3;BYMONTH=1\r\n", + "RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=3\r\n", + ].includes(recRule.icalString) + ); + deepEqual(recRule.getComponent("BYMONTH"), [1]); + deepEqual(recRule.getComponent("BYMONTHDAY"), [3]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "YEARLY"; + recRule.setComponent("BYMONTH", [4]); + recRule.setComponent("BYDAY", [3]); + ok( + [ + "RRULE:FREQ=YEARLY;BYDAY=TU;BYMONTH=4\r\n", + "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=TU\r\n", + ].includes(recRule.icalString) + ); + deepEqual(recRule.getComponent("BYMONTH"), [4]); + deepEqual(recRule.getComponent("BYDAY"), [3]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "YEARLY"; + recRule.setComponent("BYMONTH", [4]); + recRule.setComponent("BYDAY", [10]); + ok( + [ + "RRULE:FREQ=YEARLY;BYDAY=1MO;BYMONTH=4\r\n", + "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1MO\r\n", + ].includes(recRule.icalString) + ); + deepEqual(recRule.getComponent("BYMONTH"), [4]); + deepEqual(recRule.getComponent("BYDAY"), [10]); + + recRule = cal.createRecurrenceRule(); + recRule.type = "YEARLY"; + recRule.setComponent("BYMONTH", [4]); + recRule.setComponent("BYDAY", [-22]); + ok( + [ + "RRULE:FREQ=YEARLY;BYDAY=-2FR;BYMONTH=4\r\n", + "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-2FR\r\n", + ].includes(recRule.icalString) + ); + deepEqual(recRule.getComponent("BYMONTH"), [4]); + deepEqual(recRule.getComponent("BYDAY"), [-22]); +} + +function test_icalComponent() { + let duration = "PT3600S"; + let eventString = + "DESCRIPTION:Repeat every Thursday starting Tue 2nd April 2002\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TH\n" + + "DTSTART:20020402T114500\n" + + `DURATION:${duration}\n`; + + let firstOccurrenceDate = createDate(2002, 4, 4, true, 11, 45, 0); + + // Test each of these cases from the conditional in the icalComponent getter. + // * mIsProxy = true, value === null + // * mIsProxy = true, value !== null + // * mIsProxy = false, value === null + // * mIsProxy = false, value !== null + // + // Create a proxy for a given occurrence, modify properties on the proxy + // (checking before and after), then call the icalComponent getter to see + // whether both parent item and proxy item have the correct properties. + + let parent = makeEvent(eventString); + let proxy = parent.recurrenceInfo.getOccurrenceFor(firstOccurrenceDate); + + equal(parent.getProperty("DURATION"), duration); + equal(proxy.getProperty("DURATION"), duration); + + equal(parent.getProperty("LOCATION"), null); + equal(proxy.getProperty("LOCATION"), null); + + let newDuration = "PT2200S"; + let location = "Sherwood Forest"; + + proxy.setProperty("DURATION", newDuration); + proxy.setProperty("LOCATION", location); + + equal(parent.getProperty("DURATION"), duration); + equal(proxy.getProperty("DURATION"), newDuration); + + equal(parent.getProperty("LOCATION"), null); + equal(proxy.getProperty("LOCATION"), location); + + equal(parent.icalComponent.duration.toString(), duration); + equal(proxy.icalComponent.duration.toString(), newDuration); + + equal(parent.icalComponent.location, null); + equal(proxy.icalComponent.location, location); + + // Test for bug 580896. + + let event = makeEvent(eventString); + equal(event.getProperty("DURATION"), duration, "event has correct DURATION"); + + let occurrence = event.recurrenceInfo.getOccurrenceFor(firstOccurrenceDate); + + equal(occurrence.getProperty("DURATION"), duration, "occurrence has correct DURATION"); + equal(Boolean(occurrence.getProperty("DTEND")), true, "occurrence has DTEND"); + + ok(occurrence.icalComponent.duration, "occurrence icalComponent has DURATION"); + + // Changing the end date causes the duration to be set to null. + occurrence.endDate = createDate(2002, 4, 3); + + equal(occurrence.getProperty("DURATION"), null, "occurrence DURATION has been set to null"); + + ok(!occurrence.icalComponent.duration, "occurrence icalComponent does not have DURATION"); +} diff --git a/comm/calendar/test/unit/test_recurrence_utils.js b/comm/calendar/test/unit/test_recurrence_utils.js new file mode 100644 index 0000000000..53f1aaf99f --- /dev/null +++ b/comm/calendar/test/unit/test_recurrence_utils.js @@ -0,0 +1,371 @@ +/* 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 { countOccurrences } = ChromeUtils.import( + "resource:///modules/calendar/calRecurrenceUtils.jsm" +); + +function run_test() { + do_calendar_startup(run_next_test); +} + +// tests for calRecurrenceUtils.jsm +/* Incomplete - still missing test coverage for: + * recurrenceRule2String + * splitRecurrenceRules + * checkRecurrenceRule + */ + +function getIcs(aProperties) { + let calendar = [ + "BEGIN:VCALENDAR", + "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN", + "VERSION:2.0", + "BEGIN:VTIMEZONE", + "TZID:Europe/Berlin", + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:+0100", + "TZOFFSETTO:+0200", + "TZNAME:CEST", + "DTSTART:19700329T020000", + "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:+0200", + "TZOFFSETTO:+0100", + "TZNAME:CET", + "DTSTART:19701025T030000", + "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10", + "END:STANDARD", + "END:VTIMEZONE", + ]; + calendar = calendar.concat(aProperties); + calendar = calendar.concat(["END:VCALENDAR"]); + + return calendar.join("\r\n"); +} + +add_task(async function countOccurrences_test() { + let data = [ + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98000", + "SUMMARY:Occurring 3 times until a date", + "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 3, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98001", + "SUMMARY:Occurring 3 times until a date with one exception in the middle", + "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z", + "EXDATE;TZID=Europe/Berlin:20180921T120000", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 2, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98002", + "SUMMARY:Occurring 3 times until a date with one exception at the end", + "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z", + "EXDATE;TZID=Europe/Berlin:20180922T120000", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 2, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98003", + "SUMMARY:Occurring 3 times until a date with one exception at the beginning", + "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z", + "EXDATE;TZID=Europe/Berlin:20180920T120000", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 2, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004", + "SUMMARY:Occurring 3 times until a date with the middle occurrence moved after the end", + "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004", + "SUMMARY:The moved occurrence", + "RECURRENCE-ID:20180921T100000Z", + "DTSTART;TZID=Europe/Berlin:20180924T120000", + "DTEND;TZID=Europe/Berlin:20180924T130000", + "END:VEVENT", + ], + expected: 3, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005", + "SUMMARY:Occurring 3 times until a date with the middle occurrence moved before the beginning", + "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005", + "SUMMARY:The moved occurrence", + "RECURRENCE-ID:20180921T100000Z", + "DTSTART;TZID=Europe/Berlin:20180918T120000", + "DTEND;TZID=Europe/Berlin:20180918T130000", + "END:VEVENT", + ], + expected: 3, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98006", + "SUMMARY:Occurring 1 times until a date", + "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 1, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98007", + "SUMMARY:Occurring 1 times until a date with occernce removed", + "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z", + "EXDATE;TZID=Europe/Berlin:20180920T120000", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 0, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98008", + "SUMMARY:Occurring for 3 times", + "RRULE:FREQ=DAILY;COUNT=3", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 3, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98009", + "SUMMARY:Occurring for 3 times with an exception in the middle", + "EXDATE;TZID=Europe/Berlin:20180921T120000", + "RRULE:FREQ=DAILY;COUNT=3", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 2, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98010", + "SUMMARY:Occurring for 3 times with an exception at the end", + "EXDATE;TZID=Europe/Berlin:20180922T120000", + "RRULE:FREQ=DAILY;COUNT=3", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 2, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98011", + "SUMMARY:Occurring for 3 times with an exception at the beginning", + "EXDATE;TZID=Europe/Berlin:20180920T120000", + "RRULE:FREQ=DAILY;COUNT=3", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 2, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98012", + "SUMMARY:Occurring for 1 time", + "RRULE:FREQ=DAILY;COUNT=1", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 1, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98013", + "SUMMARY:Occurring for 0 times", + "RRULE:FREQ=DAILY;COUNT=1", + "EXDATE;TZID=Europe/Berlin:20180920T120000", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 0, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98014", + "SUMMARY:Occurring infinitely", + "RRULE:FREQ=DAILY", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: null, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98015", + "SUMMARY:Non-occurring item", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: null, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98016", + "SUMMARY:Occurring for 3 time and 1 rdate", + "RRULE:FREQ=DAILY;COUNT=3", + "RDATE;TZID=Europe/Berlin:20180923T100000", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 4, + }, + { + input: [ + "BEGIN:VEVENT", + "CREATED:20180912T090539Z", + "LAST-MODIFIED:20180912T090539Z", + "DTSTAMP:20180912T090539Z", + "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98017", + "SUMMARY:Occurring for 3 rdates", + "RDATE;TZID=Europe/Berlin:20180920T120000", + "RDATE;TZID=Europe/Berlin:20180921T100000", + "RDATE;TZID=Europe/Berlin:20180922T140000", + "DTSTART;TZID=Europe/Berlin:20180920T120000", + "DTEND;TZID=Europe/Berlin:20180920T130000", + "END:VEVENT", + ], + expected: 3, + }, + ]; + + let i = 0; + for (let test of data) { + i++; + + let ics = getIcs(test.input); + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + parser.parseString(ics); + let items = parser.getItems(); + + ok(items.length > 0, "parsing input succeeded (test #" + i + ")"); + for (let item of items) { + equal( + countOccurrences(item), + test.expected, + "expected number of occurrences (test #" + i + " - '" + item.title + "')" + ); + } + } +}); diff --git a/comm/calendar/test/unit/test_relation.js b/comm/calendar/test/unit/test_relation.js new file mode 100644 index 0000000000..148d5a6118 --- /dev/null +++ b/comm/calendar/test/unit/test_relation.js @@ -0,0 +1,133 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalRelation: "resource:///modules/CalRelation.jsm", +}); + +function run_test() { + // Create Relation + let relation1 = new CalRelation(); + + // Create Items + let event1 = new CalEvent(); + let event2 = new CalEvent(); + + // Testing relation set/get. + let properties = { + relType: "PARENT", + relId: event2.id, + }; + + for (let [property, value] of Object.entries(properties)) { + relation1[property] = value; + equal(relation1[property], value); + } + + // Add relation to event + event1.addRelation(relation1); + + // Add 2nd relation to event. + let relation2 = new CalRelation(); + relation2.relId = "myid2"; + event1.addRelation(relation2); + + // Check the item functions + checkRelations(event1, [relation1, relation2]); + + // modify the Relations + modifyRelations(event1, [relation1, relation2]); + + // test icalproperty + // eslint-disable-next-line no-unused-expressions + relation2.icalProperty; + + test_icalprop(); +} + +function checkRelations(event, expRel) { + let allRel = event.getRelations(); + equal(allRel.length, expRel.length); + + // check if all expacted relations are found + for (let i = 0; i < expRel.length; i++) { + ok(allRel.includes(expRel[i])); + } + + // Check if all found relations are expected + for (let i = 0; i < allRel.length; i++) { + ok(expRel.includes(allRel[i])); + } +} + +function modifyRelations(event, oldRel) { + let allRel = event.getRelations(); + let rel = allRel[0]; + + // modify the properties + rel.relType = "SIBLING"; + equal(rel.relType, "SIBLING"); + equal(rel.relType, allRel[0].relType); + + // remove one relation + event.removeRelation(rel); + equal(event.getRelations().length, oldRel.length - 1); + + // add one relation and remove all relations + event.addRelation(oldRel[0]); + event.removeAllRelations(); + equal(event.getRelations(), 0); +} + +function test_icalprop() { + let rel = new CalRelation(); + + rel.relType = "SIBLING"; + rel.setParameter("X-PROP", "VAL"); + rel.relId = "value"; + + let prop = rel.icalProperty; + let propOrig = rel.icalProperty; + + equal(rel.icalString, prop.icalString); + + equal(prop.value, "value"); + equal(prop.getParameter("X-PROP"), "VAL"); + equal(prop.getParameter("RELTYPE"), "SIBLING"); + + prop.value = "changed"; + prop.setParameter("RELTYPE", "changedtype"); + prop.setParameter("X-PROP", "changedxprop"); + + equal(rel.relId, "value"); + equal(rel.getParameter("X-PROP"), "VAL"); + equal(rel.relType, "SIBLING"); + + rel.icalProperty = prop; + + equal(rel.relId, "changed"); + equal(rel.getParameter("X-PROP"), "changedxprop"); + equal(rel.relType, "changedtype"); + + rel.icalString = propOrig.icalString; + + equal(rel.relId, "value"); + equal(rel.getParameter("X-PROP"), "VAL"); + equal(rel.relType, "SIBLING"); + + let rel2 = rel.clone(); + rel.icalProperty = prop; + + notEqual(rel.icalString, rel2.icalString); + + rel.deleteParameter("X-PROP"); + equal(rel.icalProperty.getParameter("X-PROP"), null); + + throws(() => { + rel.icalString = "X-UNKNOWN:value"; + }, /Illegal value/); +} diff --git a/comm/calendar/test/unit/test_rfc3339_parser.js b/comm/calendar/test/unit/test_rfc3339_parser.js new file mode 100644 index 0000000000..8098bdddf6 --- /dev/null +++ b/comm/calendar/test/unit/test_rfc3339_parser.js @@ -0,0 +1,188 @@ +/* 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/. */ + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + // Check if the RFC 3339 date and timezone are properly parsed to the + // expected result and if the result is properly mapped back into the RFC + // 3339 date. + function testRfc3339( + aRfc3339Date, + aTimezone, + aExpectedDateTime, + aExpectedRfc3339Date = aRfc3339Date + ) { + // Test creating a dateTime object from an RFC 3339 string. + let dateTime = cal.dtz.fromRFC3339(aRfc3339Date, aTimezone); + + // Check that each property is as expected. + let expectedDateProps = { + year: aExpectedDateTime[0], + month: aExpectedDateTime[1] - 1, // 0 based month. + day: aExpectedDateTime[2], + hour: aExpectedDateTime[3], + minute: aExpectedDateTime[4], + second: aExpectedDateTime[5], + timezone: aExpectedDateTime[6], + isDate: aExpectedDateTime[7], + }; + for (let prop in expectedDateProps) { + info("Checking prop: " + prop); + // Object comparison fails with ical.js, and we only want to check + // that we have the right timezone. + if (prop == "timezone") { + equal(dateTime[prop].tzid, expectedDateProps[prop].tzid); + } else { + equal(dateTime[prop], expectedDateProps[prop]); + } + } + + // Test round tripping that dateTime object back to an RFC 3339 string. + let rfc3339Date = cal.dtz.toRFC3339(dateTime); + + // In theory this should just match the input RFC 3339 date, but there are + // multiple ways of generating the same time, e.g. 2006-03-14Z is + // equivalent to 2006-03-14. + equal(rfc3339Date, aExpectedRfc3339Date); + } + + /* + * Some notes about the differences between calIDateTime and the RFC 3339 + * specification: + * 1. calIDateTime does not support fractions of a second, they are + * stripped. + * 2. If a timezone cannot be matched to the given time offset, the + * date/time is returned as a UTC date/time. + * 3. The first timezone (alphabetically) that has the same offset is + * chosen. + * 4. Leap seconds are not supported by calIDateTime, it resets to + * [0-23]:[0-59]:[0-59]. + * + * All tests are done under the default timezone and UTC (although both + * should give the same time). + */ + + // An arbitrary timezone (that has daylight savings time). + let getTz = aTz => cal.timezoneService.getTimezone(aTz); + let timezone = getTz("America/New_York"); + let utc = cal.dtz.UTC; + + // Timezones used in tests. This isn't a great representation, as we don't + // care what the actual timezone is, just that the offset is correct. Offset + // isn't presently easily accessible from the timezone object, however. + let utcminus6 = getTz("America/Bahia_Banderas"); + let dawson = getTz("America/Dawson"); + + /* + * Basic tests + */ + // This represents March 14, 2006 in the default timezone. + testRfc3339("2006-03-14", timezone, [2006, 3, 14, 0, 0, 0, timezone, true]); + testRfc3339("2006-03-14", utc, [2006, 3, 14, 0, 0, 0, utc, true]); + // This represents March 14, 2006 in UTC. + testRfc3339("2006-03-14Z", timezone, [2006, 3, 14, 0, 0, 0, utc, true], "2006-03-14"); + testRfc3339("2006-03-14Z", utc, [2006, 3, 14, 0, 0, 0, utc, true], "2006-03-14"); + + // This represents 30 minutes and 53 seconds past the 13th hour of November + // 14, 2050 in UTC. + testRfc3339( + "2050-11-14t13:30:53z", + timezone, + [2050, 11, 14, 13, 30, 53, utc, false], + "2050-11-14T13:30:53Z" + ); + testRfc3339( + "2050-11-14t13:30:53z", + utc, + [2050, 11, 14, 13, 30, 53, utc, false], + "2050-11-14T13:30:53Z" + ); + + // This represents 03:00:23 on October 14, 2004 in Central Standard Time. + testRfc3339("2004-10-14T03:00:23-06:00", timezone, [2004, 10, 14, 3, 0, 23, utcminus6, false]); + testRfc3339("2004-10-14T03:00:23-06:00", utc, [2004, 10, 14, 3, 0, 23, utcminus6, false]); + + /* + * The following tests are the RFC 3339 examples + * http://tools.ietf.org/html/rfc3339 + * Most of these would "fail" since iCalDateTime does not supported + * all parts of the specification, the true proper response is next to each + * test line as a comment. + */ + + // This represents 20 minutes and 50.52 seconds after the 23rd hour of + // April 12th, 1985 in UTC. + testRfc3339( + "1985-04-12T23:20:50.52Z", + timezone, + [1985, 4, 12, 23, 20, 50, utc, false], + "1985-04-12T23:20:50Z" + ); // 1985/04/12 23:20:50.52 UTC isDate=0 + testRfc3339( + "1985-04-12T23:20:50.52Z", + utc, + [1985, 4, 12, 23, 20, 50, utc, false], + "1985-04-12T23:20:50Z" + ); // 1985/04/12 23:20:50.52 UTC isDate=0 + + // This represents 39 minutes and 57 seconds after the 16th hour of December + // 19th, 1996 with an offset of -08:00 from UTC (Pacific Standard Time). + // Note that this is equivalent to in UTC. + testRfc3339("1996-12-19T16:39:57-08:00", timezone, [1996, 12, 19, 16, 39, 57, dawson, false]); + testRfc3339("1996-12-19T16:39:57-08:00", utc, [1996, 12, 19, 16, 39, 57, dawson, false]); + testRfc3339("1996-12-20T00:39:57Z", timezone, [1996, 12, 20, 0, 39, 57, utc, false]); + testRfc3339("1996-12-20T00:39:57Z", utc, [1996, 12, 20, 0, 39, 57, utc, false]); + + // This represents the same instant of time as noon, January 1, 1937, + // Netherlands time. Standard time in the Netherlands was exactly 19 minutes + // and 32.13 seconds ahead of UTC by law from 1909-05-01 through 1937-06-30. + // This time zone cannot be represented exactly using the HH:MM format, and + // this timestamp uses the closest representable UTC offset. + // + // Since no current timezone exists at +00:20 it will default to giving the + // time in UTC. + testRfc3339( + "1937-01-01T12:00:27.87+00:20", + timezone, + [1937, 1, 1, 12, 20, 27, utc, false], + "1937-01-01T12:20:27Z" + ); // 1937/01/01 12:20:27.87 UTC isDate=0 + testRfc3339( + "1937-01-01T12:00:27.87+00:20", + utc, + [1937, 1, 1, 12, 20, 27, utc, false], + "1937-01-01T12:20:27Z" + ); // 1937/01/01 12:20:27.87 UTC isDate=0 + + // This represents the leap second inserted at the end of 1990. + testRfc3339( + "1990-12-31T23:59:60Z", + timezone, + [1991, 1, 1, 0, 0, 0, utc, false], + "1991-01-01T00:00:00Z" + ); // 1990/12/31 23:59:60 UTC isDate=0 + testRfc3339( + "1990-12-31T23:59:60Z", + utc, + [1991, 1, 1, 0, 0, 0, utc, false], + "1991-01-01T00:00:00Z" + ); // 1990/12/31 23:59:60 UTC isDate=0 + // This represents the same leap second in Pacific Standard Time, 8 + // hours behind UTC. + testRfc3339( + "1990-12-31T15:59:60-08:00", + timezone, + [1990, 12, 31, 16, 0, 0, dawson, false], + "1990-12-31T16:00:00-08:00" + ); // 1990/12/31 15:59:60 America/Dawson isDate=0 + testRfc3339( + "1990-12-31T15:59:60-08:00", + utc, + [1990, 12, 31, 16, 0, 0, dawson, false], + "1990-12-31T16:00:00-08:00" + ); // 1990/12/31 15:59:60 America/Dawson isDate=0 +} diff --git a/comm/calendar/test/unit/test_startup_service.js b/comm/calendar/test/unit/test_startup_service.js new file mode 100644 index 0000000000..cfb3f76571 --- /dev/null +++ b/comm/calendar/test/unit/test_startup_service.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/. */ + +function run_test() { + let ssvc = Cc["@mozilla.org/calendar/startup-service;1"].getService(Ci.nsIObserver); + + let first = { + startup(aListener) { + second.canStart = true; + aListener.onResult(null, Cr.NS_OK); + }, + shutdown(aListener) { + ok(this.canStop); + aListener.onResult(null, Cr.NS_OK); + }, + }; + + let second = { + startup(aListener) { + ok(this.canStart); + aListener.onResult(null, Cr.NS_OK); + }, + shutdown(aListener) { + first.canStop = true; + aListener.onResult(null, Cr.NS_OK); + }, + }; + + // Change the startup order so we can test our services + let oldStartupOrder = ssvc.wrappedJSObject.getStartupOrder; + ssvc.wrappedJSObject.getStartupOrder = function () { + let origOrder = oldStartupOrder.call(this); + + let notify = origOrder[origOrder.length - 1]; + return [first, second, notify]; + }; + + // Pretend a startup run + ssvc.observe(null, "profile-after-change", null); + ok(second.canStart); + + // Pretend a stop run + ssvc.observe(null, "profile-before-change", null); + ok(first.canStop); +} diff --git a/comm/calendar/test/unit/test_storage.js b/comm/calendar/test/unit/test_storage.js new file mode 100644 index 0000000000..d4a42be428 --- /dev/null +++ b/comm/calendar/test/unit/test_storage.js @@ -0,0 +1,85 @@ +/* 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 () => { + await new Promise(resolve => { + do_calendar_startup(resolve); + }); + + let storage = getStorageCal(); + let str = [ + "BEGIN:VEVENT", + "UID:attachItem", + "DTSTART:20120101T010101Z", + "ATTACH;FMTTYPE=text/calendar;ENCODING=BASE64;FILENAME=test.ics:http://example.com/test.ics", + "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Name;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;X-THING=BAR:mailto:test@example.com", + "RELATED-TO;RELTYPE=SIBLING;FOO=BAR:VALUE", + "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5;BYDAY=MO", + "RDATE:20120201T010101Z", + "EXDATE:20120301T010101Z", + "END:VEVENT", + ].join("\r\n"); + + let storageItem = createEventFromIcalString(str); + + let addedItemId = (await storage.addItem(storageItem)).id; + + // Make sure the cache is cleared, otherwise we'll get the cached item. + delete storage.wrappedJSObject.mItemModel.itemCache[addedItemId]; + + let item = await storage.getItem(addedItemId); + + // Check start date + equal(item.startDate.compare(cal.createDateTime("20120101T010101Z")), 0); + + // Check attachment + let attaches = item.getAttachments(); + let attach = attaches[0]; + equal(attaches.length, 1); + equal(attach.uri.spec, "http://example.com/test.ics"); + equal(attach.formatType, "text/calendar"); + equal(attach.encoding, "BASE64"); + equal(attach.getParameter("FILENAME"), "test.ics"); + + // Check attendee + let attendees = item.getAttendees(); + let attendee = attendees[0]; + equal(attendees.length, 1); + equal(attendee.id, "mailto:test@example.com"); + equal(attendee.commonName, "Name"); + equal(attendee.rsvp, "TRUE"); + equal(attendee.isOrganizer, false); + equal(attendee.role, "REQ-PARTICIPANT"); + equal(attendee.participationStatus, "ACCEPTED"); + equal(attendee.userType, "INDIVIDUAL"); + equal(attendee.getProperty("X-THING"), "BAR"); + + // Check relation + let relations = item.getRelations(); + let rel = relations[0]; + equal(relations.length, 1); + equal(rel.relType, "SIBLING"); + equal(rel.relId, "VALUE"); + equal(rel.getParameter("FOO"), "BAR"); + + // Check recurrence item + for (let ritem of item.recurrenceInfo.getRecurrenceItems()) { + if (ritem instanceof Ci.calIRecurrenceRule) { + equal(ritem.type, "MONTHLY"); + equal(ritem.interval, 2); + equal(ritem.count, 5); + equal(ritem.isByCount, true); + equal(ritem.getComponent("BYDAY").toString(), [2].toString()); + equal(ritem.isNegative, false); + } else if (ritem instanceof Ci.calIRecurrenceDate) { + if (ritem.isNegative) { + equal(ritem.date.compare(cal.createDateTime("20120301T010101Z")), 0); + } else { + equal(ritem.date.compare(cal.createDateTime("20120201T010101Z")), 0); + } + } else { + do_throw("Found unknown recurrence item " + ritem); + } + } +}); diff --git a/comm/calendar/test/unit/test_storage_connection.js b/comm/calendar/test/unit/test_storage_connection.js new file mode 100644 index 0000000000..2984fa2dc4 --- /dev/null +++ b/comm/calendar/test/unit/test_storage_connection.js @@ -0,0 +1,127 @@ +/* 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_setup(async function () { + do_get_profile(); + await new Promise(resolve => cal.manager.startup({ onResult: resolve })); +}); + +/** + * Tests that local storage calendars share a database connection. + */ +add_task(async function testLocal() { + let localCalendarA = cal.manager.createCalendar( + "storage", + Services.io.newURI(`moz-storage-calendar://`) + ); + localCalendarA.id = cal.getUUID(); + let dbA = localCalendarA.wrappedJSObject.mStorageDb.db; + + let localCalendarB = cal.manager.createCalendar( + "storage", + Services.io.newURI(`moz-storage-calendar://`) + ); + localCalendarB.id = cal.getUUID(); + let dbB = localCalendarB.wrappedJSObject.mStorageDb.db; + + Assert.equal( + dbA.databaseFile.path, + PathUtils.join(PathUtils.profileDir, "calendar-data", "local.sqlite"), + "local calendar A uses the right database file" + ); + Assert.equal( + dbB.databaseFile.path, + PathUtils.join(PathUtils.profileDir, "calendar-data", "local.sqlite"), + "local calendar B uses the right database file" + ); + Assert.equal(dbA, dbB, "local calendars share a database connection"); +}); + +/** + * Tests that local storage calendars using the same specified database file share a connection, + * and that local storage calendars with a different specified database file do not. + */ +add_task(async function testLocalFile() { + let testFileA = new FileUtils.File(PathUtils.join(PathUtils.tempDir, "file-a.sqlite")); + let testFileB = new FileUtils.File(PathUtils.join(PathUtils.tempDir, "file-b.sqlite")); + + let fileCalendarA = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileA)); + fileCalendarA.id = cal.getUUID(); + let dbA = fileCalendarA.wrappedJSObject.mStorageDb.db; + + let fileCalendarB = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileB)); + fileCalendarB.id = cal.getUUID(); + let dbB = fileCalendarB.wrappedJSObject.mStorageDb.db; + + let fileCalendarC = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileA)); + fileCalendarC.id = cal.getUUID(); + let dbC = fileCalendarC.wrappedJSObject.mStorageDb.db; + + Assert.equal( + dbA.databaseFile.path, + testFileA.path, + "local calendar A uses the right database file" + ); + Assert.equal( + dbB.databaseFile.path, + testFileB.path, + "local calendar B uses the right database file" + ); + Assert.equal( + dbC.databaseFile.path, + testFileA.path, + "local calendar C uses the right database file" + ); + Assert.notEqual( + dbA, + dbB, + "calendars with different file URLs do not share a database connection" + ); + Assert.notEqual( + dbB, + dbC, + "calendars with different file URLs do not share a database connection" + ); + Assert.equal(dbA, dbC, "calendars with matching file URLs share a database connection"); +}); + +/** + * Tests that cached network calendars share a database connection. + */ +add_task(async function testNetwork() { + // Pretend to be offline so connecting to calendars that don't exist doesn't throw errors. + Services.io.offline = true; + + let networkCalendarA = cal.manager.createCalendar( + "ics", + Services.io.newURI("http://localhost/ics") + ); + networkCalendarA.id = cal.getUUID(); + networkCalendarA.setProperty("cache.enabled", true); + cal.manager.registerCalendar(networkCalendarA); + networkCalendarA = cal.manager.getCalendarById(networkCalendarA.id); + let dbA = networkCalendarA.wrappedJSObject.mCachedCalendar.wrappedJSObject.mStorageDb.db; + + let networkCalendarB = cal.manager.createCalendar( + "caldav", + Services.io.newURI("http://localhost/caldav") + ); + networkCalendarB.id = cal.getUUID(); + networkCalendarB.setProperty("cache.enabled", true); + cal.manager.registerCalendar(networkCalendarB); + networkCalendarB = cal.manager.getCalendarById(networkCalendarB.id); + let dbB = networkCalendarB.wrappedJSObject.mCachedCalendar.wrappedJSObject.mStorageDb.db; + + Assert.equal( + dbA.databaseFile.path, + PathUtils.join(PathUtils.profileDir, "calendar-data", "cache.sqlite"), + "network calendar A uses the right database file" + ); + Assert.equal( + dbB.databaseFile.path, + PathUtils.join(PathUtils.profileDir, "calendar-data", "cache.sqlite"), + "network calendar B uses the right database file" + ); + Assert.equal(dbA, dbB, "network calendars share a database connection"); +}); diff --git a/comm/calendar/test/unit/test_storage_get_items.js b/comm/calendar/test/unit/test_storage_get_items.js new file mode 100644 index 0000000000..828e59fecd --- /dev/null +++ b/comm/calendar/test/unit/test_storage_get_items.js @@ -0,0 +1,338 @@ +/* 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/. */ + +/** + * Tests for the CalStorageCalendar.getItems method. + */ + +const { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm"); + +do_get_profile(); + +/** + * The bug we are interested in testing requires the calendar to clear its + * caches in order to take effect. Since we can't directly access the internals + * of the calendar here, we instead provide a custom function that lets us + * create more than one calendar with the same id. + */ +function createStorageCalendar(id) { + let db = Services.dirsvc.get("TmpD", Ci.nsIFile); + db.append("test_storage.sqlite"); + let uri = Services.io.newFileURI(db); + + // Make sure timezone service is initialized + Cc["@mozilla.org/calendar/timezone-service;1"].getService(Ci.calIStartupService).startup(null); + + let calendar = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance( + Ci.calISyncWriteCalendar + ); + + calendar.uri = uri; + calendar.id = id; + return calendar; +} + +/** + * Tests that recurring event/todo exceptions have their properties properly + * loaded. See bug 1664731. + * + * @param {number} filterType - Number indicating the filter type. + * @param {calIITemBase} originalItem - The original item to add to the calendar. + * @param {object} originalProps - The initial properites of originalItem to + * expect. + * @param {object[]} changedProps - A list containing property values to update + * each occurrence with or null. The length indicates how many occurrences to + * expect. + */ +async function doPropertiesTest(filterType, originalItem, originalProps, changedPropList) { + for (let [key, value] of Object.entries(originalProps)) { + if (key == "CATEGORIES") { + originalItem.setCategories(value); + } else { + originalItem.setProperty(key, value); + } + } + + let calId = cal.getUUID(); + let calendar = createStorageCalendar(calId); + await calendar.addItem(originalItem); + + let filter = + filterType | + Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL | + Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + + let savedItems = await calendar.getItemsAsArray( + filter, + 0, + cal.createDateTime("20201201T000000Z"), + cal.createDateTime("20201231T000000Z") + ); + + Assert.equal( + savedItems.length, + changedPropList.length, + `created ${changedPropList.length} items successfully` + ); + + // Ensure all occurrences have the correct properties initially. + for (let item of savedItems) { + for (let [key, value] of Object.entries(originalProps)) { + if (key == "CATEGORIES") { + Assert.equal( + item.getCategories().join(), + value.join(), + `item categories are set to ${value}` + ); + } else { + Assert.equal(item.getProperty(key), value, `item property "${key}" is set to "${value}"`); + } + } + } + + // Modify the occurrences that have new properties set in changedPropList. + for (let idx = 0; idx < changedPropList.length; idx++) { + let changedProps = changedPropList[idx]; + if (changedProps) { + let targetOccurrence = savedItems[idx]; + let targetException = targetOccurrence.clone(); + + // Make the changes to the properties. + for (let [key, value] of Object.entries(changedProps)) { + if (key == "CATEGORIES") { + targetException.setCategories(value); + } else { + targetException.setProperty(key, value); + } + } + + await calendar.modifyItem( + cal.itip.prepareSequence(targetException, targetOccurrence), + targetOccurrence + ); + + // Refresh the saved items list after the change. + savedItems = await calendar.getItemsAsArray( + filter, + 0, + cal.createDateTime("20201201T000000Z"), + cal.createDateTime("20201231T000000Z") + ); + } + } + + // Get a fresh copy of the occurrences by using a new calendar with the + // same id. + let itemsAfterUpdate = await createStorageCalendar(calId).getItemsAsArray( + filter, + 0, + cal.createDateTime("20201201T000000Z"), + cal.createDateTime("20201231T000000Z") + ); + + Assert.equal( + itemsAfterUpdate.length, + changedPropList.length, + `count of occurrences retrieved after update is ${changedPropList.length}` + ); + + // Compare each property of each occurrence to ensure the changed + // occurrences have the values we expect. + for (let i = 0; i < itemsAfterUpdate.length; i++) { + let item = itemsAfterUpdate[i]; + let isException = changedPropList[i] != null; + let label = isException ? `modified occurrence ${i}` : `unmodified occurrence ${i}`; + let checkedProps = isException ? changedPropList[i] : originalProps; + + for (let [key, value] of Object.entries(checkedProps)) { + if (key == "CATEGORIES") { + Assert.equal( + item.getCategories().join(), + value.join(), + `item categories has value "${value}"` + ); + } else { + Assert.equal( + item.getProperty(key), + value, + `property "${key}" has value "${value}" for "${label}"` + ); + } + } + } +} + +/** + * Test event exceptions load their properties. + */ +add_task(async function testEventPropertiesForRecurringExceptionsLoad() { + let event = new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20201211T000000Z + LAST-MODIFIED:20201211T000000Z + DTSTAMP:20201210T080410Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Original Test Event + DTSTART:20201211T000000Z + DTEND:20201211T110000Z + RRULE:FREQ=DAILY;UNTIL=20201215T140000Z + END:VEVENT + `); + + let originalProps = { + DESCRIPTION: "This is a test event.", + CATEGORIES: ["Birthday"], + LOCATION: "Castara", + }; + + let changedProps = [ + null, + null, + { + DESCRIPTION: "This is an edited occurrence.", + CATEGORIES: ["Holiday"], + LOCATION: "Georgetown", + }, + null, + null, + ]; + + return doPropertiesTest( + Ci.calICalendar.ITEM_FILTER_TYPE_EVENT, + event, + originalProps, + changedProps + ); +}); + +/** + * Test todo exceptions load their properties. + */ +add_task(async function testTodoPropertiesForRecurringExceptionsLoad() { + let todo = new CalTodo(CalendarTestUtils.dedent` + BEGIN:VTODO + CREATED:20201211T000000Z + LAST-MODIFIED:20201211T000000Z + DTSTAMP:20201210T080410Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Original Test Event + DTSTART:20201211T000000Z + DTEND:20201211T110000Z + RRULE:FREQ=DAILY;UNTIL=20201215T140000Z + END:VTODO + `); + + let originalProps = { + DESCRIPTION: "This is a test todo.", + CATEGORIES: ["Birthday"], + LOCATION: "Castara", + STATUS: "NEEDS-ACTION", + }; + + let changedProps = [ + null, + null, + { + DESCRIPTION: "This is an edited occurrence.", + CATEGORIES: ["Holiday"], + LOCATION: "Georgetown", + STATUS: "COMPLETE", + }, + null, + null, + ]; + + return doPropertiesTest(Ci.calICalendar.ITEM_FILTER_TYPE_TODO, todo, originalProps, changedProps); +}); + +/** + * Tests calling getItems() does not overwrite subsequent event occurrence + * exceptions with their parent item. See bug 1686466. + */ +add_task(async function testRecurringEventChangesAreNotHiddenByCache() { + let event = new CalEvent(CalendarTestUtils.dedent` + BEGIN:VEVENT + CREATED:20201211T000000Z + LAST-MODIFIED:20201211T000000Z + DTSTAMP:20201210T080410Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Original Test Event + DTSTART:20201211T000000Z + DTEND:20201211T110000Z + RRULE:FREQ=DAILY;UNTIL=20201215T140000Z + END:VEVENT + `); + + let originalProps = { + LOCATION: "San Juan", + }; + + let changedProps = [ + null, + { + LOCATION: "Buenos Aries", + }, + { + LOCATION: "Bridgetown", + }, + { + LOCATION: "Freetown", + }, + null, + ]; + + return doPropertiesTest( + Ci.calICalendar.ITEM_FILTER_TYPE_EVENT, + event, + originalProps, + changedProps, + true + ); +}); + +/** + * Tests calling getItems() does not overwrite subsequent todo occurrence + * exceptions with their parent item. See bug 1686466. + */ +add_task(async function testRecurringTodoChangesNotHiddenByCache() { + let todo = new CalTodo(CalendarTestUtils.dedent` + BEGIN:VTODO + CREATED:20201211T000000Z + LAST-MODIFIED:20201211T000000Z + DTSTAMP:20201210T080410Z + UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5 + SUMMARY:Original Test Event + DTSTART:20201211T000000Z + DTEND:20201211T110000Z + RRULE:FREQ=DAILY;UNTIL=20201215T140000Z + END:VTODO + `); + + let originalProps = { + DESCRIPTION: "This is a test todo.", + CATEGORIES: ["Birthday"], + LOCATION: "Castara", + STATUS: "NEEDS-ACTION", + }; + + let changedProps = [ + null, + { + STATUS: "COMPLETE", + }, + { + STATUS: "COMPLETE", + }, + { + STATUS: "COMPLETE", + }, + null, + ]; + + return doPropertiesTest(Ci.calICalendar.ITEM_FILTER_TYPE_TODO, todo, originalProps, changedProps); +}); diff --git a/comm/calendar/test/unit/test_timezone.js b/comm/calendar/test/unit/test_timezone.js new file mode 100644 index 0000000000..d11b380b52 --- /dev/null +++ b/comm/calendar/test/unit/test_timezone.js @@ -0,0 +1,89 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", +}); + +function run_test() { + do_test_pending(); + cal.timezoneService.QueryInterface(Ci.calIStartupService).startup({ + onResult() { + really_run_test(); + do_test_finished(); + }, + }); +} + +function really_run_test() { + let event = new CalEvent(); + + let str = [ + "BEGIN:VCALENDAR", + "PRODID:-//RDU Software//NONSGML HandCal//EN", + "VERSION:2.0", + "BEGIN:VTIMEZONE", + "TZID:America/New_York", + "BEGIN:STANDARD", + "DTSTART:19981025T020000", + "TZOFFSETFROM:-0400", + "TZOFFSETTO:-0500", + "TZNAME:EST", + "END:STANDARD", + "BEGIN:DAYLIGHT", + "DTSTART:19990404T020000", + "TZOFFSETFROM:-0500", + "TZOFFSETTO:-0400", + "TZNAME:EDT", + "END:DAYLIGHT", + "END:VTIMEZONE", + "BEGIN:VEVENT", + "DTSTAMP:19980309T231000Z", + "UID:guid-1.example.com", + "ORGANIZER:mailto:mrbig@example.com", + "ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:", + " mailto:employee-A@example.com", + "DESCRIPTION:Project XYZ Review Meeting", + "CATEGORIES:MEETING", + "CLASS:PUBLIC", + "CREATED:19980309T130000Z", + "SUMMARY:XYZ Project Review", + "DTSTART;TZID=America/New_York:19980312T083000", + "DTEND;TZID=America/New_York:19980312T093000", + "LOCATION:1CP Conference Room 4350", + "END:VEVENT", + "END:VCALENDAR", + "", + ].join("\r\n"); + + let strTz = [ + "BEGIN:VTIMEZONE", + "TZID:America/New_York", + "BEGIN:STANDARD", + "DTSTART:19981025T020000", + "TZOFFSETFROM:-0400", + "TZOFFSETTO:-0500", + "TZNAME:EST", + "END:STANDARD", + "BEGIN:DAYLIGHT", + "DTSTART:19990404T020000", + "TZOFFSETFROM:-0500", + "TZOFFSETTO:-0400", + "TZNAME:EDT", + "END:DAYLIGHT", + "END:VTIMEZONE", + "", + ].join("\r\n"); + + event.icalString = str; + + let startDate = event.startDate; + let endDate = event.endDate; + + startDate.timezone = cal.timezoneService.getTimezone(startDate.timezone.tzid); + endDate.timezone = cal.timezoneService.getTimezone(endDate.timezone.tzid); + notEqual(strTz, startDate.timezone.toString()); +} diff --git a/comm/calendar/test/unit/test_timezone_changes.js b/comm/calendar/test/unit/test_timezone_changes.js new file mode 100644 index 0000000000..125187ca67 --- /dev/null +++ b/comm/calendar/test/unit/test_timezone_changes.js @@ -0,0 +1,93 @@ +/* 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 FEBRUARY = 1; +const OCTOBER = 9; +const NOVEMBER = 10; + +const UTC_MINUS_3 = -3 * 3600; +const UTC_MINUS_2 = -2 * 3600; + +function run_test() { + do_calendar_startup(run_next_test); +} + +// This test requires timezone data going back to 2016. It's been kept here as an example. +/* add_test(function testCaracas() { + let time = cal.createDateTime(); + let zone = cal.timezoneService.getTimezone("America/Caracas"); + + for (let month = JANUARY; month <= DECEMBER; month++) { + time.resetTo(2015, month, 1, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_430, time.toString()); + } + + for (let month = JANUARY; month <= APRIL; month++) { + time.resetTo(2016, month, 1, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_430, time.toString()); + } + + time.resetTo(2016, MAY, 1, 1, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_430, time.toString()); + + time.resetTo(2016, MAY, 1, 3, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_4, time.toString()); + + for (let month = JUNE; month <= DECEMBER; month++) { + time.resetTo(2016, month, 1, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_4, time.toString()); + } + + for (let month = JANUARY; month <= DECEMBER; month++) { + time.resetTo(2017, month, 1, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_4, time.toString()); + } + + run_next_test(); +}); */ + +// Brazil's rules are complicated. This tests every change in the time range we have data for. +// Updated for 2019b: Brazil no longer has DST. +add_test(function testSaoPaulo() { + let time = cal.createDateTime(); + let zone = cal.timezoneService.getTimezone("America/Sao_Paulo"); + + time.resetTo(2018, FEBRUARY, 17, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_2, time.toString()); + + time.resetTo(2018, FEBRUARY, 18, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2018, NOVEMBER, 3, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2018, NOVEMBER, 4, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_2, time.toString()); + + time.resetTo(2019, FEBRUARY, 16, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_2, time.toString()); + + time.resetTo(2019, FEBRUARY, 17, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2019, NOVEMBER, 2, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2019, NOVEMBER, 3, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2020, FEBRUARY, 15, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2020, FEBRUARY, 16, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2020, OCTOBER, 31, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + time.resetTo(2020, NOVEMBER, 1, 0, 0, 0, zone); + equal(time.timezoneOffset, UTC_MINUS_3, time.toString()); + + run_next_test(); +}); diff --git a/comm/calendar/test/unit/test_timezone_definition.js b/comm/calendar/test/unit/test_timezone_definition.js new file mode 100644 index 0000000000..79cddc2245 --- /dev/null +++ b/comm/calendar/test/unit/test_timezone_definition.js @@ -0,0 +1,32 @@ +/* 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/. */ + +function run_test() { + do_calendar_startup(run_next_test); +} + +// check tz database version +add_task(async function version_test() { + ok(cal.timezoneService.version, "service should provide timezone version"); +}); + +// check whether all tz definitions have all properties +add_task(async function zone_test() { + function resolveZone(aZoneId) { + let timezone = cal.timezoneService.getTimezone(aZoneId); + equal(aZoneId, timezone.tzid, "Zone test " + aZoneId); + ok( + timezone.icalComponent.serializeToICS().startsWith("BEGIN:VTIMEZONE"), + "VTIMEZONE test " + aZoneId + ); + } + + let foundZone = false; + for (let zone of cal.timezoneService.timezoneIds) { + foundZone = true; + resolveZone(zone); + } + + ok(foundZone, "There is at least one timezone"); +}); diff --git a/comm/calendar/test/unit/test_transaction_manager.js b/comm/calendar/test/unit/test_transaction_manager.js new file mode 100644 index 0000000000..bd9c591560 --- /dev/null +++ b/comm/calendar/test/unit/test_transaction_manager.js @@ -0,0 +1,431 @@ +/* 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/. */ + +/** + * Tests for the CalTransactionManager and the various CalTransaction instances. + */ + +const { CalendarTestUtils } = ChromeUtils.import( + "resource://testing-common/calendar/CalendarTestUtils.jsm" +); +const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); +const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm"); +const { + CalTransactionManager, + CalTransaction, + CalBatchTransaction, + CalAddTransaction, + CalModifyTransaction, + CalDeleteTransaction, +} = ChromeUtils.import("resource:///modules/CalTransactionManager.jsm"); + +/** + * Records the number of times doTransction() and undoTransction() is called. + */ +class MockCalTransaction extends CalTransaction { + /** + * The number of times doTransaction() was called. + * + * @type {number} + */ + done = 0; + + /** + * The number of times undoTransaction() was called. + */ + undone = 0; + + _writable; + + constructor(writable = true) { + super(); + this._writable = writable; + } + + canWrite() { + return this._writable; + } + + async doTransaction() { + this.done++; + } + + async undoTransaction() { + this.undone++; + } +} + +/** + * Tests a list of CalMockTransactions have the expected "done" and "undone" + * values. + * + * @param {CalMockTransaction[][]} batches The transaction batches to check. + * @param {number[][][]} expected - A 3 dimensional array containing + * the expected "done" and "undone" values for each transaction in each batch + * to be tested. + */ +function doBatchTest(batches, expected) { + for (let [batch, transactions] of batches.entries()) { + for (let [index, trn] of transactions.entries()) { + let [doneCount, undoneCount] = expected[batch][index]; + Assert.equal( + trn.done, + doneCount, + `batch ${batch}, transaction ${index} doTransaction() called ${doneCount} times` + ); + Assert.equal( + trn.undone, + undoneCount, + `batch ${batch}, transaction ${index} undoTransaction() called ${undoneCount} times` + ); + } + } +} + +add_setup(async function () { + await new Promise(resolve => do_load_calmgr(resolve)); +}); + +/** + * Tests the CalTransactionManager methods work as expected. + */ +add_task(async function testCalTransactionManager() { + let manager = new CalTransactionManager(); + + Assert.ok(!manager.canUndo(), "canUndo() returns false with an empty undo stack"); + Assert.ok(!manager.canRedo(), "canRedo() returns false with an empty redo stack"); + Assert.ok(!manager.peekUndoStack(), "peekUndoStack() returns nothing with an empty undo stack"); + Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returns nothing with an empty redo stack"); + + info("calling CalTransactionManager.commit()"); + let trn = new MockCalTransaction(); + await manager.commit(trn); + Assert.equal(trn.done, 1, "doTransaction() called once"); + Assert.equal(trn.undone, 0, "undoTransaction() was not called"); + Assert.ok(manager.canUndo(), "canUndo() returned true"); + Assert.ok(!manager.canRedo(), "canRedo() returned false"); + Assert.equal(manager.peekUndoStack(), trn, "peekUndoStack() returned the transaction"); + Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returned nothing"); + + info("calling CalTransactionManager.undo()"); + await manager.undo(); + Assert.equal(trn.done, 1, "doTransaction() was not called again"); + Assert.equal(trn.undone, 1, "undoTransaction() was called once"); + Assert.ok(!manager.canUndo(), "canUndo() returned false"); + Assert.ok(manager.canRedo(), "canRedo() returned true"); + Assert.ok(!manager.peekUndoStack(), "peekUndoStack() returned nothing"); + Assert.equal(manager.peekRedoStack(), trn, "peekRedoStack() returned the transaction"); + + info("calling CalTransactionManager.redo()"); + await manager.redo(); + Assert.equal(trn.done, 2, "doTransaction() was called again"); + Assert.equal(trn.undone, 1, "undoTransaction() was not called again"); + Assert.ok(manager.canUndo(), "canUndo() returned true"); + Assert.ok(!manager.canRedo(), "canRedo() returned false"); + Assert.equal(manager.peekUndoStack(), trn, "peekUndoStack() returned the transaction"); + Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returned nothing"); + + info("testing CalTransactionManager.beginBatch()"); + manager = new CalTransactionManager(); + + let batch = manager.beginBatch(); + Assert.ok(batch instanceof CalBatchTransaction, "beginBatch() returned a CalBatchTransaction"); + Assert.equal(manager.undoStack[0], batch, "the CalBatchTransaction is on the undo stack"); +}); + +/** + * Tests the BatchTransaction works as expected. + */ +add_task(async function testBatchTransaction() { + let batch = new CalBatchTransaction(); + + Assert.ok(!batch.canWrite(), "canWrite() returns false for an empty BatchTransaction"); + await batch.commit(new MockCalTransaction()); + await batch.commit(new MockCalTransaction(false)); + await batch.commit(new MockCalTransaction()); + Assert.ok(!batch.canWrite(), "canWrite() returns false if any transaction is not writable"); + + let transactions = [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()]; + batch = new CalBatchTransaction(); + for (let trn of transactions) { + await batch.commit(trn); + } + + Assert.ok(batch.canWrite(), "canWrite() returns true when all transactions are writable"); + info("testing commit() calls doTransaction() on each transaction in batch"); + doBatchTest( + [transactions], + [ + [ + [1, 0], + [1, 0], + [1, 0], + ], + ] + ); + + await batch.undoTransaction(); + info("testing undoTransaction() called on each transaction in batch"); + doBatchTest( + [transactions], + [ + [ + [1, 1], + [1, 1], + [1, 1], + ], + ] + ); + + await batch.doTransaction(); + info("testing doTransaction() called again on each transaction in batch"); + doBatchTest( + [transactions], + [ + [ + [2, 1], + [2, 1], + [2, 1], + ], + ] + ); +}); + +/** + * Tests that executing multiple batch transactions in sequence works. + */ +add_task(async function testSequentialBatchTransactions() { + let manager = new CalTransactionManager(); + + let batchTransactions = [ + [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()], + [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()], + [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()], + ]; + + let batch0 = manager.beginBatch(); + for (let trn of batchTransactions[0]) { + await batch0.commit(trn); + } + + let batch1 = manager.beginBatch(); + for (let trn of batchTransactions[1]) { + await batch1.commit(trn); + } + + let batch2 = manager.beginBatch(); + for (let trn of batchTransactions[2]) { + await batch2.commit(trn); + } + + doBatchTest(batchTransactions, [ + [ + [1, 0], + [1, 0], + [1, 0], + ], + [ + [1, 0], + [1, 0], + [1, 0], + ], + [ + [1, 0], + [1, 0], + [1, 0], + ], + ]); + + // Undo the top most batch. + await manager.undo(); + doBatchTest(batchTransactions, [ + [ + [1, 0], + [1, 0], + [1, 0], + ], + [ + [1, 0], + [1, 0], + [1, 0], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + ]); + + // Undo the next batch. + await manager.undo(); + doBatchTest(batchTransactions, [ + [ + [1, 0], + [1, 0], + [1, 0], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + ]); + + // Undo the last batch left. + await manager.undo(); + doBatchTest(batchTransactions, [ + [ + [1, 1], + [1, 1], + [1, 1], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + ]); + + // Redo the first batch. + await manager.redo(); + doBatchTest(batchTransactions, [ + [ + [2, 1], + [2, 1], + [2, 1], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + ]); + + // Redo the second batch. + await manager.redo(); + doBatchTest(batchTransactions, [ + [ + [2, 1], + [2, 1], + [2, 1], + ], + [ + [2, 1], + [2, 1], + [2, 1], + ], + [ + [1, 1], + [1, 1], + [1, 1], + ], + ]); + + // Redo the last batch. + await manager.redo(); + doBatchTest(batchTransactions, [ + [ + [2, 1], + [2, 1], + [2, 1], + ], + [ + [2, 1], + [2, 1], + [2, 1], + ], + [ + [2, 1], + [2, 1], + [2, 1], + ], + ]); +}); + +/** + * Tests CalAddTransaction executes and reverses as expected. + */ +add_task(async function testCalAddTransaction() { + let calendar = CalendarTestUtils.createCalendar("Test", "memory"); + let event = new CalEvent(); + event.id = "test"; + + let trn = new CalAddTransaction(event, calendar, null, null); + await trn.doTransaction(); + + let addedEvent = await calendar.getItem("test"); + Assert.ok(!!addedEvent, "transaction added event to the calendar"); + + await trn.undoTransaction(); + addedEvent = await calendar.getItem("test"); + Assert.ok(!addedEvent, "transaction removed event from the calendar"); + CalendarTestUtils.removeCalendar(calendar); +}); + +/** + * Tests CalModifyTransaction executes and reverses as expected. + */ +add_task(async function testCalModifyTransaction() { + let calendar = CalendarTestUtils.createCalendar("Test", "memory"); + let event = new CalEvent(); + event.id = "test"; + event.title = "Event"; + + let addedEvent = await calendar.addItem(event); + Assert.ok(!!addedEvent, "event was added to the calendar"); + + let modifiedEvent = addedEvent.clone(); + modifiedEvent.title = "Modified Event"; + + let trn = new CalModifyTransaction(modifiedEvent, calendar, addedEvent, null); + await trn.doTransaction(); + modifiedEvent = await calendar.getItem("test"); + Assert.ok(!!modifiedEvent); + Assert.equal(modifiedEvent.title, "Modified Event", "transaction modified event"); + + await trn.undoTransaction(); + let revertedEvent = await calendar.getItem("test"); + Assert.ok(!!revertedEvent); + Assert.equal(revertedEvent.title, "Event", "transaction reverted event to original state"); + CalendarTestUtils.removeCalendar(calendar); +}); + +/** + * Tests CalDeleteTransaction executes and reverses as expected. + */ +add_task(async function testCalDeleteTransaction() { + let calendar = CalendarTestUtils.createCalendar("Test", "memory"); + let event = new CalEvent(); + event.id = "test"; + event.title = "Event"; + + let addedEvent = await calendar.addItem(event); + Assert.ok(!!addedEvent, "event was added to the calendar"); + + let trn = new CalDeleteTransaction(addedEvent, calendar, null, null); + await trn.doTransaction(); + + let result = await calendar.getItem("test"); + Assert.ok(!result, "event was deleted from the calendar"); + + await trn.undoTransaction(); + let revertedEvent = await calendar.getItem("test"); + Assert.ok(!!revertedEvent, "event was restored to the calendar"); + CalendarTestUtils.removeCalendar(calendar); +}); diff --git a/comm/calendar/test/unit/test_unifinder_utils.js b/comm/calendar/test/unit/test_unifinder_utils.js new file mode 100644 index 0000000000..ae44379781 --- /dev/null +++ b/comm/calendar/test/unit/test_unifinder_utils.js @@ -0,0 +1,137 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +function run_test() { + test_get_item_sort_key(); + test_sort_items(); +} + +function test_get_item_sort_key() { + let event = new CalEvent(dedent` + BEGIN:VEVENT + PRIORITY:8 + SUMMARY:summary + DTSTART:20180102T030405Z + DTEND:20180607T080910Z + CATEGORIES:a,b,c + LOCATION:location + STATUS:CONFIRMED + END:VEVENT + `); + + strictEqual(cal.unifinder.getItemSortKey(event, "nothing"), null); + equal(cal.unifinder.getItemSortKey(event, "priority"), 8); + equal(cal.unifinder.getItemSortKey(event, "title"), "summary"); + equal(cal.unifinder.getItemSortKey(event, "startDate"), 1514862245000000); + equal(cal.unifinder.getItemSortKey(event, "endDate"), 1528358950000000); + equal(cal.unifinder.getItemSortKey(event, "categories"), "a, b, c"); + equal(cal.unifinder.getItemSortKey(event, "location"), "location"); + equal(cal.unifinder.getItemSortKey(event, "status"), 1); + + let task = new CalTodo(dedent` + BEGIN:VTODO + DTSTART:20180102T030405Z + DUE:20180607T080910Z + PERCENT-COMPLETE:20 + STATUS:COMPLETED + END:VTODO + `); + + equal(cal.unifinder.getItemSortKey(task, "priority"), 5); + strictEqual(cal.unifinder.getItemSortKey(task, "title"), ""); + equal(cal.unifinder.getItemSortKey(task, "entryDate"), 1514862245000000); + equal(cal.unifinder.getItemSortKey(task, "dueDate"), 1528358950000000); + equal(cal.unifinder.getItemSortKey(task, "completedDate"), -62168601600000000); + equal(cal.unifinder.getItemSortKey(task, "percentComplete"), 20); + strictEqual(cal.unifinder.getItemSortKey(task, "categories"), ""); + strictEqual(cal.unifinder.getItemSortKey(task, "location"), ""); + equal(cal.unifinder.getItemSortKey(task, "status"), 2); + + let task2 = new CalTodo(dedent` + BEGIN:VTODO + STATUS:GETTIN' THERE + END:VTODO + `); + equal(cal.unifinder.getItemSortKey(task2, "percentComplete"), 0); + equal(cal.unifinder.getItemSortKey(task2, "status"), -1); + + // Default CalTodo objects have the default percentComplete. + let task3 = new CalTodo(); + equal(cal.unifinder.getItemSortKey(task3, "percentComplete"), 0); +} + +function test_sort_items() { + // string comparison + let summaries = ["", "a", "b"]; + let items = summaries.map(summary => { + return new CalEvent(dedent` + BEGIN:VEVENT + SUMMARY:${summary} + END:VEVENT + `); + }); + + cal.unifinder.sortItems(items, "title", 1); + deepEqual( + items.map(item => item.title), + ["a", "b", null] + ); + + cal.unifinder.sortItems(items, "title", -1); + deepEqual( + items.map(item => item.title), + [null, "b", "a"] + ); + + // date comparison + let dates = ["20180101T000002Z", "20180101T000000Z", "20180101T000001Z"]; + items = dates.map(date => { + return new CalEvent(dedent` + BEGIN:VEVENT + DTSTART:${date} + END:VEVENT + `); + }); + + cal.unifinder.sortItems(items, "startDate", 1); + deepEqual( + items.map(item => item.startDate.icalString), + ["20180101T000000Z", "20180101T000001Z", "20180101T000002Z"] + ); + + cal.unifinder.sortItems(items, "startDate", -1); + deepEqual( + items.map(item => item.startDate.icalString), + ["20180101T000002Z", "20180101T000001Z", "20180101T000000Z"] + ); + + // number comparison + let percents = [3, 1, 2]; + items = percents.map(percent => { + return new CalTodo(dedent` + BEGIN:VTODO + PERCENT-COMPLETE:${percent} + END:VTODO + `); + }); + + cal.unifinder.sortItems(items, "percentComplete", 1); + deepEqual( + items.map(item => item.percentComplete), + [1, 2, 3] + ); + + cal.unifinder.sortItems(items, "percentComplete", -1); + deepEqual( + items.map(item => item.percentComplete), + [3, 2, 1] + ); +} diff --git a/comm/calendar/test/unit/test_utils.js b/comm/calendar/test/unit/test_utils.js new file mode 100644 index 0000000000..05d7423808 --- /dev/null +++ b/comm/calendar/test/unit/test_utils.js @@ -0,0 +1,185 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_recentzones(); + test_formatcss(); + test_getDefaultStartDate(); + test_getStartEndProps(); + test_OperationGroup(); + test_sameDay(); + test_binarySearch(); +} + +function test_recentzones() { + equal(cal.dtz.getRecentTimezones().length, 0); + equal(cal.dtz.getRecentTimezones(true).length, 0); + + cal.dtz.saveRecentTimezone("Europe/Berlin"); + + let zones = cal.dtz.getRecentTimezones(); + equal(zones.length, 1); + equal(zones[0], "Europe/Berlin"); + zones = cal.dtz.getRecentTimezones(true); + equal(zones.length, 1); + equal(zones[0].tzid, "Europe/Berlin"); + + cal.dtz.saveRecentTimezone(cal.dtz.defaultTimezone.tzid); + equal(cal.dtz.getRecentTimezones().length, 1); + equal(cal.dtz.getRecentTimezones(true).length, 1); + + cal.dtz.saveRecentTimezone("Europe/Berlin"); + equal(cal.dtz.getRecentTimezones().length, 1); + equal(cal.dtz.getRecentTimezones(true).length, 1); + + cal.dtz.saveRecentTimezone("America/New_York"); + equal(cal.dtz.getRecentTimezones().length, 2); + equal(cal.dtz.getRecentTimezones(true).length, 2); + + cal.dtz.saveRecentTimezone("Unknown"); + equal(cal.dtz.getRecentTimezones().length, 3); + equal(cal.dtz.getRecentTimezones(true).length, 2); +} + +function test_formatcss() { + equal(cal.view.formatStringForCSSRule(" "), "_"); + equal(cal.view.formatStringForCSSRule("ΓΌ"), "-uxfc-"); + equal(cal.view.formatStringForCSSRule("a"), "a"); +} + +function test_getDefaultStartDate() { + function transform(nowString, refDateString) { + now = cal.createDateTime(nowString); + let refDate = refDateString ? cal.createDateTime(refDateString) : null; + return cal.dtz.getDefaultStartDate(refDate); + } + + let oldNow = cal.dtz.now; + let now = cal.createDateTime("20120101T000000"); + cal.dtz.now = function () { + return now; + }; + + dump("TT: " + cal.createDateTime("20120101T000000") + "\n"); + dump("TT: " + cal.dtz.getDefaultStartDate(cal.createDateTime("20120101T000000")) + "\n"); + + equal(transform("20120101T000000").icalString, "20120101T010000"); + equal(transform("20120101T015959").icalString, "20120101T020000"); + equal(transform("20120101T230000").icalString, "20120101T230000"); + equal(transform("20120101T235959").icalString, "20120101T230000"); + + equal(transform("20120101T000000", "20120202").icalString, "20120202T010000"); + equal(transform("20120101T015959", "20120202").icalString, "20120202T020000"); + equal(transform("20120101T230000", "20120202").icalString, "20120202T230000"); + equal(transform("20120101T235959", "20120202").icalString, "20120202T230000"); + + let event = new CalEvent(); + now = cal.createDateTime("20120101T015959"); + cal.dtz.setDefaultStartEndHour(event, cal.createDateTime("20120202")); + equal(event.startDate.icalString, "20120202T020000"); + equal(event.endDate.icalString, "20120202T030000"); + + let todo = new CalTodo(); + now = cal.createDateTime("20120101T000000"); + cal.dtz.setDefaultStartEndHour(todo, cal.createDateTime("20120202")); + equal(todo.entryDate.icalString, "20120202T010000"); + + cal.dtz.now = oldNow; +} + +function test_getStartEndProps() { + equal(cal.dtz.startDateProp(new CalEvent()), "startDate"); + equal(cal.dtz.endDateProp(new CalEvent()), "endDate"); + equal(cal.dtz.startDateProp(new CalTodo()), "entryDate"); + equal(cal.dtz.endDateProp(new CalTodo()), "dueDate"); + + throws(() => cal.dtz.startDateProp(null), /NS_ERROR_NOT_IMPLEMENTED/); + throws(() => cal.dtz.endDateProp(null), /NS_ERROR_NOT_IMPLEMENTED/); +} + +function test_OperationGroup() { + let cancelCalled = false; + function cancelFunc() { + cancelCalled = true; + return true; + } + + let group = new cal.data.OperationGroup(cancelFunc); + + ok(group.isEmpty); + ok(group.id.endsWith("-0")); + equal(group.status, Cr.NS_OK); + equal(group.isPending, true); + + let completedOp = { isPending: false }; + + group.add(completedOp); + ok(group.isEmpty); + equal(group.isPending, true); + + let pendingOp1 = { + id: 1, + isPending: true, + cancel() { + this.cancelCalled = true; + return true; + }, + }; + + group.add(pendingOp1); + ok(!group.isEmpty); + equal(group.isPending, true); + + let pendingOp2 = { + id: 2, + isPending: true, + cancel() { + this.cancelCalled = true; + return true; + }, + }; + + group.add(pendingOp2); + group.remove(pendingOp1); + ok(!group.isEmpty); + equal(group.isPending, true); + + group.cancel(); + + equal(group.status, Ci.calIErrors.OPERATION_CANCELLED); + ok(!group.isPending); + ok(cancelCalled); + ok(pendingOp2.cancelCalled); +} + +function test_sameDay() { + let createDate = cal.createDateTime.bind(cal); + + ok(cal.dtz.sameDay(createDate("20120101"), createDate("20120101T120000"))); + ok(cal.dtz.sameDay(createDate("20120101"), createDate("20120101"))); + ok(!cal.dtz.sameDay(createDate("20120101"), createDate("20120102"))); + ok(!cal.dtz.sameDay(createDate("20120101T120000"), createDate("20120102T120000"))); +} + +function test_binarySearch() { + let arr = [2, 5, 7, 9, 20, 27, 34, 39, 41, 53, 62]; + equal(cal.data.binarySearch(arr, 27), 5); // Center + equal(cal.data.binarySearch(arr, 2), 0); // Left most + equal(cal.data.binarySearch(arr, 62), 11); // Right most + + equal(cal.data.binarySearch([5], 5), 1); // One element found + equal(cal.data.binarySearch([1], 0), 0); // One element insert left + equal(cal.data.binarySearch([1], 2), 1); // One element insert right +} diff --git a/comm/calendar/test/unit/test_view_utils.js b/comm/calendar/test/unit/test_view_utils.js new file mode 100644 index 0000000000..da988f8042 --- /dev/null +++ b/comm/calendar/test/unit/test_view_utils.js @@ -0,0 +1,127 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +function run_test() { + do_calendar_startup(really_run_test); +} + +function really_run_test() { + test_not_a_date(); + test_compare_event_and_todo(); + test_compare_startdate(); + test_compare_enddate(); + test_compare_alldayevent(); + test_compare_title(); + test_compare_todo(); +} + +function test_not_a_date() { + let item = new CalEvent(); + + let result = cal.view.compareItems(null, item); + equal(result, -1); + + result = cal.view.compareItems(item, null); + equal(result, 1); +} + +function test_compare_event_and_todo() { + let a = new CalEvent(); + let b = new CalTodo(); + + let result = cal.view.compareItems(a, b); + equal(result, 1); + + result = cal.view.compareItems(b, a); + equal(result, -1); +} + +function test_compare_startdate() { + let a = new CalEvent(); + a.startDate = createDate(1990, 0, 1, 1); + let b = new CalEvent(); + b.startDate = createDate(2000, 0, 1, 1); + + let result = cal.view.compareItems(a, b); + equal(result, -1); + + result = cal.view.compareItems(b, a); + equal(result, 1); + + result = cal.view.compareItems(a, a); + equal(result, 0); +} + +function test_compare_enddate() { + let a = new CalEvent(); + a.startDate = createDate(1990, 0, 1, 1); + a.endDate = createDate(1990, 0, 2, 1); + let b = new CalEvent(); + b.startDate = createDate(1990, 0, 1, 1); + b.endDate = createDate(1990, 0, 5, 1); + + let result = cal.view.compareItems(a, b); + equal(result, -1); + + result = cal.view.compareItems(b, a); + equal(result, 1); + + result = cal.view.compareItems(a, a); + equal(result, 0); +} + +function test_compare_alldayevent() { + let a = new CalEvent(); + a.startDate = createDate(1990, 0, 1); + let b = new CalEvent(); + b.startDate = createDate(1990, 0, 1, 1); + + let result = cal.view.compareItems(a, b); + equal(result, -1); + + result = cal.view.compareItems(b, a); + equal(result, 1); + + result = cal.view.compareItems(a, a); + equal(result, 0); +} + +function test_compare_title() { + let a = new CalEvent(); + a.startDate = createDate(1990, 0, 1); + a.title = "Abc"; + let b = new CalEvent(); + b.startDate = createDate(1990, 0, 1); + b.title = "Xyz"; + + let result = cal.view.compareItems(a, b); + equal(result, -1); + + result = cal.view.compareItems(b, a); + equal(result, 1); + + result = cal.view.compareItems(a, a); + equal(result, 0); +} + +function test_compare_todo() { + let a = new CalTodo(); + let b = new CalTodo(); + + let cmp = cal.view.compareItems(a, b); + equal(cmp, 0); + + cmp = cal.view.compareItems(b, a); + equal(cmp, 0); + + cmp = cal.view.compareItems(a, a); + equal(cmp, 0); +} diff --git a/comm/calendar/test/unit/test_webcal.js b/comm/calendar/test/unit/test_webcal.js new file mode 100644 index 0000000000..77d9576f4b --- /dev/null +++ b/comm/calendar/test/unit/test_webcal.js @@ -0,0 +1,44 @@ +/* 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 { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); + +function run_test() { + let httpserv = new HttpServer(); + httpserv.registerPrefixHandler("/", { + handle(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + equal(request.path, "/test_webcal"); + }, + }); + httpserv.start(-1); + + let baseUri = "://localhost:" + httpserv.identity.primaryPort + "/test_webcal"; + add_test(check_webcal_uri.bind(null, "webcal" + baseUri)); + // TODO webcals needs bug 466524 to be fixed + // add_test(check_webcal_uri.bind(null, "webcals" + baseUri)); + add_test(() => httpserv.stop(run_next_test)); + + // Now lets go... + run_next_test(); +} + +function check_webcal_uri(aUri) { + let uri = Services.io.newURI(aUri); + + let channel = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + + NetUtil.asyncFetch(channel, (data, status, request) => { + ok(Components.isSuccessCode(status)); + run_next_test(); + }); +} diff --git a/comm/calendar/test/unit/test_weekinfo_service.js b/comm/calendar/test/unit/test_weekinfo_service.js new file mode 100644 index 0000000000..9be4d02dd3 --- /dev/null +++ b/comm/calendar/test/unit/test_weekinfo_service.js @@ -0,0 +1,33 @@ +/* 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/. */ + +function run_test() { + // Bug 1239622. The 1st of January after a leap year which ends with + // a Thursday belongs to week number 53 unless the start of week is + // set on Friday. + let wkst_wknum_date = [ + [1, 53, "20210101T000000Z"], // Year 2021 affected by Bug 1239622 + [5, 1, "20210101T000000Z"], // + [3, 53, "20490101T000000Z"], // Year 2049 affected by Bug 1239622 + [5, 1, "20490101T000000Z"], // + [0, 1, "20170101T000000Z"], // Year that starts on Sunday ... + [3, 52, "20180101T000000Z"], // ... Monday + [0, 1, "20190101T000000Z"], // ... Tuesday + [4, 52, "20200101T000000Z"], // ... Wednesday + [0, 1, "20260101T000000Z"], // ... Thursday + [0, 53, "20270101T000000Z"], // ... Friday + [0, 52, "20280101T000000Z"], + ]; // ... Saturday + + let savedWeekStart = Services.prefs.getIntPref("calendar.week.start", 0); + for (let [weekStart, weekNumber, dateString] of wkst_wknum_date) { + Services.prefs.setIntPref("calendar.week.start", weekStart); + let date = cal.createDateTime(dateString); + date.isDate = true; + let week = cal.weekInfoService.getWeekTitle(date); + + equal(week, weekNumber, "Week number matches for " + dateString); + } + Services.prefs.setIntPref("calendar.week.start", savedWeekStart); +} diff --git a/comm/calendar/test/unit/xpcshell.ini b/comm/calendar/test/unit/xpcshell.ini new file mode 100644 index 0000000000..2d4639d0b6 --- /dev/null +++ b/comm/calendar/test/unit/xpcshell.ini @@ -0,0 +1,82 @@ +[DEFAULT] +head = head.js +prefs = + calendar.timezone.local=UTC + calendar.timezone.useSystemTimezone=false + calendar.itip.updateInvitationForNewAttendeesOnly=true +support-files = data/** +tags = calendar + +[test_alarm.js] +[test_alarmservice.js] +[test_alarmutils.js] +[test_attachment.js] +[test_attendee.js] +[test_auth_utils.js] +[test_bug1199942.js] +[test_bug1204255.js] +[test_bug1209399.js] +[test_bug1790339.js] +[test_bug272411.js] +[test_bug343792.js] +[test_bug350845.js] +[test_bug356207.js] +[test_bug485571.js] +[test_bug486186.js] +[test_bug494140.js] +[test_bug523860.js] +[test_bug653924.js] +[test_bug668222.js] +[test_bug759324.js] +[test_caldav_requests.js] +[test_CalendarFileImporter.js] +[test_calIteratorUtils.js] +[test_calmgr.js] +[test_calreadablestreamfactory.js] +[test_calStorageHelpers.js] +[test_data_bags.js] +[test_datetime.js] +[test_datetime_before_1970.js] +[test_datetimeformatter.js] +[test_deleted_items.js] +[test_duration.js] +[test_email_utils.js] +[test_extract.js] +[test_extract_parser.js] +[test_extract_parser_parse.js] +[test_extract_parser_service.js] +[test_extract_parser_tokenize.js] +[test_filter.js] +[test_filter_mixin.js] +[test_filter_tree_view.js] +[test_freebusy.js] +[test_freebusy_service.js] +[test_hashedarray.js] +[test_ics.js] +[test_ics_parser.js] +[test_ics_service.js] +[test_imip.js] +[test_invitationutils.js] +[test_items.js] +[test_itip_message_sender.js] +[test_itip_utils.js] +[test_l10n_utils.js] +[test_lenient_parsing.js] +[test_providers.js] +[test_recur.js] +[test_recurrence_utils.js] +[test_relation.js] +[test_rfc3339_parser.js] +[test_startup_service.js] +[test_storage.js] +[test_storage_connection.js] +[test_storage_get_items.js] +[test_timezone.js] +[test_timezone_changes.js] +[test_timezone_definition.js] +[test_transaction_manager.js] +[test_unifinder_utils.js] +[test_utils.js] +[test_view_utils.js] +[test_webcal.js] +[test_weekinfo_service.js] |