Merge branch 'master' into access
Conflicts: config/default.toml
This commit is contained in:
commit
115d254baf
33 changed files with 2331 additions and 61 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -1,10 +1,15 @@
|
||||||
|
/.idea
|
||||||
|
/last-failed-e2e-test.*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
config/development.*
|
config/development.*
|
||||||
config/production.*
|
config/production.*
|
||||||
|
config/test.*
|
||||||
workers/reports/config/development.*
|
workers/reports/config/development.*
|
||||||
workers/reports/config/production.*
|
workers/reports/config/production.*
|
||||||
|
workers/reports/config/test.*
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
|
||||||
# generate POT file every time you want to update your PO file
|
# generate POT file every time you want to update your PO file
|
||||||
|
|
|
@ -9,7 +9,7 @@ module.exports = function (grunt) {
|
||||||
},
|
},
|
||||||
|
|
||||||
nodeunit: {
|
nodeunit: {
|
||||||
all: ['test/**/*-test.js']
|
all: ['test/nodeunit/**/*-test.js']
|
||||||
},
|
},
|
||||||
|
|
||||||
jsxgettext: {
|
jsxgettext: {
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
# Mailtrain
|
# Mailtrain
|
||||||
|
|
||||||
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v5+) and MySQL (v5.5+ or MariaDB).
|
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v7+) and MySQL (v5.5+ or MariaDB).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
> Mailtrain requires at least **Node.js v7**. If you want to use an older version of Node.js then you should use version v1.24 of Mailtrain. You can either download it [here](https://github.com/Mailtrain-org/mailtrain/archive/v1.24.0.zip) or if using git then run `git checkout v1.24.0` before starting it
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Mailtrain supports subscriber list management, list segmentation, custom fields, email templates, large CSV list import files, etc.
|
Mailtrain supports subscriber list management, list segmentation, custom fields, email templates, large CSV list import files, etc.
|
||||||
|
@ -45,7 +47,7 @@ Check out [ZoneMTA](https://github.com/zone-eu/zone-mta) as an alternative self
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
* Nodejs v6+
|
* Nodejs v7+
|
||||||
* MySQL v5.5 or MariaDB
|
* MySQL v5.5 or MariaDB
|
||||||
* Redis. Optional, disabled by default. Used for session storage and for caching state between multiple processes. If you do not have Redis enabled then you can only use a single sender process
|
* Redis. Optional, disabled by default. Used for session storage and for caching state between multiple processes. If you do not have Redis enabled then you can only use a single sender process
|
||||||
|
|
||||||
|
|
6
app.js
6
app.js
|
@ -184,6 +184,12 @@ app.use((req, res, next) => {
|
||||||
res.locals.customStyles = config.customstyles || [];
|
res.locals.customStyles = config.customstyles || [];
|
||||||
res.locals.customScripts = config.customscripts || [];
|
res.locals.customScripts = config.customscripts || [];
|
||||||
|
|
||||||
|
let bodyClasses = [];
|
||||||
|
if (req.user) {
|
||||||
|
bodyClasses.push('logged-in user-' + req.user.username);
|
||||||
|
}
|
||||||
|
res.locals.bodyClass = bodyClasses.join(' ');
|
||||||
|
|
||||||
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {
|
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return next(err);
|
return next(err);
|
||||||
|
|
|
@ -110,16 +110,6 @@ host="0.0.0.0"
|
||||||
# VERP hostname is in the same domain as the From address.
|
# VERP hostname is in the same domain as the From address.
|
||||||
# disablesenderheader=true
|
# disablesenderheader=true
|
||||||
|
|
||||||
[testserver]
|
|
||||||
# Starts a vanity server that redirects all mail to /dev/null
|
|
||||||
# Mostly needed for local development
|
|
||||||
enabled=false
|
|
||||||
port=5587
|
|
||||||
host="0.0.0.0"
|
|
||||||
username="testuser"
|
|
||||||
password="testpass"
|
|
||||||
logger=false
|
|
||||||
|
|
||||||
[ldap]
|
[ldap]
|
||||||
# enable to use ldap user backend
|
# enable to use ldap user backend
|
||||||
enabled=false
|
enabled=false
|
||||||
|
@ -178,13 +168,26 @@ templates=[["demo", "Demo Template"]]
|
||||||
# then it's safer to switch off the reporting functionality below.
|
# then it's safer to switch off the reporting functionality below.
|
||||||
enabled=false
|
enabled=false
|
||||||
|
|
||||||
|
[testserver]
|
||||||
|
# Starts a vanity server that redirects all mail to /dev/null
|
||||||
|
# Mostly needed for local development
|
||||||
|
enabled=false
|
||||||
|
port=5587
|
||||||
|
mailboxserverport=3001
|
||||||
|
host="0.0.0.0"
|
||||||
|
username="testuser"
|
||||||
|
password="testpass"
|
||||||
|
logger=false
|
||||||
|
|
||||||
|
[seleniumwebdriver]
|
||||||
|
browser="phantomjs"
|
||||||
|
|
||||||
|
|
||||||
[shares.list.master]
|
[shares.list.master]
|
||||||
name="Master"
|
name="Master"
|
||||||
description="All permissions"
|
description="All permissions"
|
||||||
permissions=["view"]
|
permissions=["view"]
|
||||||
|
|
||||||
|
|
||||||
[shares.namespace.master]
|
[shares.namespace.master]
|
||||||
name="Master"
|
name="Master"
|
||||||
description="All permissions"
|
description="All permissions"
|
||||||
|
|
|
@ -107,13 +107,14 @@ function getSql(path, data, callback) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
let renderer = Handlebars.compile(source);
|
const rendered = data ? Handlebars.compile(source)(data) : source;
|
||||||
return callback(null, renderer(data || {}));
|
return callback(null, rendered);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function runInitial(callback) {
|
function runInitial(callback) {
|
||||||
let fname = process.env.DB_FROM_START ? 'base.sql' : 'mailtrain.sql';
|
let dump = process.env.NODE_ENV === 'test' ? 'mailtrain-test.sql' : 'mailtrain.sql';
|
||||||
|
let fname = process.env.DB_FROM_START ? 'base.sql' : dump;
|
||||||
let path = pathlib.join(__dirname, '..', 'setup', 'sql', fname);
|
let path = pathlib.join(__dirname, '..', 'setup', 'sql', fname);
|
||||||
log.info('sql', 'Loading tables from %s', fname);
|
log.info('sql', 'Loading tables from %s', fname);
|
||||||
applyUpdate({
|
applyUpdate({
|
||||||
|
|
|
@ -148,7 +148,6 @@ function updateMenu(res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateEmail(address, checkBlocked, callback) {
|
function validateEmail(address, checkBlocked, callback) {
|
||||||
|
|
||||||
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
|
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {
|
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {
|
||||||
return callback(new Error(util.format(_('Blocked email address "%s"'), address)));
|
return callback(new Error(util.format(_('Blocked email address "%s"'), address)));
|
||||||
|
|
19
package.json
19
package.json
|
@ -8,12 +8,17 @@
|
||||||
"test": "grunt",
|
"test": "grunt",
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"sqlinit": "node setup/sql/init.js",
|
"sqlinit": "node setup/sql/init.js",
|
||||||
"sqldump": "node setup/sql/dump.js | sed -e '/^\\/\\*.*\\*\\/;$/d' -e 's/.[0-9]\\{4\\}-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]./NOW()/g' > setup/sql/mailtrain.sql",
|
"sqldump": "node setup/sql/dump.js | sed -e '/^\\/\\*.*\\*\\/;$/d' -e 's/.[0-9]\\{4\\}-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]./NOW()/g' > setup/sql/mailtrain${DUMP_NAME_SUFFIX}.sql",
|
||||||
"sqldrop": "node setup/sql/drop.js",
|
"sqldrop": "node setup/sql/drop.js",
|
||||||
"sqlgen": "npm run sqldrop && DB_FROM_START=Y npm run sqlinit && npm run sqldump",
|
"sqlgen": "npm run sqldrop && DB_FROM_START=Y npm run sqlinit && npm run sqldump",
|
||||||
"langs:hbs": "jsxgettext -L handlebars -k translate -o langs/hbs.pot views/layout.hbs views/index.hbs",
|
"langs:hbs": "jsxgettext -L handlebars -k translate -o langs/hbs.pot views/layout.hbs views/index.hbs",
|
||||||
"langs:js": "jsxgettext -o languages/js.pot routes/index.js",
|
"langs:js": "jsxgettext -o languages/js.pot routes/index.js",
|
||||||
"langs": "npm run langs:hbs && npm run langs:js"
|
"langs": "npm run langs:hbs && npm run langs:js",
|
||||||
|
"sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump",
|
||||||
|
"sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit",
|
||||||
|
"starttest": "NODE_ENV=test node index.js",
|
||||||
|
"_e2e": "NODE_ENV=test node test/e2e/index.js",
|
||||||
|
"e2e": "npm run sqlresettest && npm run _e2e"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -26,12 +31,18 @@
|
||||||
"node": ">=5.0.0"
|
"node": ">=5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"babel-eslint": "^7.2.3",
|
||||||
|
"chai": "^3.5.0",
|
||||||
"eslint-config-nodemailer": "^1.0.0",
|
"eslint-config-nodemailer": "^1.0.0",
|
||||||
"grunt": "^1.0.1",
|
"grunt": "^1.0.1",
|
||||||
"grunt-cli": "^1.2.0",
|
"grunt-cli": "^1.2.0",
|
||||||
"grunt-contrib-nodeunit": "^1.0.0",
|
"grunt-contrib-nodeunit": "^1.0.0",
|
||||||
"grunt-eslint": "^19.0.0",
|
"grunt-eslint": "^19.0.0",
|
||||||
"jsxgettext-andris": "^0.9.0-patch.1"
|
"jsxgettext-andris": "^0.9.0-patch.1",
|
||||||
|
"mocha": "^3.3.0",
|
||||||
|
"phantomjs": "^2.1.7",
|
||||||
|
"selenium-webdriver": "^3.4.0",
|
||||||
|
"url-pattern": "^1.0.3"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"posix": "^4.1.1"
|
"posix": "^4.1.1"
|
||||||
|
@ -59,6 +70,7 @@
|
||||||
"faker": "^4.1.0",
|
"faker": "^4.1.0",
|
||||||
"feedparser": "^2.1.0",
|
"feedparser": "^2.1.0",
|
||||||
"file-type": "^4.1.0",
|
"file-type": "^4.1.0",
|
||||||
|
"fs-extra": "^3.0.1",
|
||||||
"geoip-ultralight": "^0.1.5",
|
"geoip-ultralight": "^0.1.5",
|
||||||
"gettext-parser": "^1.2.2",
|
"gettext-parser": "^1.2.2",
|
||||||
"gm": "^1.23.0",
|
"gm": "^1.23.0",
|
||||||
|
@ -74,6 +86,7 @@
|
||||||
"juice": "^4.0.2",
|
"juice": "^4.0.2",
|
||||||
"knex": "^0.13.0",
|
"knex": "^0.13.0",
|
||||||
"libmime": "^3.1.0",
|
"libmime": "^3.1.0",
|
||||||
|
"mailparser": "^2.0.5",
|
||||||
"marked": "^0.3.6",
|
"marked": "^0.3.6",
|
||||||
"memory-cache": "^0.1.6",
|
"memory-cache": "^0.1.6",
|
||||||
"mjml": "3.3.0",
|
"mjml": "3.3.0",
|
||||||
|
|
|
@ -702,7 +702,12 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (req.query.formTest ||
|
const autoUnsubscribe = req.query.auto === 'yes';
|
||||||
|
|
||||||
|
if (autoUnsubscribe) {
|
||||||
|
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
|
||||||
|
|
||||||
|
} else if (req.query.formTest ||
|
||||||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
|
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
|
||||||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
|
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
|
||||||
|
|
||||||
|
@ -741,7 +746,7 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL
|
} else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL
|
||||||
handleUnsubscribe(list, subscription, req.query.c, req.ip, res, next);
|
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -771,14 +776,32 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
|
||||||
return next(err);
|
return next(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUnsubscribe(list, subscription, campaignId, req.ip, res, next);
|
handleUnsubscribe(list, subscription, false, campaignId, req.ip, res, next);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleUnsubscribe(list, subscription, campaignId, ip, res, next) {
|
function handleUnsubscribe(list, subscription, autoUnsubscribe, campaignId, ip, res, next) {
|
||||||
if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP ||
|
if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) ||
|
||||||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
|
(autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) {
|
||||||
|
|
||||||
|
subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Shall we do anything with "found"?
|
||||||
|
|
||||||
|
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
|
@ -799,24 +822,6 @@ function handleUnsubscribe(list, subscription, campaignId, ip, res, next) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
} else if (list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP ||
|
|
||||||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) {
|
|
||||||
|
|
||||||
subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
|
|
||||||
if (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Shall we do anything with "found"?
|
|
||||||
|
|
||||||
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
|
|
||||||
if (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else { // UnsubscriptionMode.MANUAL
|
} else { // UnsubscriptionMode.MANUAL
|
||||||
res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice');
|
res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice');
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,37 @@ let log = require('npmlog');
|
||||||
let config = require('config');
|
let config = require('config');
|
||||||
let crypto = require('crypto');
|
let crypto = require('crypto');
|
||||||
let humanize = require('humanize');
|
let humanize = require('humanize');
|
||||||
|
let http = require('http');
|
||||||
|
|
||||||
let SMTPServer = require('smtp-server').SMTPServer;
|
let SMTPServer = require('smtp-server').SMTPServer;
|
||||||
|
let simpleParser = require('mailparser').simpleParser;
|
||||||
|
|
||||||
let totalMessages = 0;
|
let totalMessages = 0;
|
||||||
let received = 0;
|
let received = 0;
|
||||||
|
|
||||||
|
let mailstore = {
|
||||||
|
accounts: {},
|
||||||
|
saveMessage(address, message) {
|
||||||
|
if (!this.accounts[address]) {
|
||||||
|
this.accounts[address] = [];
|
||||||
|
}
|
||||||
|
this.accounts[address].push(message);
|
||||||
|
},
|
||||||
|
getMail(address, callback) {
|
||||||
|
if (!this.accounts[address] || this.accounts[address].length === 0) {
|
||||||
|
let err = new Error('No mail for ' + address);
|
||||||
|
err.status = 404;
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
simpleParser(this.accounts[address].shift(), (err, mail) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err.message || err);
|
||||||
|
}
|
||||||
|
callback(null, mail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Setup server
|
// Setup server
|
||||||
let server = new SMTPServer({
|
let server = new SMTPServer({
|
||||||
|
|
||||||
|
@ -74,8 +99,12 @@ let server = new SMTPServer({
|
||||||
// Handle message stream
|
// Handle message stream
|
||||||
onData: (stream, session, callback) => {
|
onData: (stream, session, callback) => {
|
||||||
let hash = crypto.createHash('md5');
|
let hash = crypto.createHash('md5');
|
||||||
|
let message = '';
|
||||||
stream.on('data', chunk => {
|
stream.on('data', chunk => {
|
||||||
hash.update(chunk);
|
hash.update(chunk);
|
||||||
|
if (/^keep/i.test(session.envelope.rcptTo[0].address)) {
|
||||||
|
message += chunk;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
stream.on('end', () => {
|
stream.on('end', () => {
|
||||||
let err;
|
let err;
|
||||||
|
@ -84,6 +113,12 @@ let server = new SMTPServer({
|
||||||
err.responseCode = 552;
|
err.responseCode = 552;
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store message for e2e tests
|
||||||
|
if (/^keep/i.test(session.envelope.rcptTo[0].address)) {
|
||||||
|
mailstore.saveMessage(session.envelope.rcptTo[0].address, message);
|
||||||
|
}
|
||||||
|
|
||||||
received++;
|
received++;
|
||||||
callback(null, 'Message queued as ' + hash.digest('hex')); // accept the message once the stream is ended
|
callback(null, 'Message queued as ' + hash.digest('hex')); // accept the message once the stream is ended
|
||||||
});
|
});
|
||||||
|
@ -94,6 +129,41 @@ server.on('error', err => {
|
||||||
log.error('Test SMTP', err.stack);
|
log.error('Test SMTP', err.stack);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mailBoxServer = http.createServer((req, res) => {
|
||||||
|
let renderer = data => (
|
||||||
|
'<!doctype html><html><head><title>' + data.title + '</title></head><body>' + data.body + '</body></html>'
|
||||||
|
);
|
||||||
|
|
||||||
|
let address = req.url.substring(1);
|
||||||
|
mailstore.getMail(address, (err, mail) => {
|
||||||
|
if (err) {
|
||||||
|
let html = renderer({
|
||||||
|
title: 'error',
|
||||||
|
body: err.message || err
|
||||||
|
});
|
||||||
|
res.writeHead(err.status || 500, { 'Content-Type': 'text/html' });
|
||||||
|
return res.end(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = mail.html || renderer({
|
||||||
|
title: 'error',
|
||||||
|
body: 'This mail has no HTML part'
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://nodemailer.com/extras/mailparser/#mail-object
|
||||||
|
delete mail.html;
|
||||||
|
delete mail.textAsHtml;
|
||||||
|
delete mail.attachments;
|
||||||
|
|
||||||
|
let script = '<script> var mailObject = ' + JSON.stringify(mail) + '; console.log(mailObject); </script>';
|
||||||
|
html = html.replace(/<\/body\b/i, match => script + match);
|
||||||
|
html = html.replace(/target="_blank"/g, 'target="_self"');
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = callback => {
|
module.exports = callback => {
|
||||||
if (config.testserver.enabled) {
|
if (config.testserver.enabled) {
|
||||||
server.listen(config.testserver.port, config.testserver.host, () => {
|
server.listen(config.testserver.port, config.testserver.host, () => {
|
||||||
|
@ -112,7 +182,10 @@ module.exports = callback => {
|
||||||
}
|
}
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
setImmediate(callback);
|
mailBoxServer.listen(config.testserver.mailboxserverport, config.testserver.host, () => {
|
||||||
|
log.info('Test SMTP', 'Mail Box Server listening on port %s', config.testserver.mailboxserverport);
|
||||||
|
setImmediate(callback);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setImmediate(callback);
|
setImmediate(callback);
|
||||||
|
|
|
@ -12,7 +12,7 @@ set -e
|
||||||
|
|
||||||
yum -y install epel-release
|
yum -y install epel-release
|
||||||
|
|
||||||
curl --silent --location https://rpm.nodesource.com/setup_6.x | bash -
|
curl --silent --location https://rpm.nodesource.com/setup_7.x | bash -
|
||||||
yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils gcc-c++ make
|
yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils gcc-c++ make
|
||||||
|
|
||||||
systemctl start mariadb
|
systemctl start mariadb
|
||||||
|
@ -225,4 +225,3 @@ systemctl start zone-mta.service
|
||||||
systemctl start mailtrain.service
|
systemctl start mailtrain.service
|
||||||
|
|
||||||
echo "Success! Open http://$HOSTNAME/ and log in as admin:test";
|
echo "Success! Open http://$HOSTNAME/ and log in as admin:test";
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ set -e
|
||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
curl -sL https://deb.nodesource.com/setup_6.x | bash -
|
curl -sL https://deb.nodesource.com/setup_7.x | bash -
|
||||||
apt-get -q -y install mariadb-server pwgen nodejs imagemagick git ufw build-essential dnsutils python software-properties-common
|
apt-get -q -y install mariadb-server pwgen nodejs imagemagick git ufw build-essential dnsutils python software-properties-common
|
||||||
|
|
||||||
apt-add-repository -y ppa:chris-lea/redis-server
|
apt-add-repository -y ppa:chris-lea/redis-server
|
||||||
|
|
|
@ -1,17 +1,23 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
console.log('This script does not run in production'); // eslint-disable-line no-console
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = require('config');
|
let config = require('config');
|
||||||
let spawn = require('child_process').spawn;
|
let spawn = require('child_process').spawn;
|
||||||
let log = require('npmlog');
|
let log = require('npmlog');
|
||||||
let path = require('path');
|
let path = require('path');
|
||||||
|
let fs = require('fs');
|
||||||
|
|
||||||
log.level = 'verbose';
|
log.level = 'verbose';
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
log.error('sqldrop', 'This script does not run in production');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) {
|
||||||
|
log.error('sqldrop', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
function createDump(callback) {
|
function createDump(callback) {
|
||||||
let cmd = spawn(path.join(__dirname, 'drop.sh'), [], {
|
let cmd = spawn(path.join(__dirname, 'drop.sh'), [], {
|
||||||
env: {
|
env: {
|
||||||
|
|
|
@ -1,15 +1,22 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
console.log('This script does not run in production'); // eslint-disable-line no-console
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let dbcheck = require('../../lib/dbcheck');
|
let dbcheck = require('../../lib/dbcheck');
|
||||||
let log = require('npmlog');
|
let log = require('npmlog');
|
||||||
|
let path = require('path');
|
||||||
|
let fs = require('fs');
|
||||||
|
|
||||||
log.level = 'verbose';
|
log.level = 'verbose';
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
log.error('sqlinit', 'This script does not run in production');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) {
|
||||||
|
log.error('sqlinit', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
dbcheck(err => {
|
dbcheck(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
log.error('DB', err);
|
log.error('DB', err);
|
||||||
|
|
1023
setup/sql/mailtrain-test.sql
Normal file
1023
setup/sql/mailtrain-test.sql
Normal file
File diff suppressed because it is too large
Load diff
11
test/e2e/.eslintrc
Normal file
11
test/e2e/.eslintrc
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"parser": "babel-eslint",
|
||||||
|
"rules": {
|
||||||
|
"strict": 0,
|
||||||
|
"no-invalid-this": 0,
|
||||||
|
"no-unused-expressions": 0
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"mocha": true
|
||||||
|
}
|
||||||
|
}
|
44
test/e2e/README.md
Normal file
44
test/e2e/README.md
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# e2e Tests
|
||||||
|
|
||||||
|
Running e2e tests requires Node 7.6 or later and a dedicated test database. It uses mocha, selenium-webdriver and phantomjs.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
These e2e tests have to be performed against predefined resources (e.g. lists, users, etc.) and therefore a dedicated test database and test config is required.
|
||||||
|
|
||||||
|
Both can be created by running `sudo sh test/e2e/install.sh` from within your mailtrain directory. This creates a MYSQL user and database called `mailtrain_test`, and generates the required `config/test.toml`.
|
||||||
|
|
||||||
|
## Running e2e Tests
|
||||||
|
|
||||||
|
For tests to succeed Mailtrian must be started in `test` mode on port 3000 (as http://localhost:3000/ is the predefined service url). The tests itself have to be started in a second Terminal window.
|
||||||
|
|
||||||
|
1. Start Mailtrain with `npm run starttest`
|
||||||
|
2. Start e2e tests with `npm run e2e`
|
||||||
|
|
||||||
|
## Using Different Browsers
|
||||||
|
|
||||||
|
By default e2e tests use `phantomjs`. If you want to use a different browser you need to install its driver and adjust your `config/test.toml`.
|
||||||
|
|
||||||
|
* Install the `firefox` driver with `npm install geckodriver`
|
||||||
|
* Install the `chrome` driver with `npm install chromedriver`
|
||||||
|
* Other drivers can be found [here](https://seleniumhq.github.io/selenium/docs/api/javascript/)
|
||||||
|
|
||||||
|
Then adjust your config:
|
||||||
|
|
||||||
|
```
|
||||||
|
[seleniumwebdriver]
|
||||||
|
browser="firefox"
|
||||||
|
```
|
||||||
|
|
||||||
|
Current Firefox issue (and patch): https://github.com/mozilla/geckodriver/issues/683
|
||||||
|
|
||||||
|
## Writing e2e Tests
|
||||||
|
|
||||||
|
You should spend your time on features rather than writing tests, yet in some cases, like for example the subscription process, manual testing is just silly. You best get started by reading the current test suites, or just open an issue describing the scenario you want to get tested.
|
||||||
|
|
||||||
|
Available commands:
|
||||||
|
|
||||||
|
* `npm run sqldumptest` - exports the test DB to `setup/sql/mailtrain-test.sql`
|
||||||
|
* `npm run sqlresettest` - drops all tables then loads `setup/sql/mailtrain-test.sql`
|
||||||
|
* `npm run _e2e` - just runs e2e tests
|
||||||
|
* `npm run e2e` - runs `sqlresettest` then `_e2e`
|
30
test/e2e/index.js
Normal file
30
test/e2e/index.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
require('./lib/exit-unless-test');
|
||||||
|
const { mocha, driver } = require('./lib/mocha-e2e');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
global.USE_SHARED_DRIVER = true;
|
||||||
|
|
||||||
|
const only = 'only';
|
||||||
|
const skip = 'skip';
|
||||||
|
|
||||||
|
let tests = [
|
||||||
|
'login',
|
||||||
|
'subscription'
|
||||||
|
];
|
||||||
|
|
||||||
|
tests = tests.map(testSpec => (testSpec.constructor === Array ? testSpec : [testSpec]));
|
||||||
|
tests = tests.filter(testSpec => testSpec[1] !== skip);
|
||||||
|
if (tests.some(testSpec => testSpec[1] === only)) {
|
||||||
|
tests = tests.filter(testSpec => testSpec[1] === only);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const testSpec of tests) {
|
||||||
|
const testPath = path.join(__dirname, 'tests', testSpec[0] + '.js');
|
||||||
|
mocha.addFile(testPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
mocha.run(failures => {
|
||||||
|
process.exit(failures); // exit with non-zero status if there were failures
|
||||||
|
});
|
36
test/e2e/install.sh
Normal file
36
test/e2e/install.sh
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This installation script works on Ubuntu 14.04 and 16.04
|
||||||
|
# Run as root!
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root" 1>&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
MYSQL_PASSWORD=`pwgen 12 -1`
|
||||||
|
|
||||||
|
# Setup MySQL user for Mailtrain Tests
|
||||||
|
mysql -u root -e "CREATE USER 'mailtrain_test'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';"
|
||||||
|
mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain_test.* TO 'mailtrain_test'@'localhost';"
|
||||||
|
mysql -u mailtrain_test --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain_test;"
|
||||||
|
|
||||||
|
# Setup installation configuration
|
||||||
|
cat >> config/test.toml <<EOT
|
||||||
|
[www]
|
||||||
|
port=3000
|
||||||
|
[mysql]
|
||||||
|
user="mailtrain_test"
|
||||||
|
password="$MYSQL_PASSWORD"
|
||||||
|
database="mailtrain_test"
|
||||||
|
[testserver]
|
||||||
|
enabled=true
|
||||||
|
[seleniumwebdriver]
|
||||||
|
browser="phantomjs"
|
||||||
|
EOT
|
||||||
|
|
||||||
|
echo "Success! The test database has been created.";
|
32
test/e2e/lib/config.js
Normal file
32
test/e2e/lib/config.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('config');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
app: config,
|
||||||
|
baseUrl: 'http://localhost:' + config.www.port,
|
||||||
|
mailUrl: 'http://localhost:' + config.testserver.mailboxserverport,
|
||||||
|
users: {
|
||||||
|
admin: {
|
||||||
|
username: 'admin',
|
||||||
|
password: 'test'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lists: {
|
||||||
|
one: {
|
||||||
|
id: 1,
|
||||||
|
cid: 'Hkj1vCoJb',
|
||||||
|
publicSubscribe: 1,
|
||||||
|
unsubscriptionMode: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'service-url' : 'http://localhost:' + config.www.port + '/',
|
||||||
|
'default-homepage': 'https://mailtrain.org',
|
||||||
|
'smtp-hostname': config.testserver.host,
|
||||||
|
'smtp-port': config.testserver.port,
|
||||||
|
'smtp-encryption': 'NONE',
|
||||||
|
'smtp-user': config.testserver.username,
|
||||||
|
'smtp-pass': config.testserver.password
|
||||||
|
}
|
||||||
|
};
|
21
test/e2e/lib/exit-unless-test.js
Normal file
21
test/e2e/lib/exit-unless-test.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
const log = require('npmlog');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'test' || !fs.existsSync(path.join(__dirname, '..', '..', '..', 'config', 'test.toml'))) {
|
||||||
|
log.error('e2e', 'This script only runs in test and config/test.toml (i.e. a dedicated test database) is present');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.app.testserver.enabled !== true) {
|
||||||
|
log.error('e2e', 'This script only runs if the testserver is enabled. Check config/test.toml');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.app.www.port !== 3000) {
|
||||||
|
log.error('e2e', 'This script requires Mailtrain to be running on port 3000. Check config/test.toml');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
19
test/e2e/lib/mail.js
Normal file
19
test/e2e/lib/mail.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
const driver = require('./mocha-e2e').driver;
|
||||||
|
const page = require('./page');
|
||||||
|
|
||||||
|
module.exports = (...extras) => page({
|
||||||
|
|
||||||
|
async fetchMail(address) {
|
||||||
|
await driver.sleep(1000);
|
||||||
|
await driver.navigate().to(`${config.mailUrl}/${address}`);
|
||||||
|
await this.waitUntilVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
async ensureUrl(path) {
|
||||||
|
throw new Error('Unsupported method.');
|
||||||
|
},
|
||||||
|
|
||||||
|
}, ...extras);
|
217
test/e2e/lib/mocha-e2e.js
Normal file
217
test/e2e/lib/mocha-e2e.js
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Mocha = require('mocha');
|
||||||
|
const color = Mocha.reporters.Base.color;
|
||||||
|
const Semaphore = require('./semaphore');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const config = require('./config');
|
||||||
|
const webdriver = require('selenium-webdriver');
|
||||||
|
|
||||||
|
const driver = new webdriver.Builder()
|
||||||
|
.forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
|
||||||
|
const failHandlerRunning = new Semaphore();
|
||||||
|
|
||||||
|
|
||||||
|
function UseCaseReporter(runner) {
|
||||||
|
Mocha.reporters.Base.call(this, runner);
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
let indents = 0;
|
||||||
|
|
||||||
|
function indent () {
|
||||||
|
return Array(indents).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
runner.on('start', function () {
|
||||||
|
console.log();
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('suite', suite => {
|
||||||
|
++indents;
|
||||||
|
console.log(color('suite', '%s%s'), indent(), suite.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('suite end', () => {
|
||||||
|
--indents;
|
||||||
|
if (indents === 1) {
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('use-case', useCase => {
|
||||||
|
++indents;
|
||||||
|
console.log();
|
||||||
|
console.log(color('suite', '%sUse case: %s'), indent(), useCase.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('use-case end', () => {
|
||||||
|
--indents;
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('steps', useCase => {
|
||||||
|
++indents;
|
||||||
|
console.log(color('pass', '%s%s'), indent(), useCase.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('steps end', () => {
|
||||||
|
--indents;
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('step pass', step => {
|
||||||
|
console.log(indent() + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + color('pass', ' %s'), step.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('step fail', step => {
|
||||||
|
console.log(indent() + color('fail', ' %s'), step.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('pending', test => {
|
||||||
|
const fmt = indent() + color('pending', ' - %s');
|
||||||
|
console.log(fmt, test.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('pass', test => {
|
||||||
|
let fmt;
|
||||||
|
if (test.speed === 'fast') {
|
||||||
|
fmt = indent() +
|
||||||
|
color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) +
|
||||||
|
color('pass', ' %s');
|
||||||
|
console.log(fmt, test.title);
|
||||||
|
} else {
|
||||||
|
fmt = indent() +
|
||||||
|
color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) +
|
||||||
|
color('pass', ' %s') +
|
||||||
|
color(test.speed, ' (%dms)');
|
||||||
|
console.log(fmt, test.title, test.duration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('fail', (test, err) => {
|
||||||
|
failHandlerRunning.enter();
|
||||||
|
(async () => {
|
||||||
|
const currentUrl = await driver.getCurrentUrl();
|
||||||
|
const info = `URL: ${currentUrl}`;
|
||||||
|
await fs.writeFile('last-failed-e2e-test.info', info);
|
||||||
|
await fs.writeFile('last-failed-e2e-test.html', await driver.getPageSource());
|
||||||
|
await fs.writeFile('last-failed-e2e-test.png', new Buffer(await driver.takeScreenshot(), 'base64'));
|
||||||
|
failHandlerRunning.exit();
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log(indent() + color('fail', ' %s'), test.title);
|
||||||
|
console.log();
|
||||||
|
console.log(err);
|
||||||
|
console.log();
|
||||||
|
console.log(`Snaphot of and info about the current page are in last-failed-e2e-test.*`);
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.on('end', () => {
|
||||||
|
const stats = self.stats;
|
||||||
|
let fmt;
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// passes
|
||||||
|
fmt = color('bright pass', ' ') + color('green', ' %d passing');
|
||||||
|
console.log(fmt, stats.passes);
|
||||||
|
|
||||||
|
// pending
|
||||||
|
if (stats.pending) {
|
||||||
|
fmt = color('pending', ' ') + color('pending', ' %d pending');
|
||||||
|
console.log(fmt, stats.pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
// failures
|
||||||
|
if (stats.failures) {
|
||||||
|
fmt = color('fail', ' %d failing');
|
||||||
|
console.log(fmt, stats.failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const mocha = new Mocha()
|
||||||
|
.timeout(120000)
|
||||||
|
.reporter(UseCaseReporter)
|
||||||
|
.ui('tdd');
|
||||||
|
|
||||||
|
mocha._originalRun = mocha.run;
|
||||||
|
|
||||||
|
|
||||||
|
let runner;
|
||||||
|
mocha.run = fn => {
|
||||||
|
runner = mocha._originalRun(async () => {
|
||||||
|
await failHandlerRunning.waitForEmpty();
|
||||||
|
await driver.quit();
|
||||||
|
|
||||||
|
fn();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
async function useCaseExec(name, asyncFn) {
|
||||||
|
runner.emit('use-case', {title: name});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await asyncFn();
|
||||||
|
runner.emit('use-case end');
|
||||||
|
} catch (err) {
|
||||||
|
runner.emit('use-case end');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCase(name, asyncFn) {
|
||||||
|
if (asyncFn) {
|
||||||
|
return test('Use case: ' + name, () => useCaseExec(name, asyncFn));
|
||||||
|
} else {
|
||||||
|
// Pending test
|
||||||
|
return test('Use case: ' + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useCase.only = (name, asyncFn) => {
|
||||||
|
return test.only('Use case: ' + name, () => useCaseExec(name, asyncFn));
|
||||||
|
};
|
||||||
|
|
||||||
|
useCase.skip = (name, asyncFn) => {
|
||||||
|
return test.skip('Use case: ' + name, () => useCaseExec(name, asyncFn));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function step(name, asyncFn) {
|
||||||
|
try {
|
||||||
|
await asyncFn();
|
||||||
|
runner.emit('step pass', {title: name});
|
||||||
|
} catch (err) {
|
||||||
|
runner.emit('step fail', {title: name});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function steps(name, asyncFn) {
|
||||||
|
try {
|
||||||
|
runner.emit('steps', {title: name});
|
||||||
|
await asyncFn();
|
||||||
|
runner.emit('steps end');
|
||||||
|
} catch (err) {
|
||||||
|
runner.emit('step end');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function precondition(preConditionName, useCaseName, asyncFn) {
|
||||||
|
await steps(`Including use case "${useCaseName}" to satisfy precondition "${preConditionName}"`, asyncFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mocha,
|
||||||
|
useCase,
|
||||||
|
step,
|
||||||
|
steps,
|
||||||
|
precondition,
|
||||||
|
driver
|
||||||
|
};
|
122
test/e2e/lib/page.js
Normal file
122
test/e2e/lib/page.js
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
const webdriver = require('selenium-webdriver');
|
||||||
|
const By = webdriver.By;
|
||||||
|
const until = webdriver.until;
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const driver = require('./mocha-e2e').driver;
|
||||||
|
const url = require('url');
|
||||||
|
const UrlPattern = require('url-pattern');
|
||||||
|
|
||||||
|
const waitTimeout = 10000;
|
||||||
|
|
||||||
|
module.exports = (...extras) => Object.assign({
|
||||||
|
elements: {},
|
||||||
|
|
||||||
|
async getElement(key) {
|
||||||
|
return await driver.findElement(By.css(this.elements[key]));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLinkParams(key) {
|
||||||
|
const elem = await driver.findElement(By.css(this.elements[key]));
|
||||||
|
|
||||||
|
const linkUrl = await elem.getAttribute('href');
|
||||||
|
const linkPath = url.parse(linkUrl).path;
|
||||||
|
|
||||||
|
const urlPattern = new UrlPattern(this.links[key]);
|
||||||
|
|
||||||
|
const params = urlPattern.match(linkPath);
|
||||||
|
if (!params) {
|
||||||
|
throw new Error(`Cannot match URL pattern ${this.links[key]}`);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
|
||||||
|
async waitUntilVisible(selector) {
|
||||||
|
await driver.wait(until.elementLocated(By.css('body')), waitTimeout);
|
||||||
|
|
||||||
|
for (const elem of (this.elementsToWaitFor || [])) {
|
||||||
|
const sel = this.elements[elem];
|
||||||
|
if (!sel) {
|
||||||
|
throw new Error(`Element "${elem}" not found.`);
|
||||||
|
}
|
||||||
|
await driver.wait(until.elementLocated(By.css(sel)), waitTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const text of (this.textsToWaitFor || [])) {
|
||||||
|
await driver.wait(new webdriver.Condition(`for text "${text}"`, async (driver) => {
|
||||||
|
return await this.containsText(text);
|
||||||
|
}), waitTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.url) {
|
||||||
|
await this.ensureUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
await driver.executeScript('document.mailTrainRefreshAcknowledged = true;');
|
||||||
|
},
|
||||||
|
|
||||||
|
async waitUntilVisibleAfterRefresh(selector) {
|
||||||
|
await driver.wait(new webdriver.Condition('for refresh', async (driver) => {
|
||||||
|
const val = await driver.executeScript('return document.mailTrainRefreshAcknowledged;');
|
||||||
|
return !val;
|
||||||
|
}), waitTimeout);
|
||||||
|
|
||||||
|
await this.waitUntilVisible(selector);
|
||||||
|
},
|
||||||
|
|
||||||
|
async click(key) {
|
||||||
|
const elem = await this.getElement(key);
|
||||||
|
await elem.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHref(key) {
|
||||||
|
const elem = await this.getElement(key);
|
||||||
|
return await elem.getAttribute('href');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getText(key) {
|
||||||
|
const elem = await this.getElement(key);
|
||||||
|
return await elem.getText();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getValue(key) {
|
||||||
|
const elem = await this.getElement(key);
|
||||||
|
return await elem.getAttribute('value');
|
||||||
|
},
|
||||||
|
|
||||||
|
async containsText(str) {
|
||||||
|
return await driver.executeScript(`
|
||||||
|
return (document.documentElement.innerText || document.documentElement.textContent).indexOf('${str}') > -1;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSource() {
|
||||||
|
return await driver.getPageSource();
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveSource(destPath) {
|
||||||
|
const src = await this.getSource();
|
||||||
|
await fs.writeFile(destPath, src);
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveScreenshot(destPath) {
|
||||||
|
const pngData = await driver.takeScreenshot();
|
||||||
|
const buf = new Buffer(pngData, 'base64');
|
||||||
|
await fs.writeFile(destPath, buf);
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveSnapshot(destPathBase) {
|
||||||
|
destPathBase = destPathBase || 'last-failed-e2e-test';
|
||||||
|
const currentUrl = await driver.getCurrentUrl();
|
||||||
|
const info = `URL: ${currentUrl}`;
|
||||||
|
await fs.writeFile(destPathBase + '.info', info);
|
||||||
|
await this.saveSource(destPathBase + '.html');
|
||||||
|
await this.saveScreenshot(destPathBase + '.png');
|
||||||
|
},
|
||||||
|
|
||||||
|
async sleep(ms) {
|
||||||
|
await driver.sleep(ms);
|
||||||
|
}
|
||||||
|
}, ...extras);
|
35
test/e2e/lib/semaphore.js
Normal file
35
test/e2e/lib/semaphore.js
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Promise = require('bluebird');
|
||||||
|
|
||||||
|
class Semaphore {
|
||||||
|
constructor() {
|
||||||
|
this.counter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
enter() {
|
||||||
|
this.counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
exit() {
|
||||||
|
this.counter--;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForEmpty() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
function wait(resolve) {
|
||||||
|
if (self.counter == 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(wait, 500, resolve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(wait, 500, resolve);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Semaphore;
|
77
test/e2e/lib/web.js
Normal file
77
test/e2e/lib/web.js
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
const By = require('selenium-webdriver').By;
|
||||||
|
const url = require('url');
|
||||||
|
const UrlPattern = require('url-pattern');
|
||||||
|
const driver = require('./mocha-e2e').driver;
|
||||||
|
const page = require('./page');
|
||||||
|
|
||||||
|
module.exports = (...extras) => page({
|
||||||
|
|
||||||
|
async navigate(pathOrParams) {
|
||||||
|
let path;
|
||||||
|
if (typeof pathOrParams === 'string') {
|
||||||
|
path = pathOrParams;
|
||||||
|
} else {
|
||||||
|
const urlPattern = new UrlPattern(this.requestUrl || this.url);
|
||||||
|
path = urlPattern.stringify(pathOrParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUrl = url.parse(path);
|
||||||
|
let absolutePath;
|
||||||
|
if (parsedUrl.host) {
|
||||||
|
absolutePath = path;
|
||||||
|
} else {
|
||||||
|
absolutePath = config.baseUrl + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
await driver.navigate().to(absolutePath);
|
||||||
|
await this.waitUntilVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
async ensureUrl(path) {
|
||||||
|
const desiredUrl = path || this.url;
|
||||||
|
|
||||||
|
if (desiredUrl) {
|
||||||
|
const currentUrl = url.parse(await driver.getCurrentUrl());
|
||||||
|
const urlPattern = new UrlPattern(desiredUrl);
|
||||||
|
const params = urlPattern.match(currentUrl.pathname);
|
||||||
|
if (!params || config.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) {
|
||||||
|
throw new Error(`Unexpected URL. Expecting ${config.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.params = params;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
const submitButton = await this.getElement('submitButton');
|
||||||
|
await submitButton.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
async waitForFlash() {
|
||||||
|
await this.waitUntilVisible('div.alert:not(.js-warning)');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFlash() {
|
||||||
|
const elem = await driver.findElement(By.css('div.alert:not(.js-warning)'));
|
||||||
|
return await elem.getText();
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearFlash() {
|
||||||
|
await driver.executeScript(`
|
||||||
|
var elements = document.getElementsByClassName('alert');
|
||||||
|
while(elements.length > 0){
|
||||||
|
elements[0].parentNode.removeChild(elements[0]);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setValue(key, value) {
|
||||||
|
const elem = await this.getElement(key);
|
||||||
|
await elem.clear();
|
||||||
|
await elem.sendKeys(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, ...extras);
|
7
test/e2e/page-objects/home.js
Normal file
7
test/e2e/page-objects/home.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const web = require('../lib/web');
|
||||||
|
|
||||||
|
module.exports = web({
|
||||||
|
url: '/'
|
||||||
|
});
|
132
test/e2e/page-objects/subscription.js
Normal file
132
test/e2e/page-objects/subscription.js
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('../lib/config');
|
||||||
|
const web = require('../lib/web');
|
||||||
|
const mail = require('../lib/mail');
|
||||||
|
|
||||||
|
module.exports = list => ({
|
||||||
|
|
||||||
|
webSubscribe: web({
|
||||||
|
url: `/subscription/${list.cid}`,
|
||||||
|
elementsToWaitFor: ['form'],
|
||||||
|
textsToWaitFor: ['Subscribe to list'],
|
||||||
|
elements: {
|
||||||
|
form: `form[action="/subscription/${list.cid}/subscribe"]`,
|
||||||
|
emailInput: '#main-form input[name="email"]',
|
||||||
|
firstNameInput: '#main-form input[name="first-name"]',
|
||||||
|
lastNameInput: '#main-form input[name="last-name"]',
|
||||||
|
submitButton: 'a[href="#submit"]'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
webConfirmSubscriptionNotice: web({
|
||||||
|
url: `/subscription/${list.cid}/confirm-subscription-notice`,
|
||||||
|
textsToWaitFor: ['We need to confirm your email address']
|
||||||
|
}),
|
||||||
|
|
||||||
|
mailConfirmSubscription: mail({
|
||||||
|
elementsToWaitFor: ['confirmLink'],
|
||||||
|
textsToWaitFor: ['Please Confirm Subscription'],
|
||||||
|
elements: {
|
||||||
|
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/subscribe/"]`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
mailAlreadySubscribed: mail({
|
||||||
|
elementsToWaitFor: ['unsubscribeLink'],
|
||||||
|
textsToWaitFor: ['Email address already registered'],
|
||||||
|
elements: {
|
||||||
|
unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`,
|
||||||
|
manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]`
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`,
|
||||||
|
manageLink: `/subscription/${list.cid}/manage/:ucid`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
webSubscribedNotice: web({
|
||||||
|
url: `/subscription/${list.cid}/subscribed-notice`,
|
||||||
|
textsToWaitFor: ['Subscription Confirmed']
|
||||||
|
}),
|
||||||
|
|
||||||
|
mailSubscriptionConfirmed: mail({
|
||||||
|
elementsToWaitFor: ['unsubscribeLink'],
|
||||||
|
textsToWaitFor: ['Subscription Confirmed'],
|
||||||
|
elements: {
|
||||||
|
unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`,
|
||||||
|
manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]`
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`,
|
||||||
|
manageLink: `/subscription/${list.cid}/manage/:ucid`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
webManage: web({
|
||||||
|
url: `/subscription/${list.cid}/manage/:ucid`,
|
||||||
|
elementsToWaitFor: ['form'],
|
||||||
|
textsToWaitFor: ['Update Your Preferences'],
|
||||||
|
elements: {
|
||||||
|
form: `form[action="/subscription/${list.cid}/manage"]`,
|
||||||
|
emailInput: '#main-form input[name="email"]',
|
||||||
|
firstNameInput: '#main-form input[name="first-name"]',
|
||||||
|
lastNameInput: '#main-form input[name="last-name"]',
|
||||||
|
submitButton: 'a[href="#submit"]',
|
||||||
|
manageAddressLink: `a[href^="/subscription/${list.cid}/manage-address/"]`
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
manageAddressLink: `/subscription/${list.cid}/manage-address/:ucid`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
webManageAddress: web({
|
||||||
|
url: `/subscription/${list.cid}/manage-address/:ucid`,
|
||||||
|
elementsToWaitFor: ['form'],
|
||||||
|
textsToWaitFor: ['Update Your Email Address'],
|
||||||
|
elements: {
|
||||||
|
form: `form[action="/subscription/${list.cid}/manage-address"]`,
|
||||||
|
emailInput: '#main-form input[name="email"]',
|
||||||
|
emailNewInput: '#main-form input[name="email-new"]',
|
||||||
|
submitButton: 'a[href="#submit"]',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
mailConfirmAddressChange: mail({
|
||||||
|
elementsToWaitFor: ['confirmLink'],
|
||||||
|
textsToWaitFor: ['Please Confirm Subscription Address Change'],
|
||||||
|
elements: {
|
||||||
|
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/change-address/"]`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
webUpdatedNotice: web({
|
||||||
|
url: `/subscription/${list.cid}/updated-notice`,
|
||||||
|
textsToWaitFor: ['Profile Updated'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
webUnsubscribedNotice: web({
|
||||||
|
url: `/subscription/${list.cid}/unsubscribed-notice`,
|
||||||
|
textsToWaitFor: ['Unsubscribe Successful'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
mailUnsubscriptionConfirmed: mail({
|
||||||
|
elementsToWaitFor: ['resubscribeLink'],
|
||||||
|
textsToWaitFor: ['You Are Now Unsubscribed'],
|
||||||
|
elements: {
|
||||||
|
resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/*
|
||||||
|
webUnsubscribe: web({ // FIXME
|
||||||
|
elementsToWaitFor: ['submitButton'],
|
||||||
|
elements: {
|
||||||
|
submitButton: 'a[href="#submit"]'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
});
|
||||||
|
|
29
test/e2e/page-objects/user.js
Normal file
29
test/e2e/page-objects/user.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const web = require('../lib/web');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
login: web({
|
||||||
|
url: '/users/login',
|
||||||
|
elementsToWaitFor: ['submitButton'],
|
||||||
|
elements: {
|
||||||
|
usernameInput: 'form[action="/users/login"] input[name="username"]',
|
||||||
|
passwordInput: 'form[action="/users/login"] input[name="password"]',
|
||||||
|
submitButton: 'form[action="/users/login"] [type=submit]'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
logout: web({
|
||||||
|
requestUrl: '/users/logout',
|
||||||
|
url: '/'
|
||||||
|
}),
|
||||||
|
|
||||||
|
account: web({
|
||||||
|
url: '/users/account',
|
||||||
|
elementsToWaitFor: ['form'],
|
||||||
|
elements: {
|
||||||
|
form: `form[action="/users/account"]`,
|
||||||
|
emailInput: 'form[action="/users/account"] input[name="email"]'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
68
test/e2e/tests/login.js
Normal file
68
test/e2e/tests/login.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('../lib/config');
|
||||||
|
const { useCase, step, driver } = require('../lib/mocha-e2e');
|
||||||
|
const expect = require('chai').expect;
|
||||||
|
|
||||||
|
const page = require('../page-objects/user');
|
||||||
|
const home = require('../page-objects/home');
|
||||||
|
|
||||||
|
suite('Login use-cases', function() {
|
||||||
|
before(() => driver.manage().deleteAllCookies());
|
||||||
|
|
||||||
|
test('User can access home page', async () => {
|
||||||
|
await home.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Anonymous user cannot access restricted content', async () => {
|
||||||
|
await driver.navigate().to(config.baseUrl + '/settings');
|
||||||
|
await page.login.waitUntilVisible();
|
||||||
|
await page.login.waitForFlash();
|
||||||
|
expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content');
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Login (invalid credential)', async () => {
|
||||||
|
await step('User navigates to the login page.', async () => {
|
||||||
|
await page.login.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User fills in the user name and incorrect password.', async () => {
|
||||||
|
await page.login.setValue('usernameInput', config.users.admin.username);
|
||||||
|
await page.login.setValue('passwordInput', 'invalid');
|
||||||
|
await page.login.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows a flash notice that credentials are invalid.', async () => {
|
||||||
|
await page.login.waitForFlash();
|
||||||
|
expect(await page.login.getFlash()).to.contain('Incorrect username or password');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Login and logout', async () => {
|
||||||
|
await step('User navigates to the login page.', async () => {
|
||||||
|
await page.login.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User fills in the user name and password.', async () => {
|
||||||
|
await page.login.setValue('usernameInput', config.users.admin.username);
|
||||||
|
await page.login.setValue('passwordInput', config.users.admin.password);
|
||||||
|
await page.login.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows the home page and a flash notice that user has been logged in.', async () => {
|
||||||
|
await home.waitUntilVisibleAfterRefresh();
|
||||||
|
await home.waitForFlash();
|
||||||
|
expect(await home.getFlash()).to.contain('Logged in as admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User navigates to its account.', async () => {
|
||||||
|
await page.account.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User logs out.', async () => {
|
||||||
|
await page.logout.navigate();
|
||||||
|
await home.waitForFlash();
|
||||||
|
expect(await home.getFlash()).to.contain('logged out');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
248
test/e2e/tests/subscription.js
Normal file
248
test/e2e/tests/subscription.js
Normal file
|
@ -0,0 +1,248 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('../lib/config');
|
||||||
|
const { useCase, step, precondition, driver } = require('../lib/mocha-e2e');
|
||||||
|
const shortid = require('shortid');
|
||||||
|
const expect = require('chai').expect;
|
||||||
|
|
||||||
|
const page = require('../page-objects/subscription')(config.lists.one);
|
||||||
|
|
||||||
|
function generateEmail() {
|
||||||
|
return 'keep.' + shortid.generate() + '@mailtrain.org';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribe(subscription) {
|
||||||
|
await step('User navigates to list subscription page.', async () => {
|
||||||
|
await page.webSubscribe.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User submits a valid email and other subscription info.', async () => {
|
||||||
|
await page.webSubscribe.setValue('emailInput', subscription.email);
|
||||||
|
|
||||||
|
if (subscription.firstName) {
|
||||||
|
await page.webSubscribe.setValue('firstNameInput', subscription.firstName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.lastName) {
|
||||||
|
await page.webSubscribe.setValue('lastNameInput', subscription.lastName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.webSubscribe.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows a notice that further instructions are in the email.', async () => {
|
||||||
|
await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System sends an email with a link to confirm the subscription.', async () => {
|
||||||
|
await page.mailConfirmSubscription.fetchMail(subscription.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User clicks confirm subscription in the email', async () => {
|
||||||
|
await page.mailConfirmSubscription.click('confirmLink');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows a notice that subscription has been confirmed.', async () => {
|
||||||
|
await page.webSubscribedNotice.waitUntilVisibleAfterRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System sends an email with subscription confirmation.', async () => {
|
||||||
|
await page.mailSubscriptionConfirmed.fetchMail(subscription.email);
|
||||||
|
subscription.unsubscribeLink = await page.mailSubscriptionConfirmed.getHref('unsubscribeLink');
|
||||||
|
subscription.manageLink = await page.mailSubscriptionConfirmed.getHref('manageLink');
|
||||||
|
|
||||||
|
const unsubscribeParams = await page.mailSubscriptionConfirmed.getLinkParams('unsubscribeLink');
|
||||||
|
const manageParams = await page.mailSubscriptionConfirmed.getLinkParams('manageLink');
|
||||||
|
expect(unsubscribeParams.ucid).to.equal(manageParams.ucid);
|
||||||
|
subscription.ucid = unsubscribeParams.ucid;
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscriptionExistsPrecondition(subscription) {
|
||||||
|
await precondition('Subscription exists', 'Subscription to a public list (main scenario)', async () => {
|
||||||
|
await subscribe(subscription);
|
||||||
|
});
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
suite('Subscription use-cases', function() {
|
||||||
|
before(() => driver.manage().deleteAllCookies());
|
||||||
|
|
||||||
|
useCase('Subscription to a public list (main scenario)', async () => {
|
||||||
|
await subscribe({
|
||||||
|
email: generateEmail()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Subscription to a public list (invalid email)', async () => {
|
||||||
|
await step('User navigates to list subscribe page', async () => {
|
||||||
|
await page.webSubscribe.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User submits an invalid email.', async () => {
|
||||||
|
await page.webSubscribe.setValue('emailInput', 'foo@bar.nope');
|
||||||
|
await page.webSubscribe.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows a flash notice that email is invalid.', async () => {
|
||||||
|
await page.webSubscribe.waitForFlash();
|
||||||
|
expect(await page.webSubscribe.getFlash()).to.contain('Invalid email address');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Subscription to a public list (email already registered)', async () => {
|
||||||
|
const subscription = await subscriptionExistsPrecondition({
|
||||||
|
email: generateEmail()
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User navigates to list subscribe page', async () => {
|
||||||
|
await page.webSubscribe.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User submits the email which has been already registered.', async () => {
|
||||||
|
await page.webSubscribe.setValue('emailInput', subscription.email);
|
||||||
|
await page.webSubscribe.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows a notice that further instructions are in the email.', async () => {
|
||||||
|
await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System sends an email informing that the address has been already registered.', async () => {
|
||||||
|
await page.mailAlreadySubscribed.fetchMail(subscription.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Subscription to a non-public list');
|
||||||
|
|
||||||
|
useCase('Change profile info', async () => {
|
||||||
|
const subscription = await subscriptionExistsPrecondition({
|
||||||
|
email: generateEmail(),
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User clicks the manage subscription button.', async () => {
|
||||||
|
await page.mailSubscriptionConfirmed.click('manageLink');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => {
|
||||||
|
await page.webManage.waitUntilVisibleAfterRefresh();
|
||||||
|
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
|
||||||
|
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
|
||||||
|
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User enters another name and submits the form.', async () => {
|
||||||
|
subscription.firstName = 'Adam';
|
||||||
|
subscription.lastName = 'B';
|
||||||
|
await page.webManage.setValue('firstNameInput', subscription.firstName);
|
||||||
|
await page.webManage.setValue('lastNameInput', subscription.lastName);
|
||||||
|
await page.webManage.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Systems shows a notice that profile has been updated.', async () => {
|
||||||
|
await page.webUpdatedNotice.waitUntilVisibleAfterRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User navigates to manage subscription again.', async () => {
|
||||||
|
// await page.webManage.navigate(subscription.manageLink);
|
||||||
|
await page.webManage.navigate({ ucid: subscription.ucid });
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Systems shows a form with the changes made previously.', async () => {
|
||||||
|
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
|
||||||
|
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
|
||||||
|
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Change email', async () => {
|
||||||
|
const subscription = await subscriptionExistsPrecondition({
|
||||||
|
email: generateEmail(),
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe'
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User clicks the manage subscription button.', async () => {
|
||||||
|
await page.mailSubscriptionConfirmed.click('manageLink');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => {
|
||||||
|
await page.webManage.waitUntilVisibleAfterRefresh();
|
||||||
|
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
|
||||||
|
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
|
||||||
|
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User clicks the change address button.', async () => {
|
||||||
|
await page.webManage.click('manageAddressLink');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Systems shows a form to change email.', async () => {
|
||||||
|
await page.webManageAddress.waitUntilVisibleAfterRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User fills in a new email address and submits the form.', async () => {
|
||||||
|
subscription.email = generateEmail();
|
||||||
|
await page.webManageAddress.setValue('emailNewInput', subscription.email);
|
||||||
|
await page.webManageAddress.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System goes back to the profile form and shows a flash notice that further instructions are in the email.', async () => {
|
||||||
|
await page.webManage.waitUntilVisibleAfterRefresh();
|
||||||
|
await page.webManage.waitForFlash();
|
||||||
|
expect(await page.webManage.getFlash()).to.contain('An email with further instructions has been sent to the provided address');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System sends an email with a link to confirm the address change.', async () => {
|
||||||
|
await page.mailConfirmAddressChange.fetchMail(subscription.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User clicks confirm subscription in the email', async () => {
|
||||||
|
await page.mailConfirmAddressChange.click('confirmLink');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows the profile form with a flash notice that address has been changed.', async () => {
|
||||||
|
await page.webManage.waitUntilVisibleAfterRefresh();
|
||||||
|
await page.webManage.waitForFlash();
|
||||||
|
expect(await page.webManage.getFlash()).to.contain('Email address changed');
|
||||||
|
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System sends an email with subscription confirmation.', async () => {
|
||||||
|
await page.mailSubscriptionConfirmed.fetchMail(subscription.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Unsubscription from list #1 (one-step, no form).', async () => {
|
||||||
|
const subscription = await subscriptionExistsPrecondition({
|
||||||
|
email: generateEmail()
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User clicks the unsubscribe button.', async () => {
|
||||||
|
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System shows a notice that confirms unsubscription.', async () => {
|
||||||
|
await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('System sends an email that confirms unsubscription.', async () => {
|
||||||
|
await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase('Unsubscription from list #2 (one-step, with form).');
|
||||||
|
|
||||||
|
useCase('Unsubscription from list #3 (two-step, no form).');
|
||||||
|
|
||||||
|
useCase('Unsubscription from list #4 (two-step, with form).');
|
||||||
|
|
||||||
|
useCase('Unsubscription from list #5 (manual unsubscribe).');
|
||||||
|
|
||||||
|
useCase('Resubscription.'); // This one is supposed to check that values pre-filled in resubscription (i.e. the re-subscribe link in unsubscription confirmation) are the same as the ones used before.
|
||||||
|
});
|
|
@ -53,7 +53,7 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="{{#if user}}logged-in user-{{user.username}}{{/if}}">
|
<body class="{{bodyClass}}">
|
||||||
|
|
||||||
<nav class="navbar navbar-default navbar-static-top">
|
<nav class="navbar navbar-default navbar-static-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue