diff options
Diffstat (limited to 'comm/calendar/test/unit')
84 files changed, 20132 insertions, 0 deletions
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] |