/* 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} 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} [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 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 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 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 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 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 ' + "https://www.example.net 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 ' + "www.example.net 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 mailto:faq@example.net instead.', }, }, { name: "Description with email", input: { description: "Or write to faq@example.net instead." }, expected: { node: "imipHtml-description-content", content: 'Or write to faq@example.net 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: ', }, 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: ', }, expected: { node: "imipHtml-description-content", content: "Or we can try: ", }, }, { name: "Description with special characters", input: { description: 'Check example.com  — only 3 €', }, expected: { node: "imipHtml-description-content", content: 'Check example.com  — only 3 €', }, }, { name: "URL", input: { url: "http://www.example.org/event.ics" }, expected: { node: "imipHtml-url-content", content: '' + "http://www.example.org/event.ics", }, }, { name: "URL attachment", input: { attach: "http://www.example.org" }, expected: { node: "imipHtml-attachments-content", content: '' + "http://www.example.org/", }, }, { 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: '' + "http://www.example.org/first/
" + '' + "http://www.example.org/second
" + 'file:///N:/folder/third.file', }, }, { 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 ", title: "Attendee 1 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 ", title: "The Organizer 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 \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 , "Rec, 3" ', 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 , "Cc, 3" ', bcc: 'bcc1@example.net, BCc 2 , "Bcc, 3" ', }, }, expected: "MIME-version: 1.0\r\n" + "Return-path: no-reply@example.net\r\n" + 'From: "invitation, sender" \r\n' + "Organization: Example Net\r\n" + 'To: rec1@example.net, Recipient 2 ,\r\n "Rec, 3" \r\n' + "Subject: Invitation: test subject\r\n" + 'Cc: cc1@example.net, Cc 2 , "Cc, 3" \r\n' + 'Bcc: bcc1@example.net, BCc 2 , "Bcc, 3"\r\n \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 ", subject: "Invitation: Diacritis check (üäé)", identity: { fullName: "René", email: "sender@example.net", replyTo: "Max & René ", organization: "Max & René", cc: "René ", bcc: "René ", }, }, expected: "MIME-version: 1.0\r\n" + "Return-path: =?UTF-8?B?TWF4ICYgUmVuw6k=?= \r\n" + "From: =?UTF-8?B?UmVuw6k=?= \r\n" + "Organization: =?UTF-8?B?TWF4ICYgUmVuw6k=?=\r\n" + "To: =?UTF-8?Q?Max_M=C3=BCller?= \r\n" + "Subject: =?UTF-8?B?SW52aXRhdGlvbjogRGlhY3JpdGlzIGNoZWNrICjDvMOk?=\r\n =?UTF-8?B" + "?w6kp?=\r\n" + "Cc: =?UTF-8?B?UmVuw6k=?= \r\n" + "Bcc: =?UTF-8?B?UmVuw6k=?= \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 ", isEmail: true, }, expected: "=?UTF-8?Q?Max_M=C3=BCller?= ", }, { // test #2 input: { header: "Max Mueller ", isEmail: true, }, expected: "Max Mueller ", }, { // 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})` ); } });