diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/test/unit/test_invitationutils.js | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/test/unit/test_invitationutils.js')
-rw-r--r-- | comm/calendar/test/unit/test_invitationutils.js | 1654 |
1 files changed, 1654 insertions, 0 deletions
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})` + ); + } +}); |