Fixed some bugs in subscription process
Added timezone selector to campaign scheduling Fixed problems with pausing campaign.
This commit is contained in:
parent
4113cb8476
commit
e3a5a3c4eb
23 changed files with 218 additions and 99 deletions
|
@ -13,11 +13,11 @@ import axios from "../lib/axios";
|
||||||
import {getPublicUrl, getUrl} from "../lib/urls";
|
import {getPublicUrl, getUrl} from "../lib/urls";
|
||||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||||
import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
|
import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
|
||||||
import moment from 'moment';
|
import moment from 'moment-timezone';
|
||||||
import campaignsStyles from "./styles.scss";
|
import campaignsStyles from "./styles.scss";
|
||||||
import {withComponentMixins} from "../lib/decorator-helpers";
|
import {withComponentMixins} from "../lib/decorator-helpers";
|
||||||
import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
|
import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
|
||||||
|
import styles from "../lib/styles.scss";
|
||||||
|
|
||||||
@withComponentMixins([
|
@withComponentMixins([
|
||||||
withTranslation,
|
withTranslation,
|
||||||
|
@ -114,6 +114,8 @@ class SendControls extends Component {
|
||||||
this.initForm({
|
this.initForm({
|
||||||
leaveConfirmation: false
|
leaveConfirmation: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.timezoneOptions = moment.tz.names().map(x => [x]);
|
||||||
}
|
}
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -126,6 +128,7 @@ class SendControls extends Component {
|
||||||
|
|
||||||
state.setIn(['date', 'error'], null);
|
state.setIn(['date', 'error'], null);
|
||||||
state.setIn(['time', 'error'], null);
|
state.setIn(['time', 'error'], null);
|
||||||
|
state.setIn(['timezone', 'error'], null);
|
||||||
|
|
||||||
if (state.getIn(['sendLater', 'value'])) {
|
if (state.getIn(['sendLater', 'value'])) {
|
||||||
const dateValue = state.getIn(['date', 'value']).trim();
|
const dateValue = state.getIn(['date', 'value']).trim();
|
||||||
|
@ -141,36 +144,54 @@ class SendControls extends Component {
|
||||||
} else if (!moment(timeValue, 'HH:mm', true).isValid()) {
|
} else if (!moment(timeValue, 'HH:mm', true).isValid()) {
|
||||||
state.setIn(['time', 'error'], t('timeIsInvalid'));
|
state.setIn(['time', 'error'], t('timeIsInvalid'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timezone = state.getIn(['timezone', 'value']);
|
||||||
|
if (!timezone) {
|
||||||
|
state.setIn(['timezone', 'error'], t('Timezone must be selected'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
populateSendLater() {
|
||||||
const entity = this.props.entity;
|
const entity = this.props.entity;
|
||||||
|
|
||||||
if (entity.scheduled) {
|
if (entity.scheduled) {
|
||||||
const date = moment.utc(entity.scheduled);
|
const timezone = entity.data.timezone || moment.tz.guess();
|
||||||
|
const date = moment.tz(entity.scheduled, timezone);
|
||||||
this.populateFormValues({
|
this.populateFormValues({
|
||||||
sendLater: true,
|
sendLater: true,
|
||||||
date: date.format('YYYY-MM-DD'),
|
date: date.format('YYYY-MM-DD'),
|
||||||
time: date.format('HH:mm')
|
time: date.format('HH:mm'),
|
||||||
|
timezone
|
||||||
});
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.populateFormValues({
|
this.populateFormValues({
|
||||||
sendLater: false,
|
sendLater: false,
|
||||||
date: '',
|
date: '',
|
||||||
time: ''
|
time: '',
|
||||||
|
timezone: moment.tz.guess()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.populateSendLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.entity.scheduled !== this.props.entity.scheduled) {
|
||||||
|
this.populateSendLater();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshEntity() {
|
async refreshEntity() {
|
||||||
await this.props.refreshEntity();
|
await this.props.refreshEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
async postAndMaskStateError(url) {
|
async postAndMaskStateError(url, data) {
|
||||||
try {
|
try {
|
||||||
await axios.post(getUrl(url));
|
await axios.post(getUrl(url), data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof interoperableErrors.InvalidStateError) {
|
if (err instanceof interoperableErrors.InvalidStateError) {
|
||||||
// Just mask the fact that it's not possible to start anything and refresh instead.
|
// Just mask the fact that it's not possible to start anything and refresh instead.
|
||||||
|
@ -183,17 +204,12 @@ class SendControls extends Component {
|
||||||
async scheduleAsync() {
|
async scheduleAsync() {
|
||||||
if (this.isFormWithoutErrors()) {
|
if (this.isFormWithoutErrors()) {
|
||||||
const data = this.getFormValues();
|
const data = this.getFormValues();
|
||||||
const date = moment(data.date, 'YYYY-MM-DD');
|
const dateTime = moment.tz(data.date + ' ' + data.time, 'YYYY-MM-DD HH:mm', data.timezone);
|
||||||
const time = moment(data.time, 'HH:mm');
|
|
||||||
|
|
||||||
date.hour(time.hour());
|
await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}`, {
|
||||||
date.minute(time.minute());
|
startAt: dateTime.valueOf(),
|
||||||
date.second(0);
|
timezone: data.timezone
|
||||||
date.millisecond(0);
|
});
|
||||||
date.utcOffset(0, true); // TODO, process offset from user settings
|
|
||||||
|
|
||||||
|
|
||||||
await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}/${date.valueOf()}`);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.showFormValidation();
|
this.showFormValidation();
|
||||||
|
@ -219,7 +235,17 @@ class SendControls extends Component {
|
||||||
t('doYouWantToLaunchTheCampaign?'),
|
t('doYouWantToLaunchTheCampaign?'),
|
||||||
async () => {
|
async () => {
|
||||||
await this.startAsync();
|
await this.startAsync();
|
||||||
await this.refreshEntity();
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmSchedule() {
|
||||||
|
const t = this.props.t;
|
||||||
|
this.actionDialog(
|
||||||
|
t('confirmLaunch'),
|
||||||
|
t('Do you want to schedule the campaign for launch?'),
|
||||||
|
async () => {
|
||||||
|
await this.scheduleAsync();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -305,10 +331,30 @@ class SendControls extends Component {
|
||||||
|
|
||||||
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
|
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
|
||||||
|
|
||||||
|
const timezoneColumns = [
|
||||||
|
{ data: 0, title: t('Timezone') }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dateValue = (this.getFormValue('date') || '').trim();
|
||||||
|
const timeValue = (this.getFormValue('time') || '').trim();
|
||||||
|
const timezone = this.getFormValue('timezone');
|
||||||
|
|
||||||
|
let dateTimeHelp = t('Select date, time and a timezone to display the date and time with offset');
|
||||||
|
let dateTimeAlert = null;
|
||||||
|
if (moment(dateValue, 'YYYY-MM-DD', true).isValid() && moment(timeValue, 'HH:mm', true).isValid() && timezone) {
|
||||||
|
const dateTime = moment.tz(dateValue + ' ' + timeValue, 'YYYY-MM-DD HH:mm', timezone);
|
||||||
|
|
||||||
|
dateTimeHelp = dateTime.toString();
|
||||||
|
if (!moment().isBefore(dateTime)) {
|
||||||
|
dateTimeAlert = <div className="alert alert-danger" role="alert">{t('Scheduled date/time seems to be in the past. If you schedule the send, campaign will be sent immediately.')}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>{dialogs}
|
<div>{dialogs}
|
||||||
<AlignedRow label={t('sendStatus')}>
|
<AlignedRow label={t('sendStatus')}>
|
||||||
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
|
{entity.status === CampaignStatus.SCHEDULED ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
|
||||||
</AlignedRow>
|
</AlignedRow>
|
||||||
|
|
||||||
<Form stateOwner={this}>
|
<Form stateOwner={this}>
|
||||||
|
@ -317,16 +363,20 @@ class SendControls extends Component {
|
||||||
<div>
|
<div>
|
||||||
<DatePicker id="date" label={t('date')} />
|
<DatePicker id="date" label={t('date')} />
|
||||||
<InputField id="time" label={t('time')} help={t('enter24HourTimeInFormatHhmmEg1348')}/>
|
<InputField id="time" label={t('time')} help={t('enter24HourTimeInFormatHhmmEg1348')}/>
|
||||||
{/* TODO: Timezone selector */}
|
<TableSelect id="timezone" label={t('Timezone')} dropdown columns={timezoneColumns} selectionKeyIndex={0} selectionLabelIndex={0} data={this.timezoneOptions}
|
||||||
|
help={dateTimeHelp}
|
||||||
|
/>
|
||||||
|
{dateTimeAlert && <AlignedRow>{dateTimeAlert}</AlignedRow>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</Form>
|
</Form>
|
||||||
<ButtonRow className={campaignsStyles.sendButtonRow}>
|
<ButtonRow className={campaignsStyles.sendButtonRow}>
|
||||||
{this.getFormValue('sendLater') ?
|
{this.getFormValue('sendLater') ?
|
||||||
<Button className="btn-primary" icon="play" label={(entity.scheduled ? t('rescheduleSend') : t('scheduleSend')) + subscrInfo} onClickAsync={::this.scheduleAsync}/>
|
<Button className="btn-primary" icon="play" label={(entity.status === CampaignStatus.SCHEDULED ? t('rescheduleSend') : t('scheduleSend')) + subscrInfo} onClickAsync={::this.confirmSchedule}/>
|
||||||
:
|
:
|
||||||
<Button className="btn-primary" icon="play" label={t('send') + subscrInfo} onClickAsync={::this.confirmStart}/>
|
<Button className="btn-primary" icon="play" label={t('send') + subscrInfo} onClickAsync={::this.confirmStart}/>
|
||||||
}
|
}
|
||||||
|
{entity.status === CampaignStatus.SCHEDULED && <Button className="btn-primary" icon="pause" label={t('Pause')} onClickAsync={::this.stopAsync}/>}
|
||||||
{entity.status === CampaignStatus.PAUSED && <Button className="btn-primary" icon="redo" label={t('reset')} onClickAsync={::this.resetAsync}/>}
|
{entity.status === CampaignStatus.PAUSED && <Button className="btn-primary" icon="redo" label={t('reset')} onClickAsync={::this.resetAsync}/>}
|
||||||
{entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>}
|
{entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>}
|
||||||
{testButtons}
|
{testButtons}
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const config = require('config');
|
|
||||||
const knex = require('./lib/knex');
|
|
||||||
const moment = require('moment');
|
|
||||||
const shortid = require('shortid');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
// const info = await knex('subscription__1').columnInfo();
|
|
||||||
// console.log(info);
|
|
||||||
|
|
||||||
// const ts = moment().toDate();
|
|
||||||
const ts = new Date(Date.now());
|
|
||||||
console.log(ts);
|
|
||||||
|
|
||||||
const cid = shortid.generate();
|
|
||||||
|
|
||||||
await knex('subscription__1')
|
|
||||||
.insert({
|
|
||||||
email: cid,
|
|
||||||
cid,
|
|
||||||
custom_date_mmddyy_rjkeojrzz: ts
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const row = await knex('subscription__1').select(['id', 'created', 'custom_date_mmddyy_rjkeojrzz']).where('cid', cid).first();
|
|
||||||
|
|
||||||
// const row = await knex('subscription__1').where('id', 2).first();
|
|
||||||
console.log(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
|
@ -71,7 +71,7 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
|
||||||
email
|
email
|
||||||
};
|
};
|
||||||
|
|
||||||
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
|
const flds = await fields.listGrouped(contextHelpers.getAdminContext(), list.id);
|
||||||
|
|
||||||
const encryptionKeys = [];
|
const encryptionKeys = [];
|
||||||
for (const fld of flds) {
|
for (const fld of flds) {
|
||||||
|
|
|
@ -124,7 +124,7 @@ async function validateEmail(address) {
|
||||||
|
|
||||||
function validateEmailGetMessage(result, address, language) {
|
function validateEmailGetMessage(result, address, language) {
|
||||||
let t;
|
let t;
|
||||||
if (language) {
|
if (language !== undefined) {
|
||||||
t = (key, args) => tUI(key, language, args);
|
t = (key, args) => tUI(key, language, args);
|
||||||
} else {
|
} else {
|
||||||
t = (key, args) => tLog(key, args);
|
t = (key, args) => tLog(key, args);
|
||||||
|
|
|
@ -339,6 +339,11 @@ async function getTrackingSettingsByCidTx(tx, cid) {
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function lockByIdTx(tx, id) {
|
||||||
|
// This locks the entry for update
|
||||||
|
await tx('campaigns').where('id', id).forUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
async function rawGetByTx(tx, key, id) {
|
async function rawGetByTx(tx, key, id) {
|
||||||
const entity = await tx('campaigns').where('campaigns.' + key, id)
|
const entity = await tx('campaigns').where('campaigns.' + key, id)
|
||||||
.leftJoin('campaign_lists', 'campaigns.id', 'campaign_lists.campaign')
|
.leftJoin('campaign_lists', 'campaigns.id', 'campaign_lists.campaign')
|
||||||
|
@ -857,8 +862,12 @@ async function getSubscribersQueryGeneratorTx(tx, campaignId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, startAt) {
|
async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, extraData) {
|
||||||
await knex.transaction(async tx => {
|
await knex.transaction(async tx => {
|
||||||
|
// This is quite inefficient because it selects the same row 3 times. However as status is changed
|
||||||
|
// rather infrequently, we keep it this way for simplicity
|
||||||
|
await lockByIdTx(tx, campaignId);
|
||||||
|
|
||||||
const entity = await getByIdTx(tx, context, campaignId, false);
|
const entity = await getByIdTx(tx, context, campaignId, false);
|
||||||
|
|
||||||
await enforceSendPermissionTx(tx, context, entity, false);
|
await enforceSendPermissionTx(tx, context, entity, false);
|
||||||
|
@ -867,14 +876,36 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
|
||||||
throw new interoperableErrors.InvalidStateError(invalidStateMessage);
|
throw new interoperableErrors.InvalidStateError(invalidStateMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(newState)) {
|
||||||
|
const newStateIdx = permittedCurrentStates.indexOf(entity.status);
|
||||||
|
enforce(newStateIdx != -1);
|
||||||
|
newState = newState[newStateIdx];
|
||||||
|
}
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
status: newState,
|
status: newState
|
||||||
};
|
};
|
||||||
|
|
||||||
if (startAt !== undefined) {
|
if (!extraData) {
|
||||||
|
updateData.scheduled = null;
|
||||||
|
updateData.start_at = null;
|
||||||
|
} else {
|
||||||
|
const startAt = extraData.startAt;
|
||||||
|
|
||||||
|
// If campaign is started without "scheduled" specified, startAt === null
|
||||||
updateData.scheduled = startAt;
|
updateData.scheduled = startAt;
|
||||||
if (!startAt || startAt.valueOf() < Date.now()) {
|
if (!startAt || startAt.valueOf() < Date.now()) {
|
||||||
updateData.start_at = new Date();
|
updateData.start_at = new Date();
|
||||||
|
} else {
|
||||||
|
updateData.start_at = startAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = extraData.timezone;
|
||||||
|
if (timezone) {
|
||||||
|
updateData.data = JSON.stringify({
|
||||||
|
...entity.data,
|
||||||
|
timezone
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -887,22 +918,23 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function start(context, campaignId, startAt) {
|
async function start(context, campaignId, extraData) {
|
||||||
await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.SCHEDULED, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE, PAUSED, or FINISHED state', startAt);
|
await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.SCHEDULED, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE, PAUSED, or FINISHED state', extraData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stop(context, campaignId) {
|
async function stop(context, campaignId) {
|
||||||
await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED, CampaignStatus.SENDING], CampaignStatus.PAUSING, 'Cannot stop campaign until it is in SCHEDULED or SENDING state');
|
await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED, CampaignStatus.SENDING], [CampaignStatus.PAUSED, CampaignStatus.PAUSING], 'Cannot stop campaign until it is in SCHEDULED or SENDING state');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reset(context, campaignId) {
|
async function reset(context, campaignId) {
|
||||||
await knex.transaction(async tx => {
|
await knex.transaction(async tx => {
|
||||||
|
// This is quite inefficient because it selects the same row 3 times. However as RESET is
|
||||||
|
// going to be called rather infrequently, we keep it this way for simplicity
|
||||||
|
await lockByIdTx(tx, campaignId);
|
||||||
|
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'send');
|
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'send');
|
||||||
|
|
||||||
const entity = await tx('campaigns').where('id', campaignId).first();
|
const entity = await tx('campaigns').where('id', campaignId).first();
|
||||||
if (!entity) {
|
|
||||||
throw new interoperableErrors.NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity.status !== CampaignStatus.FINISHED && entity.status !== CampaignStatus.PAUSED) {
|
if (entity.status !== CampaignStatus.FINISHED && entity.status !== CampaignStatus.PAUSED) {
|
||||||
throw new interoperableErrors.InvalidStateError('Cannot reset campaign until it is FINISHED or PAUSED state');
|
throw new interoperableErrors.InvalidStateError('Cannot reset campaign until it is FINISHED or PAUSED state');
|
||||||
|
|
|
@ -841,7 +841,7 @@ async function updateManaged(context, listId, cid, entity) {
|
||||||
for (const key in groupedFieldsMap) {
|
for (const key in groupedFieldsMap) {
|
||||||
const fld = groupedFieldsMap[key];
|
const fld = groupedFieldsMap[key];
|
||||||
|
|
||||||
if (fld.order_manage) {
|
if (fld.order_manage !== null) {
|
||||||
update[key] = entity[key];
|
update[key] = entity[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ router.postAsync('/subscribe/:listCid', passport.loggedIn, async (req, res) => {
|
||||||
|
|
||||||
const emailErr = await tools.validateEmail(input.EMAIL);
|
const emailErr = await tools.validateEmail(input.EMAIL);
|
||||||
if (emailErr) {
|
if (emailErr) {
|
||||||
const errMsg = tools.validateEmailGetMessage(emailErr, input.email);
|
const errMsg = tools.validateEmailGetMessage(emailErr, input.email, null);
|
||||||
log.error('API', errMsg);
|
log.error('API', errMsg);
|
||||||
throw new APIError(errMsg, 400);
|
throw new APIError(errMsg, 400);
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,11 +74,13 @@ router.deleteAsync('/campaigns/:campaignId', passport.loggedIn, passport.csrfPro
|
||||||
});
|
});
|
||||||
|
|
||||||
router.postAsync('/campaign-start/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
router.postAsync('/campaign-start/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||||
return res.json(await campaigns.start(req.context, castToInteger(req.params.campaignId), null));
|
return res.json(await campaigns.start(req.context, castToInteger(req.params.campaignId), {startAt: null}));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.postAsync('/campaign-start-at/:campaignId/:dateTime', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
router.postAsync('/campaign-start-at/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||||
return res.json(await campaigns.start(req.context, castToInteger(req.params.campaignId), new Date(Number.parseInt(req.params.dateTime))));
|
const startAt = new Date(req.body.startAt);
|
||||||
|
const timezone = req.body.timezone;
|
||||||
|
return res.json(await campaigns.start(req.context, castToInteger(req.params.campaignId), {startAt, timezone}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -249,7 +249,7 @@ router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, as
|
||||||
|
|
||||||
const emailErr = await tools.validateEmail(email);
|
const emailErr = await tools.validateEmail(email);
|
||||||
if (emailErr) {
|
if (emailErr) {
|
||||||
const errMsg = tools.validateEmailGetMessage(emailErr, email);
|
const errMsg = tools.validateEmailGetMessage(emailErr, email, req.locale);
|
||||||
|
|
||||||
if (req.xhr) {
|
if (req.xhr) {
|
||||||
throw new Error(errMsg);
|
throw new Error(errMsg);
|
||||||
|
@ -457,7 +457,7 @@ router.postAsync('/:lcid/manage-address', passport.parseForm, passport.csrfProte
|
||||||
} else {
|
} else {
|
||||||
const emailErr = await tools.validateEmail(emailNew);
|
const emailErr = await tools.validateEmail(emailNew);
|
||||||
if (emailErr) {
|
if (emailErr) {
|
||||||
const errMsg = tools.validateEmailGetMessage(emailErr, email);
|
const errMsg = tools.validateEmailGetMessage(emailErr, email, req.locale);
|
||||||
|
|
||||||
req.flash('danger', errMsg);
|
req.flash('danger', errMsg);
|
||||||
|
|
||||||
|
|
|
@ -328,15 +328,15 @@ async function processCampaign(campaignId) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const cpg = await knex('campaigns').where('id', campaignId).first();
|
const cpg = await knex('campaigns').where('id', campaignId).first();
|
||||||
|
|
||||||
const expirationThreshold = Date.now() - config.queue.retention.campaign * 1000;
|
|
||||||
if (cpg.start_at.valueOf() < expirationThreshold) {
|
|
||||||
return await finish(true, CampaignStatus.FINISHED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cpg.status === CampaignStatus.PAUSING) {
|
if (cpg.status === CampaignStatus.PAUSING) {
|
||||||
return await finish(true, CampaignStatus.PAUSED);
|
return await finish(true, CampaignStatus.PAUSED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expirationThreshold = Date.now() - config.queue.retention.campaign * 1000;
|
||||||
|
if (cpg.start_at && cpg.start_at.valueOf() < expirationThreshold) {
|
||||||
|
return await finish(true, CampaignStatus.FINISHED);
|
||||||
|
}
|
||||||
|
|
||||||
sendConfigurationIdByCampaignId.set(cpg.id, cpg.send_configuration);
|
sendConfigurationIdByCampaignId.set(cpg.id, cpg.send_configuration);
|
||||||
|
|
||||||
if (isSendConfigurationPostponed(cpg.send_configuration)) {
|
if (isSendConfigurationPostponed(cpg.send_configuration)) {
|
||||||
|
|
|
@ -13,8 +13,8 @@ if (process.env.NODE_ENV === 'production') {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) {
|
if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.yaml'))) {
|
||||||
log.error('sqldrop', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present');
|
log.error('sqldrop', 'This script only runs in test if config/test.yaml (i.e. a dedicated test database) is present');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,8 @@ if (process.env.NODE_ENV === 'production') {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) {
|
if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.yaml'))) {
|
||||||
log.error('sqlinit', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present');
|
log.error('sqlinit', 'This script only runs in test if config/test.yaml (i.e. a dedicated test database) is present');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const config = require('server/test/e2e/lib/config');
|
const config = require('config');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
app: config,
|
app: config,
|
||||||
baseUrl: 'http://localhost:' + config.www.publicPort,
|
baseTrustedUrl: 'http://localhost:' + config.www.trustedPort,
|
||||||
|
basePublicUrl: 'http://localhost:' + config.www.publicPort,
|
||||||
mailUrl: 'http://localhost:' + config.testServer.mailboxServerPort,
|
mailUrl: 'http://localhost:' + config.testServer.mailboxServerPort,
|
||||||
users: {
|
users: {
|
||||||
admin: {
|
admin: {
|
||||||
|
|
|
@ -5,17 +5,13 @@ const log = require('npmlog');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test' || !fs.existsSync(path.join(__dirname, '..', '..', '..', 'config', 'test.toml'))) {
|
if (process.env.NODE_ENV !== 'test' || !fs.existsSync(path.join(__dirname, '..', '..', '..', 'config', 'test.yaml'))) {
|
||||||
log.error('e2e', 'This script only runs in test and config/test.toml (i.e. a dedicated test database) is present');
|
log.error('e2e', 'This script only runs in test and config/test.yaml (i.e. a dedicated test database) is present');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.app.testServer.enabled !== true) {
|
if (config.app.testServer.enabled !== true) {
|
||||||
log.error('e2e', 'This script only runs if the testServer is enabled. Check config/test.toml');
|
log.error('e2e', 'This script only runs if the testServer is enabled. Check config/test.yaml');
|
||||||
process.exit(1);
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ const driver = require('./mocha-e2e').driver;
|
||||||
const url = require('url');
|
const url = require('url');
|
||||||
const UrlPattern = require('url-pattern');
|
const UrlPattern = require('url-pattern');
|
||||||
|
|
||||||
const waitTimeout = 10000;
|
const waitTimeout = 20000;
|
||||||
|
|
||||||
module.exports = (...extras) => Object.assign({
|
module.exports = (...extras) => Object.assign({
|
||||||
elements: {},
|
elements: {},
|
||||||
|
@ -21,7 +21,8 @@ module.exports = (...extras) => Object.assign({
|
||||||
const elem = await driver.findElement(By.css(this.elements[key]));
|
const elem = await driver.findElement(By.css(this.elements[key]));
|
||||||
|
|
||||||
const linkUrl = await elem.getAttribute('href');
|
const linkUrl = await elem.getAttribute('href');
|
||||||
const linkPath = url.parse(linkUrl).path;
|
const parsedUrl = url.parse(linkUrl);
|
||||||
|
const linkPath = parsedUrl.pathname;
|
||||||
|
|
||||||
const urlPattern = new UrlPattern(this.links[key]);
|
const urlPattern = new UrlPattern(this.links[key]);
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ module.exports = (...extras) => page({
|
||||||
if (parsedUrl.host) {
|
if (parsedUrl.host) {
|
||||||
absolutePath = path;
|
absolutePath = path;
|
||||||
} else {
|
} else {
|
||||||
absolutePath = config.baseUrl + path;
|
absolutePath = this.baseUrl + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
await driver.navigate().to(absolutePath);
|
await driver.navigate().to(absolutePath);
|
||||||
|
@ -37,8 +37,8 @@ module.exports = (...extras) => page({
|
||||||
const currentUrl = url.parse(await driver.getCurrentUrl());
|
const currentUrl = url.parse(await driver.getCurrentUrl());
|
||||||
const urlPattern = new UrlPattern(desiredUrl);
|
const urlPattern = new UrlPattern(desiredUrl);
|
||||||
const params = urlPattern.match(currentUrl.pathname);
|
const params = urlPattern.match(currentUrl.pathname);
|
||||||
if (!params || config.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) {
|
if (!params || this.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) {
|
||||||
throw new Error(`Unexpected URL. Expecting ${config.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`);
|
throw new Error(`Unexpected URL. Expecting ${this.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.params = params;
|
this.params = params;
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('../lib/config');
|
||||||
const web = require('../lib/web');
|
const web = require('../lib/web');
|
||||||
|
|
||||||
module.exports = web({
|
module.exports = web({
|
||||||
|
baseUrl: config.baseTrustedUrl,
|
||||||
url: '/'
|
url: '/'
|
||||||
});
|
});
|
||||||
|
|
|
@ -50,6 +50,7 @@ const fieldHelpers = list => ({
|
||||||
module.exports = list => ({
|
module.exports = list => ({
|
||||||
|
|
||||||
webSubscribe: web({
|
webSubscribe: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}`,
|
url: `/subscription/${list.cid}`,
|
||||||
elementsToWaitFor: ['form'],
|
elementsToWaitFor: ['form'],
|
||||||
textsToWaitFor: ['Subscribe to list'],
|
textsToWaitFor: ['Subscribe to list'],
|
||||||
|
@ -63,6 +64,7 @@ module.exports = list => ({
|
||||||
}, fieldHelpers(list)),
|
}, fieldHelpers(list)),
|
||||||
|
|
||||||
webSubscribeAfterPost: web({
|
webSubscribeAfterPost: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/subscribe`,
|
url: `/subscription/${list.cid}/subscribe`,
|
||||||
elementsToWaitFor: ['form'],
|
elementsToWaitFor: ['form'],
|
||||||
textsToWaitFor: ['Subscribe to list'],
|
textsToWaitFor: ['Subscribe to list'],
|
||||||
|
@ -76,11 +78,13 @@ module.exports = list => ({
|
||||||
}, fieldHelpers(list)),
|
}, fieldHelpers(list)),
|
||||||
|
|
||||||
webSubscribeNonPublic: web({
|
webSubscribeNonPublic: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}`,
|
url: `/subscription/${list.cid}`,
|
||||||
textsToWaitFor: ['Permission denied'],
|
textsToWaitFor: ['Permission denied'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webConfirmSubscriptionNotice: web({
|
webConfirmSubscriptionNotice: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/confirm-subscription-notice`,
|
url: `/subscription/${list.cid}/confirm-subscription-notice`,
|
||||||
textsToWaitFor: ['We need to confirm your email address']
|
textsToWaitFor: ['We need to confirm your email address']
|
||||||
}),
|
}),
|
||||||
|
@ -107,6 +111,7 @@ module.exports = list => ({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webSubscribedNotice: web({
|
webSubscribedNotice: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/subscribed-notice`,
|
url: `/subscription/${list.cid}/subscribed-notice`,
|
||||||
textsToWaitFor: ['Subscription Confirmed']
|
textsToWaitFor: ['Subscription Confirmed']
|
||||||
}),
|
}),
|
||||||
|
@ -125,6 +130,7 @@ module.exports = list => ({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webManage: web({
|
webManage: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/manage/:ucid`,
|
url: `/subscription/${list.cid}/manage/:ucid`,
|
||||||
elementsToWaitFor: ['form'],
|
elementsToWaitFor: ['form'],
|
||||||
textsToWaitFor: ['Update Your Preferences'],
|
textsToWaitFor: ['Update Your Preferences'],
|
||||||
|
@ -142,6 +148,7 @@ module.exports = list => ({
|
||||||
}, fieldHelpers(list)),
|
}, fieldHelpers(list)),
|
||||||
|
|
||||||
webManageAddress: web({
|
webManageAddress: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/manage-address/:ucid`,
|
url: `/subscription/${list.cid}/manage-address/:ucid`,
|
||||||
elementsToWaitFor: ['form'],
|
elementsToWaitFor: ['form'],
|
||||||
textsToWaitFor: ['Update Your Email Address'],
|
textsToWaitFor: ['Update Your Email Address'],
|
||||||
|
@ -162,11 +169,13 @@ module.exports = list => ({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webUpdatedNotice: web({
|
webUpdatedNotice: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/updated-notice`,
|
url: `/subscription/${list.cid}/updated-notice`,
|
||||||
textsToWaitFor: ['Profile Updated']
|
textsToWaitFor: ['Profile Updated']
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webUnsubscribedNotice: web({
|
webUnsubscribedNotice: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/unsubscribed-notice`,
|
url: `/subscription/${list.cid}/unsubscribed-notice`,
|
||||||
textsToWaitFor: ['Unsubscribe Successful']
|
textsToWaitFor: ['Unsubscribe Successful']
|
||||||
}),
|
}),
|
||||||
|
@ -180,6 +189,7 @@ module.exports = list => ({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webUnsubscribe: web({
|
webUnsubscribe: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
elementsToWaitFor: ['submitButton'],
|
elementsToWaitFor: ['submitButton'],
|
||||||
textsToWaitFor: ['Unsubscribe'],
|
textsToWaitFor: ['Unsubscribe'],
|
||||||
elements: {
|
elements: {
|
||||||
|
@ -188,6 +198,7 @@ module.exports = list => ({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webConfirmUnsubscriptionNotice: web({
|
webConfirmUnsubscriptionNotice: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/confirm-unsubscription-notice`,
|
url: `/subscription/${list.cid}/confirm-unsubscription-notice`,
|
||||||
textsToWaitFor: ['We need to confirm your email address']
|
textsToWaitFor: ['We need to confirm your email address']
|
||||||
}),
|
}),
|
||||||
|
@ -201,6 +212,7 @@ module.exports = list => ({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webManualUnsubscribeNotice: web({
|
webManualUnsubscribeNotice: web({
|
||||||
|
baseUrl: config.basePublicUrl,
|
||||||
url: `/subscription/${list.cid}/manual-unsubscribe-notice`,
|
url: `/subscription/${list.cid}/manual-unsubscribe-notice`,
|
||||||
elementsToWaitFor: ['contactLink'],
|
elementsToWaitFor: ['contactLink'],
|
||||||
textsToWaitFor: ['Online Unsubscription Is Not Possible', config.settings['admin-email']],
|
textsToWaitFor: ['Online Unsubscription Is Not Possible', config.settings['admin-email']],
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('../lib/config');
|
||||||
const web = require('../lib/web');
|
const web = require('../lib/web');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
login: web({
|
login: web({
|
||||||
|
baseUrl: config.baseTrustedUrl,
|
||||||
url: '/users/login',
|
url: '/users/login',
|
||||||
elementsToWaitFor: ['submitButton'],
|
elementsToWaitFor: ['submitButton'],
|
||||||
elements: {
|
elements: {
|
||||||
|
@ -14,11 +16,13 @@ module.exports = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
logout: web({
|
logout: web({
|
||||||
|
baseUrl: config.baseTrustedUrl,
|
||||||
requestUrl: '/users/logout',
|
requestUrl: '/users/logout',
|
||||||
url: '/'
|
url: '/'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
account: web({
|
account: web({
|
||||||
|
baseUrl: config.baseTrustedUrl,
|
||||||
url: '/users/account',
|
url: '/users/account',
|
||||||
elementsToWaitFor: ['form'],
|
elementsToWaitFor: ['form'],
|
||||||
elements: {
|
elements: {
|
||||||
|
|
|
@ -17,7 +17,7 @@ suite('Login use-cases', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Anonymous user cannot access restricted content', async () => {
|
test('Anonymous user cannot access restricted content', async () => {
|
||||||
await driver.navigate().to(config.baseUrl + '/settings');
|
await driver.navigate().to(config.baseTrustedUrl + '/settings');
|
||||||
await page.login.waitUntilVisible();
|
await page.login.waitUntilVisible();
|
||||||
await page.login.waitForFlash();
|
await page.login.waitForFlash();
|
||||||
expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content');
|
expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content');
|
||||||
|
|
|
@ -136,7 +136,7 @@ suite('Subscription use-cases', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await step('User submits an invalid email.', async () => {
|
await step('User submits an invalid email.', async () => {
|
||||||
await page.webSubscribe.setValue('emailInput', 'foo@bar.nope');
|
await page.webSubscribe.setValue('emailInput', 'foo-bar');
|
||||||
await page.webSubscribe.submit();
|
await page.webSubscribe.submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -545,7 +545,7 @@ suite('Subscription use-cases', () => {
|
||||||
async function apiSubscribe(listConf, subscription) {
|
async function apiSubscribe(listConf, subscription) {
|
||||||
await step('Add subscription via API call.', async () => {
|
await step('Add subscription via API call.', async () => {
|
||||||
const response = await request({
|
const response = await request({
|
||||||
uri: `${config.baseUrl}/api/subscribe/${listConf.cid}?access_token=${config.users.admin.accessToken}`,
|
uri: `${config.baseTrustedUrl}/api/subscribe/${listConf.cid}?access_token=${config.users.admin.accessToken}`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
json: subscription
|
json: subscription
|
||||||
});
|
});
|
||||||
|
@ -630,7 +630,7 @@ suite('API Subscription use-cases', () => {
|
||||||
|
|
||||||
await step('Unsubsribe via API call.', async () => {
|
await step('Unsubsribe via API call.', async () => {
|
||||||
const response = await request({
|
const response = await request({
|
||||||
uri: `${config.baseUrl}/api/unsubscribe/${config.lists.l1.cid}?access_token=${config.users.admin.accessToken}`,
|
uri: `${config.baseTrustedUrl}/api/unsubscribe/${config.lists.l1.cid}?access_token=${config.users.admin.accessToken}`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
json: {
|
json: {
|
||||||
EMAIL: subscription.EMAIL
|
EMAIL: subscription.EMAIL
|
||||||
|
|
|
@ -456,3 +456,45 @@ function deleteAllModules {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function setupTest {
|
||||||
|
mysqlPassword=`pwgen 12 -1`
|
||||||
|
|
||||||
|
# Setup MySQL user for Mailtrain
|
||||||
|
mysql -u root -e "CREATE USER 'mailtrain_test'@'localhost' IDENTIFIED BY '$mysqlPassword';"
|
||||||
|
mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain_test.* TO 'mailtrain_test'@'localhost';"
|
||||||
|
mysql -u mailtrain_test --password="$mysqlPassword" -e "CREATE database mailtrain_test;"
|
||||||
|
|
||||||
|
# Setup installation configuration
|
||||||
|
cat > server/config/test.yaml <<EOT
|
||||||
|
mysql:
|
||||||
|
user: mailtrain_test
|
||||||
|
password: "$mysqlPassword"
|
||||||
|
database: mailtrain_test
|
||||||
|
|
||||||
|
redis:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
builtinZoneMTA:
|
||||||
|
log:
|
||||||
|
level: warn
|
||||||
|
|
||||||
|
queue:
|
||||||
|
processes: 5
|
||||||
|
|
||||||
|
testServer:
|
||||||
|
enabled: true
|
||||||
|
EOT
|
||||||
|
|
||||||
|
cat >> server/services/workers/reports/config/test.yaml <<EOT
|
||||||
|
log:
|
||||||
|
level: warn
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
user: mailtrain_test
|
||||||
|
password: "$mysqlPassword"
|
||||||
|
database: mailtrain_test
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
|
9
setup/setup-test.sh
Normal file
9
setup/setup-test.sh
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_PATH=$(dirname $(realpath -s $0))
|
||||||
|
. $SCRIPT_PATH/functions
|
||||||
|
cd $SCRIPT_PATH/..
|
||||||
|
|
||||||
|
setupTest
|
Loading…
Add table
Add a link
Reference in a new issue