Merge pull request #6764 from TryGhost/subscribe

Subscribers
This commit is contained in:
John O'Nolan 2016-05-11 13:53:53 +02:00
commit e6fe1c672c
96 changed files with 3761 additions and 170 deletions

View File

@ -0,0 +1,150 @@
import Ember from 'ember';
import { invoke, invokeAction } from 'ember-invoke-action';
import {
RequestEntityTooLargeError,
UnsupportedMediaTypeError
} from 'ghost/services/ajax';
const {
Component,
computed,
inject: {service},
isBlank,
run
} = Ember;
export default Component.extend({
tagName: 'section',
classNames: ['gh-image-uploader'],
classNameBindings: ['dragClass'],
labelText: 'Select or drag-and-drop a file',
url: null,
paramName: 'file',
file: null,
response: null,
dragClass: null,
failureMessage: null,
uploadPercentage: 0,
ajax: service(),
formData: computed('file', function () {
let paramName = this.get('paramName');
let file = this.get('file');
let formData = new FormData();
formData.append(paramName, file);
return formData;
}),
progressStyle: computed('uploadPercentage', function () {
let percentage = this.get('uploadPercentage');
let width = '';
if (percentage > 0) {
width = `${percentage}%`;
} else {
width = '0';
}
return Ember.String.htmlSafe(`width: ${width}`);
}),
dragOver(event) {
event.preventDefault();
this.set('dragClass', '--drag-over');
},
dragLeave(event) {
event.preventDefault();
this.set('dragClass', null);
},
drop(event) {
event.preventDefault();
this.set('dragClass', null);
if (event.dataTransfer.files) {
invoke(this, 'fileSelected', event.dataTransfer.files);
}
},
generateRequest() {
let ajax = this.get('ajax');
let formData = this.get('formData');
let url = this.get('url');
invokeAction(this, 'uploadStarted');
ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text',
xhr: () => {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
this._uploadProgress(event);
}, false);
return xhr;
}
}).then((response) => {
this._uploadSuccess(JSON.parse(response));
}).catch((error) => {
this._uploadFailed(error);
}).finally(() => {
invokeAction(this, 'uploadFinished');
});
},
_uploadProgress(event) {
if (event.lengthComputable) {
run(() => {
let percentage = Math.round((event.loaded / event.total) * 100);
this.set('uploadPercentage', percentage);
});
}
},
_uploadSuccess(response) {
invokeAction(this, 'uploadSuccess', response);
invoke(this, 'reset');
},
_uploadFailed(error) {
let message;
if (error instanceof UnsupportedMediaTypeError) {
message = 'The file type you uploaded is not supported.';
} else if (error instanceof RequestEntityTooLargeError) {
message = 'The file you uploaded was larger than the maximum file size your server allows.';
} else if (error.errors && !isBlank(error.errors[0].message)) {
message = error.errors[0].message;
} else {
message = 'Something went wrong :(';
}
this.set('failureMessage', message);
invokeAction(this, 'uploadFailed', error);
},
actions: {
fileSelected(fileList) {
this.set('file', fileList[0]);
run.schedule('actions', this, function () {
this.generateRequest();
});
},
reset() {
this.set('file', null);
this.set('uploadPercentage', 0);
this.set('failureMessage', null);
}
}
});

View File

@ -28,7 +28,6 @@ export default Component.extend({
ajax: service(),
config: service(),
session: service(),
// TODO: this wouldn't be necessary if the server could accept direct
// file uploads

View File

@ -0,0 +1,27 @@
import Ember from 'ember';
import LightTable from 'ember-light-table/components/light-table';
const {$, run} = Ember;
export default LightTable.extend({
// HACK: infinite pagination was not triggering when scrolling very fast
// as the throttle triggers before scrolling into the buffer area but
// the scroll finishes before the throttle timeout. Adding a debounce that
// does the same thing means that we are guaranteed a final trigger when
// scrolling stops
//
// An issue has been opened upstream, this can be removed if it gets fixed
// https://github.com/offirgolan/ember-light-table/issues/15
_setupScrollEvents() {
$(this.get('touchMoveContainer')).on('touchmove.light-table', run.bind(this, this._scrollHandler, '_touchmoveTimer'));
$(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollTimer'));
$(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollDebounce'));
},
_scrollHandler(timer) {
this.set(timer, run.debounce(this, this._onScroll, 100));
this.set(timer, run.throttle(this, this._onScroll, 100));
}
});

View File

@ -22,6 +22,7 @@ export default Component.extend({
config: service(),
session: service(),
ghostPaths: service(),
feature: service(),
mouseEnter() {
this.sendAction('onMouseEnter');

View File

@ -0,0 +1,17 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['subscribers-table'],
table: null,
actions: {
onScrolledToBottom() {
let loadNextPage = this.get('loadNextPage');
if (!this.get('isLoading')) {
loadNextPage();
}
}
}
});

View File

@ -0,0 +1,23 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
import {invokeAction} from 'ember-invoke-action';
const {computed} = Ember;
const {alias} = computed;
export default ModalComponent.extend({
submitting: false,
subscriber: alias('model'),
actions: {
confirm() {
this.set('submitting', true);
invokeAction(this, 'confirm').finally(() => {
this.set('submitting', false);
});
}
}
});

View File

@ -0,0 +1,43 @@
import Ember from 'ember';
import { invokeAction } from 'ember-invoke-action';
import ModalComponent from 'ghost/components/modals/base';
import ghostPaths from 'ghost/utils/ghost-paths';
const {computed} = Ember;
export default ModalComponent.extend({
labelText: 'Select or drag-and-drop a CSV File',
response: null,
closeDisabled: false,
uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/subscribers/csv/`;
}),
actions: {
uploadStarted() {
this.set('closeDisabled', true);
},
uploadFinished() {
this.set('closeDisabled', false);
},
uploadSuccess(response) {
this.set('response', response.meta.stats);
// invoke the passed in confirm action
invokeAction(this, 'confirm');
},
confirm() {
// noop - we don't want the enter key doing anything
},
closeModal() {
if (!this.get('closeDisabled')) {
this._super(...arguments);
}
}
}
});

View File

@ -0,0 +1,32 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
export default ModalComponent.extend({
actions: {
updateEmail(newEmail) {
this.set('model.email', newEmail);
this.set('model.hasValidated', Ember.A());
this.get('model.errors').clear();
},
confirm() {
let confirmAction = this.get('confirm');
this.set('submitting', true);
confirmAction().then(() => {
this.send('closeModal');
}).catch((errors) => {
let [error] = errors;
if (error && error.match(/email/)) {
this.get('model.errors').add('email', error);
this.get('model.hasValidated').pushObject('email');
}
}).finally(() => {
if (!this.get('isDestroying') && !this.get('isDestroyed')) {
this.set('submitting', false);
}
});
}
}
});

View File

@ -0,0 +1,162 @@
import Ember from 'ember';
import Table from 'ember-light-table';
import PaginationMixin from 'ghost/mixins/pagination';
import ghostPaths from 'ghost/utils/ghost-paths';
const {
$,
assign,
computed,
inject: {service}
} = Ember;
export default Ember.Controller.extend(PaginationMixin, {
queryParams: ['order', 'direction'],
order: 'created_at',
direction: 'desc',
paginationModel: 'subscriber',
total: 0,
table: null,
subscriberToDelete: null,
session: service(),
// paginationSettings is replaced by the pagination mixin so we need a
// getter/setter CP here so that we don't lose the dynamic order param
paginationSettings: computed('order', 'direction', {
get() {
let order = this.get('order');
let direction = this.get('direction');
let currentSettings = this._paginationSettings || {
limit: 30
};
return assign({}, currentSettings, {
order: `${order} ${direction}`
});
},
set(key, value) {
this._paginationSettings = value;
return value;
}
}),
columns: computed('order', 'direction', function () {
let order = this.get('order');
let direction = this.get('direction');
return [{
label: 'Subscriber',
valuePath: 'email',
sorted: order === 'email',
ascending: direction === 'asc'
}, {
label: 'Subscription Date',
valuePath: 'createdAt',
format(value) {
return value.format('MMMM DD, YYYY');
},
sorted: order === 'created_at',
ascending: direction === 'asc'
}, {
label: 'Status',
valuePath: 'status',
sorted: order === 'status',
ascending: direction === 'asc'
}, {
label: '',
sortable: false,
cellComponent: 'gh-subscribers-table-delete-cell',
align: 'right'
}];
}),
initializeTable() {
this.set('table', new Table(this.get('columns'), this.get('subscribers')));
},
// capture the total from the server any time we fetch a new page
didReceivePaginationMeta(meta) {
if (meta && meta.pagination) {
this.set('total', meta.pagination.total);
}
},
actions: {
loadFirstPage() {
let table = this.get('table');
return this._super(...arguments).then((results) => {
table.addRows(results);
return results;
});
},
loadNextPage() {
let table = this.get('table');
return this._super(...arguments).then((results) => {
table.addRows(results);
return results;
});
},
sortByColumn(column) {
let table = this.get('table');
if (column.sorted) {
this.setProperties({
order: column.get('valuePath').trim().underscore(),
direction: column.ascending ? 'asc' : 'desc'
});
table.setRows([]);
this.send('loadFirstPage');
}
},
addSubscriber(subscriber) {
this.get('table').insertRowAt(0, subscriber);
this.incrementProperty('total');
},
deleteSubscriber(subscriber) {
this.set('subscriberToDelete', subscriber);
},
confirmDeleteSubscriber() {
let subscriber = this.get('subscriberToDelete');
return subscriber.destroyRecord().then(() => {
this.set('subscriberToDelete', null);
this.get('table').removeRow(subscriber);
this.decrementProperty('total');
});
},
cancelDeleteSubscriber() {
this.set('subscriberToDelete', null);
},
reset() {
this.get('table').setRows([]);
this.send('loadFirstPage');
},
exportData() {
let exportUrl = ghostPaths().url.api('subscribers/csv');
let accessToken = this.get('session.data.authenticated.access_token');
let downloadURL = `${exportUrl}?access_token=${accessToken}`;
let iframe = $('#iframeDownload');
if (iframe.length === 0) {
iframe = $('<iframe>', {id: 'iframeDownload'}).hide().appendTo('body');
}
iframe.attr('src', downloadURL);
}
}
});

View File

@ -47,12 +47,78 @@ function paginatedResponse(modelName, allModels, request) {
};
}
function mockSubscribers(server) {
server.get('/subscribers/', function (db, request) {
let response = paginatedResponse('subscribers', db.subscribers, request);
return response;
});
server.post('/subscribers/', function (db, request) {
let [attrs] = JSON.parse(request.requestBody).subscribers;
let [subscriber] = db.subscribers.where({email: attrs.email});
if (subscriber) {
return new Mirage.Response(422, {}, {
errors: [{
errorType: 'DataImportError',
message: 'duplicate email',
property: 'email'
}]
});
} else {
attrs.created_at = new Date();
attrs.created_by = 0;
subscriber = db.subscribers.insert(attrs);
return {
subscriber
};
}
});
server.put('/subscribers/:id/', function (db, request) {
let {id} = request.params;
let [attrs] = JSON.parse(request.requestBody).subscribers;
let subscriber = db.subscribers.update(id, attrs);
return {
subscriber
};
});
server.del('/subscribers/:id/', function (db, request) {
db.subscribers.remove(request.params.id);
return new Mirage.Response(204, {}, {});
});
server.post('/subscribers/csv/', function (/*db, request*/) {
// NB: we get a raw FormData object with no way to inspect it in Chrome
// until version 50 adds the additional read methods
// https://developer.mozilla.org/en-US/docs/Web/API/FormData#Browser_compatibility
server.createList('subscriber', 50);
return {
meta: {
stats: {
imported: 50,
duplicates: 3,
invalid: 2
}
}
};
});
}
export default function () {
// this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server
this.namespace = 'ghost/api/v0.1'; // make this `api`, for example, if your API is namespaced
// this.timing = 400; // delay for each request, automatically set to 0 during testing
this.timing = 400; // delay for each request, automatically set to 0 during testing
// Mock endpoints here to override real API requests during development
mockSubscribers(this);
// keep this line, it allows all other API requests to hit the real server
this.passthrough();
@ -251,6 +317,10 @@ export function testConfig() {
};
});
/* Subscribers ---------------------------------------------------------- */
mockSubscribers(this);
/* Tags ----------------------------------------------------------------- */
this.post('/tags/', function (db, request) {

View File

@ -0,0 +1,21 @@
import Mirage, {faker} from 'ember-cli-mirage';
let randomDate = function randomDate(start = moment().subtract(30, 'days').toDate(), end = new Date()) {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
};
let statuses = ['pending', 'subscribed'];
// jscs:disable requireBlocksOnNewline
// jscs:disable requireCamelCaseOrUpperCaseIdentifiers
export default Mirage.Factory.extend({
uuid(i) { return `subscriber-${i}`; },
name() { return `${faker.name.firstName()} ${faker.name.lastName()}`; },
email() { return faker.internet.email(); },
status() { return statuses[Math.floor(Math.random() * statuses.length)]; },
created_at() { return randomDate(); },
updated_at: null,
created_by: 0,
updated_by: null,
unsubscribed_at: null
});

View File

@ -125,7 +125,7 @@ export default [
id: 12,
uuid: 'd806f358-7996-4c74-b153-8876959c4b70',
key: 'labs',
value: '{"codeInjectionUI":true}',
value: '{"codeInjectionUI":true,"subscribers":true}',
type: 'blog',
created_at: '2015-01-12T18:29:01.000Z',
created_by: 1,

View File

@ -1,6 +1,8 @@
export default function (/* server */) {
export default function (server) {
// Seed your development database using your factories. This
// data will not be loaded in your tests.
// server.createList('contact', 10);
server.createList('subscriber', 125);
}

View File

@ -4,6 +4,7 @@ import getRequestErrorMessage from 'ghost/utils/ajax';
const {
Mixin,
computed,
RSVP,
inject: {service}
} = Ember;
@ -34,7 +35,7 @@ export default Mixin.create({
init() {
let paginationSettings = this.get('paginationSettings');
let settings = Ember.$.extend({}, defaultPaginationSettings, paginationSettings);
let settings = Ember.assign({}, defaultPaginationSettings, paginationSettings);
this._super(...arguments);
this.set('paginationSettings', settings);
@ -63,7 +64,7 @@ export default Mixin.create({
let paginationSettings = this.get('paginationSettings');
let modelName = this.get('paginationModel');
paginationSettings.page = 1;
this.set('paginationSettings.page', 1);
this.set('isLoading', true);
@ -93,7 +94,7 @@ export default Mixin.create({
let nextPage = metadata.pagination && metadata.pagination.next;
let paginationSettings = this.get('paginationSettings');
if (nextPage) {
if (nextPage && !this.get('isLoading')) {
this.set('isLoading', true);
this.set('paginationSettings.page', nextPage);
@ -105,6 +106,8 @@ export default Mixin.create({
}).finally(() => {
this.set('isLoading', false);
});
} else {
return RSVP.resolve([]);
}
},

View File

@ -3,18 +3,20 @@ import DS from 'ember-data';
import Model from 'ember-data/model';
import getRequestErrorMessage from 'ghost/utils/ajax';
import ValidatorExtensions from 'ghost/utils/validator-extensions';
import PostValidator from 'ghost/validators/post';
import SetupValidator from 'ghost/validators/setup';
import SignupValidator from 'ghost/validators/signup';
import SigninValidator from 'ghost/validators/signin';
import SettingValidator from 'ghost/validators/setting';
import ResetValidator from 'ghost/validators/reset';
import UserValidator from 'ghost/validators/user';
import TagSettingsValidator from 'ghost/validators/tag-settings';
import NavItemValidator from 'ghost/validators/nav-item';
import InviteUserValidator from 'ghost/validators/invite-user';
import NavItemValidator from 'ghost/validators/nav-item';
import PostValidator from 'ghost/validators/post';
import ResetValidator from 'ghost/validators/reset';
import SettingValidator from 'ghost/validators/setting';
import SetupValidator from 'ghost/validators/setup';
import SigninValidator from 'ghost/validators/signin';
import SignupValidator from 'ghost/validators/signup';
import SlackIntegrationValidator from 'ghost/validators/slack-integration';
import SubscriberValidator from 'ghost/validators/subscriber';
import TagSettingsValidator from 'ghost/validators/tag-settings';
import UserValidator from 'ghost/validators/user';
import ValidatorExtensions from 'ghost/utils/validator-extensions';
const {Mixin, RSVP, isArray} = Ember;
const {Errors} = DS;
@ -36,17 +38,18 @@ export default Mixin.create({
// in that case the model will be the class that the ValidationEngine
// was mixed into, i.e. the controller or Ember Data model.
validators: {
post: PostValidator,
setup: SetupValidator,
signup: SignupValidator,
signin: SigninValidator,
setting: SettingValidator,
reset: ResetValidator,
user: UserValidator,
tag: TagSettingsValidator,
navItem: NavItemValidator,
inviteUser: InviteUserValidator,
slackIntegration: SlackIntegrationValidator
navItem: NavItemValidator,
post: PostValidator,
reset: ResetValidator,
setting: SettingValidator,
setup: SetupValidator,
signin: SigninValidator,
signup: SignupValidator,
slackIntegration: SlackIntegrationValidator,
subscriber: SubscriberValidator,
tag: TagSettingsValidator,
user: UserValidator
},
// This adds the Errors object to the validation engine, and shouldn't affect

View File

@ -0,0 +1,23 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import {belongsTo} from 'ember-data/relationships';
import ValidationEngine from 'ghost/mixins/validation-engine';
export default Model.extend(ValidationEngine, {
validationType: 'subscriber',
uuid: attr('string'),
name: attr('string'),
email: attr('string'),
status: attr('string'),
subscribedUrl: attr('string'),
subscribedReferrer: attr('string'),
unsubscribedUrl: attr('string'),
unsubscribedAt: attr('moment-date'),
createdAt: attr('moment-date'),
updatedAt: attr('moment-date'),
createdBy: attr('number'),
updatedBy: attr('number'),
post: belongsTo('post')
});

View File

@ -59,6 +59,11 @@ Router.map(function () {
this.route('slack', {path: 'slack'});
});
this.route('subscribers', function() {
this.route('new');
this.route('import');
});
this.route('error404', {path: '/*path'});
});

View File

@ -0,0 +1,54 @@
import Ember from 'ember';
import AuthenticatedRoute from 'ghost/routes/authenticated';
const {
RSVP,
inject: {service}
} = Ember;
export default AuthenticatedRoute.extend({
titleToken: 'Subscribers',
feature: service(),
// redirect if subscribers is disabled or user isn't owner/admin
beforeModel() {
this._super(...arguments);
let promises = {
user: this.get('session.user'),
subscribers: this.get('feature.subscribers')
};
return RSVP.hash(promises).then((hash) => {
let {user, subscribers} = hash;
if (!subscribers || !(user.get('isOwner') || user.get('isAdmin'))) {
return this.transitionTo('posts');
}
});
},
setupController(controller) {
this._super(...arguments);
controller.initializeTable();
controller.send('loadFirstPage');
},
resetController(controller, isExiting) {
this._super(...arguments);
if (isExiting) {
controller.set('order', 'created_at');
controller.set('direction', 'desc');
}
},
actions: {
addSubscriber(subscriber) {
this.get('controller').send('addSubscriber', subscriber);
},
reset() {
this.get('controller').send('reset');
}
}
});

View File

@ -0,0 +1,9 @@
import Ember from 'ember';
export default Ember.Route.extend({
actions: {
cancel() {
this.transitionTo('subscribers');
}
}
});

View File

@ -0,0 +1,37 @@
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.get('store').createRecord('subscriber');
},
deactivate() {
let subscriber = this.controller.get('model');
this._super(...arguments);
if (subscriber.get('isNew')) {
this.rollbackModel();
}
},
rollbackModel() {
let subscriber = this.controller.get('model');
subscriber.rollbackAttributes();
},
actions: {
save() {
let subscriber = this.controller.get('model');
return subscriber.save().then((saved) => {
this.send('addSubscriber', saved);
return saved;
});
},
cancel() {
this.rollbackModel();
this.transitionTo('subscribers');
}
}
});

View File

@ -31,6 +31,7 @@ export default Service.extend({
notifications: service(),
publicAPI: feature('publicAPI'),
subscribers: feature('subscribers'),
_settings: null,

View File

@ -45,3 +45,4 @@
@import "layouts/error.css";
@import "layouts/apps.css";
@import "layouts/packages.css";
@import "layouts/subscribers.css";

View File

@ -61,10 +61,6 @@
/* The modal
/* ---------------------------------------------------------- */
.fullscreen-modal .gh-image-uploader {
margin: 0;
}
/* Modal content
/* ---------------------------------------------------------- */
@ -143,6 +139,10 @@
margin-left: 0;
}
.modal-body .gh-image-uploader {
margin: 0;
}
/* Content Modifiers
/* ---------------------------------------------------------- */

View File

@ -0,0 +1,69 @@
/* Subscribers Management /ghost/subscribers/
/* ---------------------------------------------------------- */
.view-subscribers .view-container {
display: flex;
flex-direction: row;
min-height: 100%;
}
/* Table (left pane)
/* ---------------------------------------------------------- */
.subscribers-table {
flex-grow: 1;
overflow-y: auto;
padding: 0 12px; /* ember-light-table has 8px padding on cells */
max-height: 100%;
}
.subscribers-table table {
margin: 0;
}
.subscribers-table table .btn {
visibility: hidden;
}
.subscribers-table table tr:hover .btn {
visibility: visible;
}
.subscribers-table tbody td:last-of-type {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
/* Sidebar (right pane)
/* ---------------------------------------------------------- */
.subscribers-sidebar {
width: 350px;
border-left: 1px solid #dfe1e3;
}
.subscribers-import-buttons {
display: flex;
flex-direction: row;
}
.subscribers-import-buttons .btn {
flex-grow: 1;
margin-right: 10px;
}
.subscribers-import-buttons .btn:last-of-type {
margin-right: 0;
}
/* Import modal
/* ---------------------------------------------------------- */
.subscribers-import-results {
margin: 0;
width: auto;
}

View File

@ -65,6 +65,13 @@ fieldset[disabled] .btn {
vertical-align: middle;
}
.btn-hover-green:hover,
.btn-hover-green:active,
.btn-hover-green:focus {
border-color: var(--green);
color: color(var(--green) lightness(-10%));
}
/* Blue button
/* ---------------------------------------------------------- */

View File

@ -87,9 +87,15 @@
.icon-idea:before {
content: "\e00e";
}
.icon-arrow:before {
.icon-arrow:before,
.icon-ascending:before,
.icon-descending:before {
content: "\e00f";
}
.icon-ascending:before {
display: inline-block;
transform: rotate(180deg);
}
.icon-pen:before {
content: "\e010";
}

View File

@ -59,3 +59,20 @@ table td,
.table.plain tbody > tr:nth-child(odd) > th {
background: transparent;
}
/* Ember Light Table
/* ---------------------------------------------------------- */
.ember-light-table th {
white-space: nowrap;
}
.ember-light-table .lt-column .lt-sort-icon {
float: none;
margin-left: 0.3rem;
}
.lt-sort-icon.icon-ascending:before,
.lt-sort-icon.icon-descending:before {
font-size: 0.6em;
}

View File

@ -0,0 +1,20 @@
{{#if file}}
{{!-- Upload in progress! --}}
{{#if failureMessage}}
<div class="failed">{{failureMessage}}</div>
{{/if}}
<div class="progress-container">
<div class="progress">
<div class="bar {{if failureMessage "fail"}}" style={{progressStyle}}></div>
</div>
</div>
{{#if failureMessage}}
<button class="btn btn-green" {{action "reset"}}>Try Again</button>
{{/if}}
{{else}}
<div class="upload-form">
{{#x-file-input multiple=false alt=labelText action=(action 'fileSelected') accept="text/csv"}}
<div class="description">{{labelText}}</div>
{{/x-file-input}}
</div>
{{/if}}

View File

@ -25,6 +25,11 @@
{{!<li><a href="#"><i class="icon-user"></i>My Posts</a></li>}}
<li>{{#link-to "team" classNames="gh-nav-main-users"}}<i class="icon-team"></i>Team{{/link-to}}</li>
{{!<li><a href="#"><i class="icon-idea"></i>Ideas</a></li>}}
{{#if feature.subscribers}}
{{#if (gh-user-can-admin session.user)}}
<li>{{#link-to "subscribers" classNames="gh-nav-main-subscribers"}}<i class="icon-mail"></i>Subscribers{{/link-to}}</li>
{{/if}}
{{/if}}
</ul>
{{#if (gh-user-can-admin session.user)}}
<ul class="gh-nav-list gh-nav-settings">

View File

@ -0,0 +1 @@
<button class="btn btn-minor btn-sm" {{action tableActions.delete row.content}}><i class="icon-trash"></i></button>

View File

@ -0,0 +1,17 @@
{{#gh-light-table table scrollContainer=".subscribers-table" scrollBuffer=100 onScrolledToBottom=(action 'onScrolledToBottom') as |t|}}
{{t.head onColumnClick=(action sortByColumn) iconAscending="icon-ascending" iconDescending="icon-descending"}}
{{#t.body canSelect=false tableActions=(hash delete=(action delete)) as |body|}}
{{#if isLoading}}
{{#body.loader}}
Loading...
{{/body.loader}}
{{else}}
{{#if table.isEmpty}}
{{#body.no-data}}
No subscribers found.
{{/body.no-data}}
{{/if}}
{{/if}}
{{/t.body}}
{{/gh-light-table}}

View File

@ -0,0 +1,13 @@
<header class="modal-header">
<h1>Are you sure?</h1>
</header>
<a class="close icon-x" href="" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body">
<strong>WARNING:</strong> All data for this subscriber will be deleted. There is no way to recover this.
</div>
<div class="modal-footer">
<button {{action "closeModal"}} class="btn btn-default btn-minor">Cancel</button>
{{#gh-spin-button action="confirm" class="btn btn-red" submitting=submitting}}Delete{{/gh-spin-button}}
</div>

View File

@ -0,0 +1,47 @@
<header class="modal-header">
<h1>
{{#if response}}
Import Successful
{{else}}
Import Subscribers
{{/if}}
</h1>
</header>
<a class="close icon-x" href="" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body">
{{#liquid-if response class="fade-transition"}}
<table class="subscribers-import-results">
<tr>
<td>Imported:</td>
<td align="left">{{response.imported}}</td>
</tr>
{{#if response.duplicates}}
<tr>
<td>Duplicates:</td>
<td align="left">{{response.duplicates}}</td>
</tr>
{{/if}}
{{#if response.invalid}}
<tr>
<td>Invalid:</td>
<td align="left">{{response.invalid}}</td>
</tr>
{{/if}}
</table>
{{else}}
{{gh-file-uploader
url=uploadUrl
paramName="subscribersfile"
labelText="Select or drag-and-drop a CSV file."
uploadStarted=(action 'uploadStarted')
uploadFinished=(action 'uploadFinished')
uploadSuccess=(action 'uploadSuccess')}}
{{/liquid-if}}
</div>
<div class="modal-footer">
<button {{action "closeModal"}} disabled={{closeDisabled}} class="btn btn-default btn-minor">
{{#if response}}Close{{else}}Cancel{{/if}}
</button>
</div>

View File

@ -0,0 +1,29 @@
<header class="modal-header">
<h1>Add a Subscriber</h1>
</header>
<a class="close icon-x" href="" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body">
<fieldset>
{{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="email"}}
<label for="new-subscriber-email">Email Address</label>
<input type="email"
value={{model.email}}
oninput={{action "updateEmail" value="target.value"}}
id="new-subscriber-email"
class="gh-input email"
placeholder="Email Address"
name="email"
autofocus="autofocus"
autocapitalize="off"
autocorrect="off">
{{gh-error-message errors=model.errors property="email"}}
{{/gh-form-group}}
</fieldset>
</div>
<div class="modal-footer">
<button {{action "closeModal"}} class="btn btn-default btn-minor">Cancel</button>
{{#gh-spin-button action="confirm" class="btn btn-green" submitting=submitting}}Add{{/gh-spin-button}}
</div>

View File

@ -50,6 +50,9 @@
{{#gh-feature-flag "publicAPI"}}
Public API - For full instructions, read the <a href="http://support.ghost.org/public-api-beta/">developer guide</a>.
{{/gh-feature-flag}}
{{#gh-feature-flag "subscribers"}}
Subscribers - Allow visitors to subscribe to e-mail updates of your new posts
{{/gh-feature-flag}}
</div>
</fieldset>
</form>

View File

@ -0,0 +1,49 @@
<section class="gh-view view-subscribers">
<header class="view-header">
{{#gh-view-title openMobileMenu="openMobileMenu"}}<span>Subscribers</span>{{/gh-view-title}}
<div class="view-actions">
{{#link-to "subscribers.new" class="btn btn-green"}}Add Subscriber{{/link-to}}
</div>
</header>
<section class="view-container">
{{gh-subscribers-table
table=table
isLoading=isLoading
loadNextPage=(action 'loadNextPage')
sortByColumn=(action 'sortByColumn')
delete=(action 'deleteSubscriber')}}
<div class="subscribers-sidebar">
<div class="settings-menu-header">
<h4>Import Subscribers</h4>
</div>
<div class="settings-menu-content subscribers-import-buttons">
{{#link-to "subscribers.import" class="btn btn-hover-green"}}Import CSV{{/link-to}}
<a {{action 'exportData'}} class="btn">Export CSV</a>
</div>
<div class="settings-menu-header">
<h4>Quick Stats</h4>
</div>
<div class="settings-menu-content">
<ul>
<li>
Total Subscribers:
<span id="total-subscribers">{{total}}</span>
</li>
</ul>
</div>
</div>
</section>
</section>
{{#if subscriberToDelete}}
{{gh-fullscreen-modal "delete-subscriber"
model=subscriberToDelete
confirm=(action "confirmDeleteSubscriber")
close=(action "cancelDeleteSubscriber")
modifier="action wide"}}
{{/if}}
{{outlet}}

View File

@ -0,0 +1,3 @@
{{gh-fullscreen-modal "import-subscribers"
confirm=(route-action "reset")
close=(route-action "cancel")}}

View File

@ -0,0 +1,4 @@
{{gh-fullscreen-modal "new-subscriber"
model=model
confirm=(route-action "save")
close=(route-action "cancel")}}

View File

@ -8,4 +8,9 @@ export default function () {
this.use('tether', ['fade', {duration: 150}], ['fade', {duration: 150}]),
this.reverse('tether', ['fade', {duration: 80}], ['fade', {duration: 150}])
);
this.transition(
this.hasClass('fade-transition'),
this.use('crossFade', {duration: 100})
);
}

View File

@ -2,6 +2,9 @@ import Ember from 'ember';
const {isArray} = Ember;
// TODO: this should be removed and instead have our app serializer properly
// process the response so that errors can be tied to the model
// Used in API request fail handlers to parse a standard api error
// response json for the message to display
export default function getRequestErrorMessage(request, performConcat) {
@ -20,12 +23,12 @@ export default function getRequestErrorMessage(request, performConcat) {
if (request.status !== 200) {
try {
// Try to parse out the error, or default to 'Unknown'
if (request.responseJSON.errors && isArray(request.responseJSON.errors)) {
message = request.responseJSON.errors.map((errorItem) => {
if (request.errors && isArray(request.errors)) {
message = request.errors.map((errorItem) => {
return errorItem.message;
});
} else {
message = request.responseJSON.error || 'Unknown Error';
message = request.error || 'Unknown Error';
}
} catch (e) {
msgDetail = request.status ? `${request.status} - ${request.statusText}` : 'Server was not available';

View File

@ -0,0 +1,19 @@
import BaseValidator from './base';
export default BaseValidator.create({
properties: ['email'],
email(model) {
let email = model.get('email');
if (validator.empty(email)) {
model.get('errors').add('email', 'Please enter an email.');
model.get('hasValidated').pushObject('email');
this.invalidate();
} else if (!validator.isEmail(email)) {
model.get('errors').add('email', 'Invalid email.');
model.get('hasValidated').pushObject('email');
this.invalidate();
}
}
});

View File

@ -42,6 +42,7 @@
"ember-data-filter": "1.13.0",
"ember-export-application-global": "1.0.5",
"ember-invoke-action": "1.3.0",
"ember-light-table": "0.1.9",
"ember-load-initializers": "0.5.1",
"ember-myth": "0.1.1",
"ember-one-way-controls": "0.6.2",

View File

@ -0,0 +1,256 @@
/* jshint expr:true */
import {
describe,
it,
beforeEach,
afterEach
} from 'mocha';
import { expect } from 'chai';
import startApp from '../helpers/start-app';
import destroyApp from '../helpers/destroy-app';
import { invalidateSession, authenticateSession } from 'ghost/tests/helpers/ember-simple-auth';
describe('Acceptance: Subscribers', function() {
let application;
beforeEach(function() {
application = startApp();
});
afterEach(function() {
destroyApp(application);
});
it('redirects to signin when not authenticated', function () {
invalidateSession(application);
visit('/subscribers');
andThen(function () {
expect(currentURL()).to.equal('/signin');
});
});
it('redirects editors to posts', function () {
let role = server.create('role', {name: 'Editor'});
let user = server.create('user', {roles: [role]});
authenticateSession(application);
visit('/subscribers');
andThen(function () {
expect(currentURL()).to.equal('/');
expect(find('.gh-nav-main a:contains("Subscribers")').length, 'sidebar link is visible')
.to.equal(0);
});
});
it('redirects authors to posts', function () {
let role = server.create('role', {name: 'Author'});
let user = server.create('user', {roles: [role]});
authenticateSession(application);
visit('/subscribers');
andThen(function () {
expect(currentURL()).to.equal('/');
expect(find('.gh-nav-main a:contains("Subscribers")').length, 'sidebar link is visible')
.to.equal(0);
});
});
describe('an admin', function () {
beforeEach(function () {
let role = server.create('role', {name: 'Administrator'});
let user = server.create('user', {roles: [role]});
server.loadFixtures();
return authenticateSession(application);
});
it('can manage subscribers', function () {
server.createList('subscriber', 40);
authenticateSession(application);
visit('/');
click('.gh-nav-main a:contains("Subscribers")');
andThen(function() {
// it navigates to the correct page
expect(currentPath()).to.equal('subscribers.index');
// it has correct page title
expect(document.title, 'page title')
.to.equal('Subscribers - Test Blog');
// it loads the first page
expect(find('.subscribers-table .lt-body .lt-row').length, 'number of subscriber rows')
.to.equal(30);
// it shows the total number of subscribers
expect(find('#total-subscribers').text().trim(), 'displayed subscribers total')
.to.equal('40');
// it defaults to sorting by created_at desc
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.order).to.equal('created_at desc');
let createdAtHeader = find('.subscribers-table th:contains("Subscription Date")');
expect(createdAtHeader.hasClass('is-sorted'), 'createdAt column is sorted')
.to.be.true;
expect(createdAtHeader.find('.icon-descending').length, 'createdAt column has descending icon')
.to.equal(1);
});
// click the column to re-order
click('th:contains("Subscription Date")');
andThen(function () {
// it flips the directions and re-fetches
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.order).to.equal('created_at asc');
let createdAtHeader = find('.subscribers-table th:contains("Subscription Date")');
expect(createdAtHeader.find('.icon-ascending').length, 'createdAt column has ascending icon')
.to.equal(1);
// scroll to the bottom of the table to simulate infinite scroll
find('.subscribers-table').scrollTop(find('.subscribers-table .ember-light-table').height());
});
// trigger infinite scroll
triggerEvent('.subscribers-table', 'scroll');
andThen(function () {
// it loads the next page
expect(find('.subscribers-table .lt-body .lt-row').length, 'number of subscriber rows after infinite-scroll')
.to.equal(40);
});
// click the add subscriber button
click('.btn:contains("Add Subscriber")');
andThen(function () {
// it displays the add subscriber modal
expect(find('.fullscreen-modal').length, 'add subscriber modal displayed')
.to.equal(1);
});
// cancel the modal
click('.fullscreen-modal .btn:contains("Cancel")');
andThen(function () {
// it closes the add subscriber modal
expect(find('.fullscreen-modal').length, 'add subscriber modal displayed after cancel')
.to.equal(0);
});
// save a new subscriber
click('.btn:contains("Add Subscriber")');
fillIn('.fullscreen-modal input[name="email"]', 'test@example.com');
click('.fullscreen-modal .btn:contains("Add")');
andThen(function () {
// the add subscriber modal is closed
expect(find('.fullscreen-modal').length, 'add subscriber modal displayed after save')
.to.equal(0);
// the subscriber is added to the table
expect(find('.subscribers-table .lt-body .lt-row:first-of-type .lt-cell:first-of-type').text().trim(), 'first email in list after addition')
.to.equal('test@example.com');
// the table is scrolled to the top
// TODO: implement scroll to new record after addition
// expect(find('.subscribers-table').scrollTop(), 'scroll position after addition')
// .to.equal(0);
// the subscriber total is updated
expect(find('#total-subscribers').text().trim(), 'subscribers total after addition')
.to.equal('41');
});
// saving a duplicate subscriber
click('.btn:contains("Add Subscriber")');
fillIn('.fullscreen-modal input[name="email"]', 'test@example.com');
click('.fullscreen-modal .btn:contains("Add")');
andThen(function () {
// the validation error is displayed
expect(find('.fullscreen-modal .error .response').text().trim(), 'duplicate email validation')
.to.match(/duplicate/);
// the subscriber is not added to the table
expect(find('.lt-cell:contains(test@example.com)').length, 'number of "test@example.com rows"')
.to.equal(1);
// the subscriber total is unchanged
expect(find('#total-subscribers').text().trim(), 'subscribers total after failed add')
.to.equal('41');
});
// deleting a subscriber
click('.fullscreen-modal .btn:contains("Cancel")');
click('.subscribers-table tbody tr:first-of-type button:last-of-type');
andThen(function () {
// it displays the delete subscriber modal
expect(find('.fullscreen-modal').length, 'delete subscriber modal displayed')
.to.equal(1);
});
// cancel the modal
click('.fullscreen-modal .btn:contains("Cancel")');
andThen(function () {
// return pauseTest();
// it closes the add subscriber modal
expect(find('.fullscreen-modal').length, 'delete subscriber modal displayed after cancel')
.to.equal(0);
});
click('.subscribers-table tbody tr:first-of-type button:last-of-type');
click('.fullscreen-modal .btn:contains("Delete")');
andThen(function () {
// the add subscriber modal is closed
expect(find('.fullscreen-modal').length, 'delete subscriber modal displayed after confirm')
.to.equal(0);
// the subscriber is removed from the table
expect(find('.subscribers-table .lt-body .lt-row:first-of-type .lt-cell:first-of-type').text().trim(), 'first email in list after addition')
.to.not.equal('test@example.com');
// the subscriber total is updated
expect(find('#total-subscribers').text().trim(), 'subscribers total after addition')
.to.equal('40');
});
// click the import subscribers button
click('.btn:contains("Import CSV")');
andThen(function () {
// it displays the import subscribers modal
expect(find('.fullscreen-modal').length, 'import subscribers modal displayed')
.to.equal(1);
});
// cancel the modal
click('.fullscreen-modal .btn:contains("Cancel")');
andThen(function () {
// it closes the import subscribers modal
expect(find('.fullscreen-modal').length, 'import subscribers modal displayed after cancel')
.to.equal(0);
});
// TODO: how to simulate file upload?
// re-open import modal
// upload a file
// modal title changes
// modal button changes
// table is reset
// close modal
});
});
});

View File

@ -0,0 +1,93 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import Ember from 'ember';
import Pretender from 'pretender';
import wait from 'ember-test-helpers/wait';
const {run} = Ember;
const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay);
};
const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{
errorType: error,
message: `Error: ${error}`
}]
})];
}, delay);
};
describeComponent(
'gh-file-uploader',
'Integration: Component: gh-file-uploader',
{
integration: true
},
function() {
let server;
beforeEach(function () {
server = new Pretender();
});
afterEach(function () {
server.shutdown();
});
it('renders', function() {
this.render(hbs`{{gh-file-uploader}}`);
expect(this.$('label').text().trim(), 'default label')
.to.equal('Select or drag-and-drop a file');
});
it('renders form with supplied label text', function () {
this.set('labelText', 'My label');
this.render(hbs`{{gh-file-uploader labelText=labelText}}`);
expect(this.$('label').text().trim(), 'label')
.to.equal('My label');
});
it('generates request to supplied endpoint', function (done) {
stubSuccessfulUpload(server);
this.set('uploadUrl', '/ghost/api/v0.1/uploads/');
this.render(hbs`{{gh-file-uploader url=uploadUrl}}`);
this.$('input[type="file"]').trigger('change');
wait().then(() => {
expect(server.handledRequests.length).to.equal(1);
expect(server.handledRequests[0].url).to.equal('/ghost/api/v0.1/uploads/');
done();
});
});
it('handles drag over/leave', function () {
this.render(hbs`{{gh-file-uploader}}`);
run(() => {
this.$('.gh-image-uploader').trigger('dragover');
});
expect(this.$('.gh-image-uploader').hasClass('--drag-over'), 'has drag-over class').to.be.true;
run(() => {
this.$('.gh-image-uploader').trigger('dragleave');
});
expect(this.$('.gh-image-uploader').hasClass('--drag-over'), 'has drag-over class').to.be.false;
});
}
);

View File

@ -0,0 +1,26 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import Table from 'ember-light-table';
describeComponent(
'gh-subscribers-table',
'Integration: Component: gh-subscribers-table',
{
integration: true
},
function() {
it('renders', function() {
this.set('table', new Table([], []));
this.set('sortByColumn', function () {});
this.set('delete', function () {});
this.render(hbs`{{gh-subscribers-table table=table sortByColumn=(action sortByColumn) delete=(action delete)}}`);
expect(this.$()).to.have.length(1);
});
}
);

View File

@ -0,0 +1,30 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describeComponent(
'modals/delete-subscriber',
'Integration: Component: modals/delete-subscriber',
{
integration: true
},
function() {
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#modals/delete-subscriber}}
// template content
// {{/modals/delete-subscriber}}
// `);
this.render(hbs`{{modals/delete-subscriber}}`);
expect(this.$()).to.have.length(1);
});
}
);

View File

@ -0,0 +1,30 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describeComponent(
'modals/import-subscribers',
'Integration: Component: modals/import-subscribers',
{
integration: true
},
function() {
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#modals/import-subscribers}}
// template content
// {{/modals/import-subscribers}}
// `);
this.render(hbs`{{modals/import-subscribers}}`);
expect(this.$()).to.have.length(1);
});
}
);

View File

@ -0,0 +1,30 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describeComponent(
'modals/new-subscriber',
'Integration: Component: modals/new-subscriber',
{
integration: true
},
function() {
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#modals/new-subscriber}}
// template content
// {{/modals/new-subscriber}}
// `);
this.render(hbs`{{modals/new-subscriber}}`);
expect(this.$()).to.have.length(1);
});
}
);

View File

@ -0,0 +1,313 @@
/* jshint expr:true */
/* global Blob */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import Ember from 'ember';
import sinon from 'sinon';
import Pretender from 'pretender';
import wait from 'ember-test-helpers/wait';
const {run} = Ember;
const createFile = function (content = ['test'], options = {}) {
let {
name,
type,
lastModifiedDate
} = options;
let file = new Blob(content, {type: type ? type : 'text/plain'});
file.name = name ? name : 'text.txt';
return file;
};
const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay);
};
const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{
errorType: error,
message: `Error: ${error}`
}]
})];
}, delay);
};
describeComponent(
'gh-file-uploader',
'Unit: Component: gh-file-uploader',
{
needs: [
'service:ajax',
'service:session', // used by ajax service
'service:feature',
'component:x-file-input'
],
unit: true
},
function() {
let server, url;
beforeEach(function () {
server = new Pretender();
url = '/ghost/api/v0.1/uploads/';
});
afterEach(function () {
server.shutdown();
});
it('renders', function() {
// creates the component instance
let component = this.subject();
// renders the component on the page
this.render();
expect(component).to.be.ok;
expect(this.$()).to.have.length(1);
});
it('fires uploadSuccess action on successful upload', function (done) {
let uploadSuccess = sinon.spy();
let component = this.subject({url, uploadSuccess});
let file = createFile();
stubSuccessfulUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadSuccess.calledOnce).to.be.true;
expect(uploadSuccess.firstCall.args[0]).to.equal('/content/images/test.png');
done();
});
});
it('fires uploadStarted action on upload start', function (done) {
let uploadStarted = sinon.spy();
let component = this.subject({url, uploadStarted});
let file = createFile();
stubSuccessfulUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadStarted.calledOnce).to.be.true;
done();
});
});
it('fires uploadFinished action on successful upload', function (done) {
let uploadFinished = sinon.spy();
let component = this.subject({url, uploadFinished});
let file = createFile();
stubSuccessfulUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadFinished.calledOnce).to.be.true;
done();
});
});
it('fires uploadFinished action on failed upload', function (done) {
let uploadFinished = sinon.spy();
let component = this.subject({url, uploadFinished});
let file = createFile();
stubFailedUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadFinished.calledOnce).to.be.true;
done();
});
});
it('displays invalid file type error', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 415, 'UnsupportedMediaTypeError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The file type you uploaded is not supported/);
expect(this.$('.btn-green').length, 'reset button is displayed').to.equal(1);
expect(this.$('.btn-green').text()).to.equal('Try Again');
done();
});
});
it('displays file too large for server error', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 413, 'RequestEntityTooLargeError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The file you uploaded was larger/);
done();
});
});
it('handles file too large error directly from the web server', function (done) {
let component = this.subject({url});
let file = createFile();
server.post('/ghost/api/v0.1/uploads/', function () {
return [413, {}, ''];
});
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The file you uploaded was larger/);
done();
});
});
it('displays other server-side error with message', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 400, 'UnknownError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/Error: UnknownError/);
done();
});
});
it('handles unknown failure', function (done) {
let component = this.subject({url});
let file = createFile();
server.post('/ghost/api/v0.1/uploads/', function () {
return [500, {'Content-Type': 'application/json'}, ''];
});
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/Something went wrong/);
done();
});
});
it('can be reset after a failed upload', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 400, 'UnknownError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
run(() => {
this.$('.btn-green').click();
});
});
wait().then(() => {
expect(this.$('input[type="file"]').length).to.equal(1);
done();
});
});
it('displays upload progress', function (done) {
let component = this.subject({url, uploadFinished: done});
let file = createFile();
// pretender fires a progress event every 50ms
stubSuccessfulUpload(server, 150);
this.render();
run(() => {
component.send('fileSelected', [file]);
});
// after 75ms we should have had one progress event
run.later(this, function () {
expect(this.$('.progress .bar').length).to.equal(1);
let [_, percentageWidth] = this.$('.progress .bar').attr('style').match(/width: (\d+)%?/);
expect(percentageWidth).to.be.above(0);
expect(percentageWidth).to.be.below(100);
}, 75);
});
it('triggers file upload on file drop', function (done) {
let uploadSuccess = sinon.spy();
let component = this.subject({url, uploadSuccess});
let file = createFile();
let drop = Ember.$.Event('drop', {
dataTransfer: {
files: [file]
}
});
stubSuccessfulUpload(server);
this.render();
run(() => {
this.$().trigger(drop);
});
wait().then(() => {
expect(uploadSuccess.calledOnce).to.be.true;
expect(uploadSuccess.firstCall.args[0]).to.equal('/content/images/test.png');
done();
});
});
}
);

View File

@ -0,0 +1,21 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'controller:subscribers',
'Unit: Controller: subscribers',
{
needs: ['service:notifications']
},
function() {
// Replace this with your real tests.
it('exists', function() {
let controller = this.subject();
expect(controller).to.be.ok;
});
}
);

View File

@ -0,0 +1,20 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'subscriber',
'Unit: Model: subscriber',
{
// Specify the other units that are required for this test.
needs: ['model:post']
},
function() {
// Replace this with your real tests.
it('exists', function() {
let model = this.subject();
// var store = this.store();
expect(model).to.be.ok;
});
}
);

View File

@ -0,0 +1,20 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'route:subscribers',
'Unit: Route: subscribers',
{
needs: ['service:notifications']
},
function() {
it('exists', function() {
let route = this.subject();
expect(route).to.be.ok;
});
}
);

View File

@ -0,0 +1,21 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'route:subscribers/import',
'SubscribersImportRoute',
{
// Specify the other units that are required for this test.
needs: ['service:notifications']
},
function() {
it('exists', function() {
let route = this.subject();
expect(route).to.be.ok;
});
}
);

View File

@ -0,0 +1,20 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'route:subscribers/new',
'Unit: Route: subscribers/new',
{
needs: ['service:notifications']
},
function() {
it('exists', function() {
let route = this.subject();
expect(route).to.be.ok;
});
}
);

View File

@ -0,0 +1,77 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describe,
it
} from 'mocha';
import Ember from 'ember';
import ValidationEngine from 'ghost/mixins/validation-engine';
const {run} = Ember;
const Subscriber = Ember.Object.extend(ValidationEngine, {
validationType: 'subscriber',
email: null
});
describe('Unit: Validator: subscriber', function () {
it('validates email by default', function () {
let subscriber = Subscriber.create({});
let properties = subscriber.get('validators.subscriber.properties');
console.log(subscriber);
expect(properties, 'properties').to.include('email');
});
it('passes with a valid email', function () {
let subscriber = Subscriber.create({email: 'test@example.com'});
let passed = false;
run(() => {
subscriber.validate({property: 'email'}).then(() => {
passed = true;
});
});
expect(passed, 'passed').to.be.true;
expect(subscriber.get('hasValidated'), 'hasValidated').to.include('email');
});
it('validates email presence', function () {
let subscriber = Subscriber.create({});
let passed = false;
run(() => {
subscriber.validate({property: 'email'}).then(() => {
passed = true;
});
});
let emailErrors = subscriber.get('errors').errorsFor('email').get(0);
expect(emailErrors.attribute, 'errors.email.attribute').to.equal('email');
expect(emailErrors.message, 'errors.email.message').to.equal('Please enter an email.');
expect(passed, 'passed').to.be.false;
expect(subscriber.get('hasValidated'), 'hasValidated').to.include('email');
});
it('validates email', function () {
let subscriber = Subscriber.create({email: 'foo'});
let passed = false;
run(() => {
subscriber.validate({property: 'email'}).then(() => {
passed = true;
});
});
let emailErrors = subscriber.get('errors').errorsFor('email').get(0);
expect(emailErrors.attribute, 'errors.email.attribute').to.equal('email');
expect(emailErrors.message, 'errors.email.message').to.equal('Invalid email.');
expect(passed, 'passed').to.be.false;
expect(subscriber.get('hasValidated'), 'hasValidated').to.include('email');
});
});

View File

@ -1,4 +1,3 @@
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
/* jshint expr:true */
import { expect } from 'chai';
import {

View File

@ -5,6 +5,7 @@
// from a theme, an app, or from an external app, you'll use the Ghost JSON API to do so.
var _ = require('lodash'),
Promise = require('bluebird'),
config = require('../config'),
// Include Endpoints
configuration = require('./configuration'),
@ -19,6 +20,7 @@ var _ = require('lodash'),
themes = require('./themes'),
users = require('./users'),
slugs = require('./slugs'),
subscribers = require('./subscribers'),
authentication = require('./authentication'),
uploads = require('./upload'),
exporter = require('../data/export'),
@ -28,7 +30,8 @@ var _ = require('lodash'),
addHeaders,
cacheInvalidationHeader,
locationHeader,
contentDispositionHeader,
contentDispositionHeaderExport,
contentDispositionHeaderSubscribers,
init;
/**
@ -138,12 +141,18 @@ locationHeader = function locationHeader(req, result) {
* @see http://tools.ietf.org/html/rfc598
* @return {string}
*/
contentDispositionHeader = function contentDispositionHeader() {
contentDispositionHeaderExport = function contentDispositionHeaderExport() {
return exporter.fileName().then(function then(filename) {
return 'Attachment; filename="' + filename + '"';
});
};
contentDispositionHeaderSubscribers = function contentDispositionHeaderSubscribers() {
var datetime = (new Date()).toJSON().substring(0, 10);
return Promise.resolve('Attachment; filename="subscribers.' + datetime + '.csv"');
};
addHeaders = function addHeaders(apiMethod, req, res, result) {
var cacheInvalidation,
location,
@ -164,15 +173,24 @@ addHeaders = function addHeaders(apiMethod, req, res, result) {
}
}
// Add Export Content-Disposition Header
if (apiMethod === db.exportContent) {
contentDisposition = contentDispositionHeader()
.then(function addContentDispositionHeader(header) {
// Add Content-Disposition Header
if (apiMethod === db.exportContent) {
contentDisposition = contentDispositionHeaderExport()
.then(function addContentDispositionHeaderExport(header) {
res.set({
'Content-Disposition': header
});
});
}
// Add Subscribers Content-Disposition Header
if (apiMethod === subscribers.exportCSV) {
contentDisposition = contentDispositionHeaderSubscribers()
.then(function addContentDispositionHeaderSubscribers(header) {
res.set({
'Content-Disposition': header,
'Content-Type': 'text/csv'
});
});
}
@ -195,7 +213,7 @@ http = function http(apiMethod) {
var object = req.body,
options = _.extend({}, req.file, req.query, req.params, {
context: {
user: (req.user && req.user.id) ? req.user.id : null
user: ((req.user && req.user.id) || (req.user && req.user.id === 0)) ? req.user.id : null
}
});
@ -213,7 +231,10 @@ http = function http(apiMethod) {
if (req.method === 'DELETE') {
return res.status(204).end();
}
// Keep CSV header and formatting
if (res.get('Content-Type') && res.get('Content-Type').indexOf('text/csv') === 0) {
return res.status(200).send(response);
}
// Send a properly formatting HTTP response containing the data with correct headers
res.json(response || {});
}).catch(function onAPIError(error) {
@ -243,6 +264,7 @@ module.exports = {
themes: themes,
users: users,
slugs: slugs,
subscribers: subscribers,
authentication: authentication,
uploads: uploads,
slack: slack

View File

@ -0,0 +1,335 @@
// # Tag API
// RESTful API for the Tag resource
var Promise = require('bluebird'),
_ = require('lodash'),
fs = require('fs'),
dataProvider = require('../models'),
errors = require('../errors'),
utils = require('./utils'),
serverUtils = require('../utils'),
pipeline = require('../utils/pipeline'),
i18n = require('../i18n'),
docName = 'subscribers',
subscribers;
/**
* ### Subscribers API Methods
*
* **See:** [API Methods](index.js.html#api%20methods)
*/
subscribers = {
/**
* ## Browse
* @param {{context}} options
* @returns {Promise<Subscriber>} Subscriber Collection
*/
browse: function browse(options) {
var tasks;
/**
* ### Model Query
* Make the call to the Model layer
* @param {Object} options
* @returns {Object} options
*/
function doQuery(options) {
return dataProvider.Subscriber.findPage(options);
}
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {opts: utils.browseDefaultOptions}),
utils.handlePermissions(docName, 'browse'),
doQuery
];
// Pipeline calls each task passing the result of one to be the arguments for the next
return pipeline(tasks, options);
},
/**
* ## Read
* @param {{id}} options
* @return {Promise<Subscriber>} Subscriber
*/
read: function read(options) {
var attrs = ['id'],
tasks;
/**
* ### Model Query
* Make the call to the Model layer
* @param {Object} options
* @returns {Object} options
*/
function doQuery(options) {
return dataProvider.Subscriber.findOne(options.data, _.omit(options, ['data']));
}
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {attrs: attrs}),
utils.handlePermissions(docName, 'read'),
doQuery
];
// Pipeline calls each task passing the result of one to be the arguments for the next
return pipeline(tasks, options).then(function formatResponse(result) {
if (result) {
return {subscribers: [result.toJSON(options)]};
}
return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.subscribers.subscriberNotFound')));
});
},
/**
* ## Add
* @param {Subscriber} object the subscriber to create
* @returns {Promise(Subscriber)} Newly created Subscriber
*/
add: function add(object, options) {
var tasks;
/**
* ### Model Query
* Make the call to the Model layer
* @param {Object} options
* @returns {Object} options
*/
function doQuery(options) {
return dataProvider.Subscriber.getByEmail(options.data.subscribers[0].email)
.then(function (subscriber) {
if (subscriber && options.context.external) {
// we don't expose this information
return Promise.resolve(subscriber);
} else if (subscriber) {
return Promise.reject(new errors.ValidationError(i18n.t('errors.api.subscribers.subscriberAlreadyExist')));
}
return dataProvider.Subscriber.add(options.data.subscribers[0], _.omit(options, ['data'])).catch(function (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
return Promise.reject(new errors.ValidationError(i18n.t('errors.api.subscribers.subscriberAlreadyExist')));
}
return Promise.reject(error);
});
});
}
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName),
utils.handlePermissions(docName, 'add'),
doQuery
];
// Pipeline calls each task passing the result of one to be the arguments for the next
return pipeline(tasks, object, options).then(function formatResponse(result) {
var subscriber = result.toJSON(options);
return {subscribers: [subscriber]};
});
},
/**
* ## Edit
*
* @public
* @param {Subscriber} object Subscriber or specific properties to update
* @param {{id, context, include}} options
* @return {Promise<Subscriber>} Edited Subscriber
*/
edit: function edit(object, options) {
var tasks;
/**
* Make the call to the Model layer
* @param {Object} options
* @returns {Object} options
*/
function doQuery(options) {
return dataProvider.Subscriber.edit(options.data.subscribers[0], _.omit(options, ['data']));
}
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {opts: utils.idDefaultOptions}),
utils.handlePermissions(docName, 'edit'),
doQuery
];
// Pipeline calls each task passing the result of one to be the arguments for the next
return pipeline(tasks, object, options).then(function formatResponse(result) {
if (result) {
var subscriber = result.toJSON(options);
return {subscribers: [subscriber]};
}
return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.subscribers.subscriberNotFound')));
});
},
/**
* ## Destroy
*
* @public
* @param {{id, context}} options
* @return {Promise}
*/
destroy: function destroy(options) {
var tasks;
/**
* ### Delete Subscriber
* Make the call to the Model layer
* @param {Object} options
*/
function doQuery(options) {
return dataProvider.Subscriber.destroy(options).return(null);
}
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {opts: utils.idDefaultOptions}),
utils.handlePermissions(docName, 'destroy'),
doQuery
];
// Pipeline calls each task passing the result of one to be the arguments for the next
return pipeline(tasks, options);
},
/**
* ### Export Subscribers
* Generate the CSV to export
*
* @public
* @param {{context}} options
* @returns {Promise} Ghost Export CSV format
*/
exportCSV: function exportCSV(options) {
var tasks = [];
options = options || {};
function formatCSV(data) {
var fields = ['id', 'email', 'created_at', 'deleted_at'],
csv = fields.join(',') + '\r\n',
subscriber,
field,
j,
i;
for (j = 0; j < data.length; j = j + 1) {
subscriber = data[j];
for (i = 0; i < fields.length; i = i + 1) {
field = fields[i];
csv += subscriber[field] !== null ? subscriber[field] : '';
if (i !== fields.length - 1) {
csv += ',';
}
}
csv += '\r\n';
}
return csv;
}
// Export data, otherwise send error 500
function exportSubscribers() {
return dataProvider.Subscriber.findPage(options).then(function (data) {
return formatCSV(data.subscribers);
}).catch(function (error) {
return Promise.reject(new errors.InternalServerError(error.message || error));
});
}
tasks = [
utils.handlePermissions(docName, 'browse'),
exportSubscribers
];
return pipeline(tasks, options);
},
/**
* ### Import CSV
* Import subscribers from a CSV file
*
* @public
* @param {{context}} options
* @returns {Promise} Success
*/
importCSV: function (options) {
var tasks = [];
options = options || {};
function validate(options) {
options.name = options.originalname;
options.type = options.mimetype;
// Check if a file was provided
if (!utils.checkFileExists(options)) {
return Promise.reject(new errors.ValidationError(i18n.t('errors.api.db.selectFileToImport')));
}
// TODO: check for valid entries
return options;
}
function importCSV(options) {
var filePath = options.path,
fulfilled = 0,
invalid = 0,
duplicates = 0;
return serverUtils.readCSV({
path: filePath,
columnsToExtract: ['email']
}).then(function (result) {
return Promise.all(result.map(function (entry) {
return subscribers.add(
{subscribers: [{email: entry.email}]},
{context: options.context}
).reflect();
})).each(function (inspection) {
if (inspection.isFulfilled()) {
fulfilled = fulfilled + 1;
} else {
if (inspection.reason() instanceof errors.ValidationError) {
duplicates = duplicates + 1;
} else {
invalid = invalid + 1;
}
}
});
}).then(function () {
return {
meta: {
stats: {
imported: fulfilled,
duplicates: duplicates,
invalid: invalid
}
}
};
}).finally(function () {
// Remove uploaded file from tmp location
return Promise.promisify(fs.unlink)(filePath);
});
}
tasks = [
validate,
utils.handlePermissions(docName, 'add'),
importCSV
];
return pipeline(tasks, options);
}
};
module.exports = subscribers;

View File

@ -0,0 +1,81 @@
var _ = require('lodash'),
path = require('path'),
hbs = require('express-hbs'),
router = require('./lib/router'),
// Dirty requires
config = require('../../config'),
errors = require('../../errors'),
i18n = require('../../i18n'),
labs = require('../../utils/labs'),
template = require('../../helpers/template'),
utils = require('../../helpers/utils'),
params = ['error', 'success', 'email'],
/**
* This helper script sets the referrer and current location if not existent.
*
* document.querySelector['.location']['value'] = document.querySelector('.location')['value'] || window.location.href;
*/
subscribeScript =
'<script type="text/javascript">' +
'(function(g,h,o,s,t){' +
'h[o](\'.location\')[s]=h[o](\'.location\')[s] || g.location.href;' +
'h[o](\'.referrer\')[s]=h[o](\'.referrer\')[s] || h.referrer;' +
'})(window,document,\'querySelector\',\'value\');' +
'</script>';
function makeHidden(name, extras) {
return utils.inputTemplate({
type: 'hidden',
name: name,
className: name,
extras: extras
});
}
function subscribeFormHelper(options) {
var root = options.data.root,
data = _.merge({}, options.hash, _.pick(root, params), {
action: path.join('/', config.paths.subdir, config.routeKeywords.subscribe, '/'),
script: new hbs.handlebars.SafeString(subscribeScript),
hidden: new hbs.handlebars.SafeString(
makeHidden('confirm') +
makeHidden('location', root.subscribed_url ? 'value=' + root.subscribed_url : '') +
makeHidden('referrer', root.subscribed_referrer ? 'value=' + root.subscribed_referrer : '')
)
});
return template.execute('subscribe_form', data, options);
}
module.exports = {
activate: function activate(ghost) {
var errorMessages = [
i18n.t('warnings.helpers.helperNotAvailable', {helperName: 'subscribe_form'}),
i18n.t('warnings.helpers.apiMustBeEnabled', {helperName: 'subscribe_form', flagName: 'subscribers'}),
i18n.t('warnings.helpers.seeLink', {url: 'http://support.ghost.org/subscribers-beta/'})
];
// Correct way to register a helper from an app
ghost.helpers.register('subscribe_form', function labsEnabledHelper() {
if (labs.isSet('subscribers') === true) {
return subscribeFormHelper.apply(this, arguments);
}
errors.logError.apply(this, errorMessages);
return new hbs.handlebars.SafeString('<script>console.error("' + errorMessages.join(' ') + '");</script>');
});
},
setupRoutes: function setupRoutes(blogRouter) {
blogRouter.use('/' + config.routeKeywords.subscribe + '/', function labsEnabledRouter(req, res, next) {
if (labs.isSet('subscribers') === true) {
return router.apply(this, arguments);
}
next();
});
}
};

View File

@ -0,0 +1,105 @@
var path = require('path'),
express = require('express'),
_ = require('lodash'),
subscribeRouter = express.Router(),
// Dirty requires
api = require('../../../api'),
errors = require('../../../errors'),
templates = require('../../../controllers/frontend/templates'),
postlookup = require('../../../controllers/frontend/post-lookup'),
setResponseContext = require('../../../controllers/frontend/context');
function controller(req, res) {
var defaultView = path.resolve(__dirname, 'views', 'subscribe.hbs'),
paths = templates.getActiveThemePaths(req.app.get('activeTheme')),
data = req.body;
setResponseContext(req, res);
if (paths.hasOwnProperty('subscribe.hbs')) {
return res.render('subscribe', data);
} else {
return res.render(defaultView, data);
}
}
function errorHandler(error, req, res, next) {
/*jshint unused:false */
if (error.statusCode !== 404) {
res.locals.error = error;
return controller(req, res);
}
next(error);
}
function honeyPot(req, res, next) {
if (!req.body.hasOwnProperty('confirm') || req.body.confirm !== '') {
return next(new Error('Oops, something went wrong!'));
}
// we don't need this anymore
delete req.body.confirm;
next();
}
function handleSource(req, res, next) {
req.body.subscribed_url = req.body.location;
req.body.subscribed_referrer = req.body.referrer;
delete req.body.location;
delete req.body.referrer;
postlookup(req.body.subscribed_url)
.then(function (result) {
if (result && result.post) {
req.body.post_id = result.post.id;
}
next();
})
.catch(function (err) {
if (err instanceof errors.NotFoundError) {
return next();
}
next(err);
});
}
function storeSubscriber(req, res, next) {
req.body.status = 'subscribed';
if (_.isEmpty(req.body.email)) {
return next(new errors.ValidationError('Email cannot be blank.'));
}
return api.subscribers.add({subscribers: [req.body]}, {context: {external: true}})
.then(function () {
res.locals.success = true;
next();
})
.catch(function () {
// we do not expose any information
res.locals.success = true;
next();
});
}
// subscribe frontend route
subscribeRouter.route('/')
.get(
controller
)
.post(
honeyPot,
handleSource,
storeSubscriber,
controller
);
// configure an error handler just for subscribe problems
subscribeRouter.use(errorHandler);
module.exports = subscribeRouter;
module.exports.controller = controller;

View File

@ -0,0 +1,63 @@
<!doctype html>
<!--[if (IE 8)&!(IEMobile)]><html class="no-js lt-ie9" lang="en"><![endif]-->
<!--[if (gte IE 9)| IEMobile |!(IE)]><!--><html class="no-js" lang="en"><!--<![endif]-->
<head>
<meta http-equiv="Content-Type" content="text/html" charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<title>Ghost - Subscribe</title>
<meta name="HandheldFriendly" content="True">
<meta name="MobileOptimized" content="320">
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="shortcut icon" href="{{asset "favicon.ico"}}">
<meta http-equiv="cleartype" content="on">
<link rel="stylesheet" type='text/css' href='//fonts.googleapis.com/css?family=Open+Sans:400,300,700'>
<link rel="stylesheet" href="{{asset "ghost.css" ghost="true" minifyInProduction="true"}}" />
</head>
<body>
<div class="gh-app">
<div class="gh-viewport">
<main class="gh-main" role="main">
<div class="gh-flow">
<header class="gh-flow-head">
<nav class="gh-flow-nav">
<a href="{{@blog.url}}" class="gh-flow-back"><i class="icon-arrow-left"></i> Back</a>
</nav>
</header>
<div class="gh-flow-content-wrap">
<section class="gh-flow-content">
{{^if success}}
<header>
<h1>Subscribe to {{@blog.title}}</h1>
</header>
{{subscribe_form
form_class="gh-signin"
input_class="gh-input"
button_class="btn btn-blue btn-block"
placeholder="Your email address"
autofocus="true"
}}
{{else}}
<header>
<h1>Subscribed!</h1>
</header>
<p>
You've successfully subscribed to <em>{{@blog.title}}</em>
with the email address <em>{{email}}</em>.
</p>
{{/if}}
</section>
</div>
</div>
</main>
</div>
</div>
</body>
</html>

View File

@ -194,7 +194,7 @@ ConfigManager.prototype.set = function (config) {
private: 'private',
subscribe: 'subscribe'
},
internalApps: ['private-blogging'],
internalApps: ['private-blogging', 'subscribers'],
slugs: {
// Used by generateSlug to generate slugs for posts, tags, users, ..
// reserved slugs are reserved but can be extended/removed by apps

View File

@ -0,0 +1,16 @@
var commands = require('../../schema').commands,
db = require('../../db'),
table = 'subscribers',
message = 'Creating table: ' + table;
module.exports = function addSubscribersTable(logger) {
return db.knex.schema.hasTable(table).then(function (exists) {
if (!exists) {
logger.info(message);
return commands.createTable(table);
} else {
logger.warn(message);
}
});
};

View File

@ -5,7 +5,8 @@ module.exports = [
require('./02-add-visibility-column-to-key-tables'),
// Add mobiledoc column to posts
require('./03-add-mobiledoc-column-to-posts'),
// Add social media columns to isers
require('./04-add-social-media-columns-to-users')
// Add social media columns to users
require('./04-add-social-media-columns-to-users'),
// Add subscribers table
require('./05-add-subscribers-table')
];

View File

@ -1,12 +1,13 @@
// Update the permissions & permissions_roles tables to get the new entries
var utils = require('../utils');
// Update the permissions & permissions_roles tables to add entries for clients
var utils = require('../utils'),
resource = 'client';
function getClientPermissions() {
return utils.findModelFixtures('Permission', {object_type: 'client'});
function getPermissions() {
return utils.findModelFixtures('Permission', {object_type: resource});
}
function getClientRelations() {
return utils.findPermissionRelationsForObject('client');
function getRelations() {
return utils.findPermissionRelationsForObject(resource);
}
function printResult(logger, result, message) {
@ -18,13 +19,13 @@ function printResult(logger, result, message) {
}
module.exports = function addClientPermissions(options, logger) {
var modelToAdd = getClientPermissions(),
relationToAdd = getClientRelations();
var modelToAdd = getPermissions(),
relationToAdd = getRelations();
return utils.addFixturesForModel(modelToAdd).then(function (result) {
printResult(logger, result, 'Adding permissions fixtures for clients');
printResult(logger, result, 'Adding permissions fixtures for ' + resource + 's');
return utils.addFixturesForRelation(relationToAdd);
}).then(function (result) {
printResult(logger, result, 'Adding permissions_roles fixtures for clients');
printResult(logger, result, 'Adding permissions_roles fixtures for ' + resource + 's');
});
};

View File

@ -0,0 +1,31 @@
// Update the permissions & permissions_roles tables to add entries for subscribers
var utils = require('../utils'),
resource = 'subscriber';
function getPermissions() {
return utils.findModelFixtures('Permission', {object_type: resource});
}
function getRelations() {
return utils.findPermissionRelationsForObject(resource);
}
function printResult(logger, result, message) {
if (result.done === result.expected) {
logger.info(message);
} else {
logger.warn('(' + result.done + '/' + result.expected + ') ' + message);
}
}
module.exports = function addSubscriberPermissions(options, logger) {
var modelToAdd = getPermissions(),
relationToAdd = getRelations();
return utils.addFixturesForModel(modelToAdd).then(function (result) {
printResult(logger, result, 'Adding permissions fixtures for ' + resource + 's');
return utils.addFixturesForRelation(relationToAdd);
}).then(function (result) {
printResult(logger, result, 'Adding permissions_roles fixtures for ' + resource + 's');
});
};

View File

@ -4,5 +4,7 @@ module.exports = [
// add ghost-scheduler client
require('./02-add-ghost-scheduler-client'),
// add client permissions and permission_role relations
require('./03-add-client-permissions')
require('./03-add-client-permissions'),
// add subscriber permissions and permission_role relations
require('./04-add-subscriber-permissions')
];

View File

@ -249,6 +249,31 @@
"name": "Delete clients",
"action_type": "destroy",
"object_type": "client"
},
{
"name": "Browse subscribers",
"action_type": "browse",
"object_type": "subscriber"
},
{
"name": "Read subscribers",
"action_type": "read",
"object_type": "subscriber"
},
{
"name": "Edit subscribers",
"action_type": "edit",
"object_type": "subscriber"
},
{
"name": "Add subscribers",
"action_type": "add",
"object_type": "subscriber"
},
{
"name": "Delete subscribers",
"action_type": "destroy",
"object_type": "subscriber"
}
]
}
@ -277,7 +302,8 @@
"theme": "all",
"user": "all",
"role": "all",
"client": "all"
"client": "all",
"subscriber": "all"
},
"Editor": {
"post": "all",
@ -286,7 +312,8 @@
"tag": "all",
"user": "all",
"role": "all",
"client": "all"
"client": "all",
"subscriber": ["add"]
},
"Author": {
"post": ["browse", "read", "add"],
@ -295,7 +322,8 @@
"tag": ["browse", "read", "add"],
"user": ["browse", "read"],
"role": ["browse"],
"client": "all"
"client": "all",
"subscriber": ["add"]
}
}
},

View File

@ -198,5 +198,21 @@ module.exports = {
user_id: {type: 'integer', nullable: false, unsigned: true, references: 'users.id'},
client_id: {type: 'integer', nullable: false, unsigned: true, references: 'clients.id'},
expires: {type: 'bigInteger', nullable: false}
},
subscribers: {
id: {type: 'increments', nullable: false, primary: true},
uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}},
name: {type: 'string', maxlength: 150, nullable: true},
email: {type: 'string', maxlength: 254, nullable: false, unique: true, validations: {isEmail: true}},
status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'pending', validations: {isIn: [['subscribed', 'pending', 'unsubscribed']]}},
post_id: {type: 'integer', nullable: true, unsigned: true, references: 'posts.id'},
subscribed_url: {type: 'text', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}},
subscribed_referrer: {type: 'text', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}},
unsubscribed_url: {type: 'text', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}},
unsubscribed_at: {type: 'dateTime', nullable: true},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'integer', nullable: false},
updated_at: {type: 'dateTime', nullable: true},
updated_by: {type: 'integer', nullable: true}
}
};

View File

@ -152,6 +152,7 @@ errors = {
context = i18n.t('errors.errors.databaseIsReadOnly');
help = i18n.t('errors.errors.checkDatabase');
}
// TODO: Logging framework hookup
// Eventually we'll have better logging which will know about envs
if ((process.env.NODE_ENV === 'development' ||

View File

@ -38,6 +38,7 @@ coreHelpers.url = require('./url');
// Specialist helpers for certain templates
coreHelpers.input_password = require('./input_password');
coreHelpers.input_email = require('./input_email');
coreHelpers.page_url = require('./page_url');
coreHelpers.pageUrl = require('./page_url').deprecated;
@ -97,6 +98,7 @@ registerHelpers = function (adminHbs) {
registerThemeHelper('has', coreHelpers.has);
registerThemeHelper('is', coreHelpers.is);
registerThemeHelper('image', coreHelpers.image);
registerThemeHelper('input_email', coreHelpers.input_email);
registerThemeHelper('input_password', coreHelpers.input_password);
registerThemeHelper('meta_description', coreHelpers.meta_description);
registerThemeHelper('meta_title', coreHelpers.meta_title);

View File

@ -0,0 +1,43 @@
// # Input Email Helper
// Usage: `{{input_email}}`
//
// Password input used on private.hbs for password-protected blogs
//
// We use the name meta_title to match the helper for consistency:
// jscs:disable requireCamelCaseOrUpperCaseIdentifiers
var hbs = require('express-hbs'),
utils = require('./utils'),
input_email;
input_email = function (options) {
options = options || {};
options.hash = options.hash || {};
var className = (options.hash.class) ? options.hash.class : 'subscribe-email',
extras = '',
output;
if (options.hash.autofocus) {
extras += 'autofocus="autofocus"';
}
if (options.hash.placeholder) {
extras += ' placeholder="' + options.hash.placeholder + '"';
}
if (options.hash.value) {
extras += ' value="' + options.hash.value + '"';
}
output = utils.inputTemplate({
type: 'email',
name: 'email',
className: className,
extras: extras
});
return new hbs.handlebars.SafeString(output);
};
module.exports = input_email;

View File

@ -0,0 +1,15 @@
<form method="post" action="{{action}}" class="{{form_class}}">
{{! This is required for the form to work correctly }}
{{hidden}}
<div class="form-group{{#if error}} error{{/if}}">
{{input_email class=input_class placeholder=placeholder value=email autofocus=autofocus}}
</div>
<button class="{{button_class}}" type="submit">Subscribe</button>
{{! This is used to get extra info about where this subscriber came from }}
{{script}}
</form>
{{#if error}}
<p class="main-error">{{{error.message}}}</p>
{{/if}}

View File

@ -99,6 +99,7 @@ auth = {
} else if (isBearerAutorizationHeader(req)) {
return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), req, res, next);
} else if (req.client) {
req.user = {id: 0};
return next();
}
@ -110,7 +111,7 @@ auth = {
// Workaround for missing permissions
// TODO: rework when https://github.com/TryGhost/Ghost/issues/3911 is done
requiresAuthorizedUser: function requiresAuthorizedUser(req, res, next) {
if (req.user) {
if (req.user && req.user.id) {
return next();
} else {
return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next);
@ -122,7 +123,7 @@ auth = {
if (labs.isSet('publicAPI') === true) {
return next();
} else {
if (req.user) {
if (req.user && req.user.id) {
return next();
} else {
return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next);

View File

@ -26,6 +26,7 @@ var bodyParser = require('body-parser'),
uncapitalise = require('./uncapitalise'),
cors = require('./cors'),
netjet = require('netjet'),
labs = require('./labs'),
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
BearerStrategy = require('passport-http-bearer').Strategy,
@ -44,7 +45,8 @@ middleware = {
requiresAuthorizedUser: auth.requiresAuthorizedUser,
requiresAuthorizedUserPublicAPI: auth.requiresAuthorizedUserPublicAPI,
errorHandler: errors.handleAPIError,
cors: cors
cors: cors,
labs: labs
}
};

View File

@ -0,0 +1,15 @@
var errors = require('../errors'),
labsUtil = require('../utils/labs'),
labs;
labs = {
subscribers: function subscribers(req, res, next) {
if (labsUtil.isSet('subscribers') === true) {
return next();
} else {
return errors.handleAPIError(new errors.NotFoundError(), req, res, next);
}
}
};
module.exports = labs;

View File

@ -27,6 +27,7 @@ themeHandler = {
// Setup handlebars for the current context (admin or theme)
configHbsForContext: function configHbsForContext(req, res, next) {
var themeData = _.cloneDeep(config.theme),
labsData = _.cloneDeep(config.labs),
blogApp = req.app;
if (req.secure && config.urlSSL) {
@ -38,7 +39,7 @@ themeHandler = {
themeData.posts_per_page = themeData.postsPerPage;
delete themeData.postsPerPage;
hbs.updateTemplateOptions({data: {blog: themeData}});
hbs.updateTemplateOptions({data: {blog: themeData, labs: labsData}});
if (config.paths.themePath && blogApp.get('activeTheme')) {
blogApp.set('views', path.join(config.paths.themePath, blogApp.get('activeTheme')));

View File

@ -134,11 +134,13 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
// Get the user from the options object
contextUser: function contextUser(options) {
// Default to context user
if (options.context && options.context.user) {
if ((options.context && options.context.user) || (options.context && options.context.user === 0)) {
return options.context.user;
// Other wise use the internal override
} else if (options.context && options.context.internal) {
return 1;
} else if (options.context && options.context.external) {
return 0;
} else {
errors.logAndThrowError(new Error(i18n.t('errors.models.base.index.missingContext')));
}

View File

@ -25,6 +25,7 @@ models = [
'refreshtoken',
'role',
'settings',
'subscriber',
'tag',
'user'
];

View File

@ -0,0 +1,102 @@
var ghostBookshelf = require('./base'),
errors = require('../errors'),
events = require('../events'),
i18n = require('../i18n'),
Promise = require('bluebird'),
uuid = require('node-uuid'),
Subscriber,
Subscribers;
Subscriber = ghostBookshelf.Model.extend({
tableName: 'subscribers',
emitChange: function emitChange(event) {
events.emit('subscriber' + '.' + event, this);
},
defaults: function defaults() {
return {
uuid: uuid.v4(),
status: 'subscribed'
};
},
initialize: function initialize() {
ghostBookshelf.Model.prototype.initialize.apply(this, arguments);
this.on('created', function onCreated(model) {
model.emitChange('added');
});
this.on('updated', function onUpdated(model) {
model.emitChange('edited');
});
this.on('destroyed', function onDestroyed(model) {
model.emitChange('deleted');
});
}
}, {
orderDefaultOptions: function orderDefaultOptions() {
return {};
},
/**
* @deprecated in favour of filter
*/
processOptions: function processOptions(options) {
return options;
},
permittedOptions: function permittedOptions(methodName) {
var options = ghostBookshelf.Model.permittedOptions(),
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
validOptions = {
findPage: ['page', 'limit', 'columns', 'filter', 'order'],
findAll: ['columns']
};
if (validOptions[methodName]) {
options = options.concat(validOptions[methodName]);
}
return options;
},
permissible: function permissible(postModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) {
// CASE: external is only allowed to add and edit subscribers
if (context.external) {
if (['add', 'edit'].indexOf(action) !== -1) {
return Promise.resolve();
}
}
if (hasUserPermission && hasAppPermission) {
return Promise.resolve();
}
return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.subscriber.notEnoughPermission')));
},
// TODO: This is a copy paste of models/user.js!
getByEmail: function getByEmail(email, options) {
options = options || {};
options.require = true;
return Subscribers.forge(options).fetch(options).then(function then(subscribers) {
var subscriberWithEmail = subscribers.find(function findSubscriber(subscriber) {
return subscriber.get('email').toLowerCase() === email.toLowerCase();
});
if (subscriberWithEmail) {
return subscriberWithEmail;
}
});
}
});
Subscribers = ghostBookshelf.Collection.extend({
model: Subscriber
});
module.exports = {
Subscriber: ghostBookshelf.model('Subscriber', Subscriber),
Subscribers: ghostBookshelf.collection('Subscriber', Subscribers)
};

View File

@ -26,11 +26,17 @@ function parseContext(context) {
// Parse what's passed to canThis.beginCheck for standard user and app scopes
var parsed = {
internal: false,
external: false,
user: null,
app: null,
public: true
};
if (context && (context === 'external' || context.external)) {
parsed.external = true;
parsed.public = false;
}
if (context && (context === 'internal' || context.internal)) {
parsed.internal = true;
parsed.public = false;
@ -117,8 +123,10 @@ CanThisResult.prototype.buildObjectTypeHandlers = function (objTypes, actType, c
role: Models.Role,
user: Models.User,
permission: Models.Permission,
setting: Models.Settings
setting: Models.Settings,
subscriber: Models.Subscriber
};
// Iterate through the object types, i.e. ['post', 'tag', 'user']
return _.reduce(objTypes, function (objTypeHandlers, objType) {
// Grab the TargetModel through the objectTypeModelMap

View File

@ -65,6 +65,20 @@ apiRoutes = function apiRoutes(middleware) {
router.put('/tags/:id', authenticatePrivate, api.http(api.tags.edit));
router.del('/tags/:id', authenticatePrivate, api.http(api.tags.destroy));
// ## Subscribers
router.get('/subscribers', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.browse));
router.get('/subscribers/csv', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.exportCSV));
router.post('/subscribers/csv',
middleware.api.labs.subscribers,
authenticatePrivate,
middleware.upload.single('subscribersfile'),
api.http(api.subscribers.importCSV)
);
router.get('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.read));
router.post('/subscribers', middleware.api.labs.subscribers, authenticatePublic, api.http(api.subscribers.add));
router.put('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.edit));
router.del('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.destroy));
// ## Roles
router.get('/roles/', authenticatePrivate, api.http(api.roles.browse));

View File

@ -197,6 +197,9 @@
}
},
"models": {
"subscriber": {
"notEnoughPermission": "You do not have permission to perform this action"
},
"post": {
"untitled": "(Untitled)",
"valueCannotBeBlank": "Value in {key} cannot be blank.",
@ -333,6 +336,10 @@
"tags": {
"tagNotFound": "Tag not found."
},
"subscribers": {
"subscriberNotFound": "Subscriber not found.",
"subscriberAlreadyExist": "Email already exist."
},
"themes": {
"noPermissionToBrowseThemes": "You do not have permission to browse themes.",
"noPermissionToEditThemes": "You do not have permission to edit themes.",
@ -447,6 +454,9 @@
"unableToSendEmail": "Ghost is currently unable to send email."
},
"helpers": {
"helperNotAvailable": "The \\{\\{{helperName}\\}\\} helper is not available.",
"apiMustBeEnabled": "The {flagName} labs flag must be enabled if you wish to use the \\{\\{{helperName}\\}\\} helper.",
"seeLink": "See {url}",
"foreach": {
"iteratorNeeded": "Need to pass an iterator to #foreach"
},

View File

@ -1,5 +1,6 @@
var unidecode = require('unidecode'),
_ = require('lodash'),
readCSV = require('./read-csv'),
utils,
getRandomInt;
@ -99,7 +100,9 @@ utils = {
/*jslint unparam:true*/
res.set({'Cache-Control': 'public, max-age=' + utils.ONE_YEAR_S});
res.redirect(301, path);
}
},
readCSV: readCSV
};
module.exports = utils;

View File

@ -0,0 +1,66 @@
var readline = require('readline'),
Promise = require('bluebird'),
lodash = require('lodash'),
errors = require('../errors'),
fs = require('fs');
function readCSV(options) {
var path = options.path,
columnsToExtract = options.columnsToExtract || [],
firstLine = true,
mapping = {},
toReturn = [],
rl;
return new Promise(function (resolve, reject) {
rl = readline.createInterface({
input: fs.createReadStream(path),
terminal: false
});
rl.on('line', function (line) {
var values = line.split(','),
entry = {};
// CASE: column headers
if (firstLine) {
if (values.length === 1) {
mapping[columnsToExtract[0]] = 0;
} else {
try {
lodash.each(columnsToExtract, function (columnToExtract) {
mapping[columnToExtract] = lodash.findIndex(values, function (value) {
if (value.match(columnToExtract)) {
return true;
}
});
// CASE: column does not exist
if (mapping[columnToExtract] === -1) {
throw new errors.ValidationError(
'Column header missing: "{{column}}".'.replace('{{column}}', columnToExtract)
);
}
});
} catch (err) {
reject(err);
}
}
firstLine = false;
} else {
lodash.each(mapping, function (index, columnName) {
entry[columnName] = values[index];
});
toReturn.push(entry);
}
});
rl.on('close', function () {
resolve(toReturn);
});
});
}
module.exports = readCSV;

View File

@ -0,0 +1,303 @@
/*globals describe, before, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
sinon = require('sinon'),
Promise = require('bluebird'),
fs = require('fs'),
_ = require('lodash'),
context = testUtils.context,
errors = require('../../../server/errors'),
serverUtils = require('../../../server/utils'),
apiUtils = require('../../../server/api/utils'),
SubscribersAPI = require('../../../server/api/subscribers');
describe('Subscribers API', function () {
// Keep the DB clean
before(testUtils.teardown);
afterEach(testUtils.teardown);
beforeEach(testUtils.setup('users:roles', 'perms:subscriber', 'perms:init', 'subscriber'));
should.exist(SubscribersAPI);
describe('Add', function () {
var newSubscriber;
beforeEach(function () {
newSubscriber = _.clone(testUtils.DataGenerator.forKnex.createSubscriber(testUtils.DataGenerator.Content.subscribers[1]));
Promise.resolve(newSubscriber);
});
it('can add a subscriber (admin)', function (done) {
SubscribersAPI.add({subscribers: [newSubscriber]}, testUtils.context.admin)
.then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.length.should.be.above(0);
done();
}).catch(done);
});
it('can add a subscriber (editor)', function (done) {
SubscribersAPI.add({subscribers: [newSubscriber]}, testUtils.context.editor)
.then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.length.should.be.above(0);
done();
}).catch(done);
});
it('can add a subscriber (author)', function (done) {
SubscribersAPI.add({subscribers: [newSubscriber]}, testUtils.context.author)
.then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.length.should.be.above(0);
done();
}).catch(done);
});
it('can add a subscriber (external)', function (done) {
SubscribersAPI.add({subscribers: [newSubscriber]}, testUtils.context.external)
.then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.length.should.be.above(0);
done();
}).catch(done);
});
it('duplicate subscriber', function (done) {
SubscribersAPI.add({subscribers: [newSubscriber]}, testUtils.context.external)
.then(function () {
SubscribersAPI.add({subscribers: [newSubscriber]}, testUtils.context.external)
.then(function () {
return done();
})
.catch(done);
})
.catch(done);
});
it('CANNOT add subscriber without context', function (done) {
SubscribersAPI.add({subscribers: [newSubscriber]})
.then(function () {
done(new Error('Add subscriber without context should have no access.'));
})
.catch(function (err) {
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
});
});
describe('Edit', function () {
var newSubscriberEmail = 'subscriber@updated.com',
firstSubscriber = 1;
it('can edit a subscriber (admin)', function (done) {
SubscribersAPI.edit({subscribers: [{email: newSubscriberEmail}]}, _.extend({}, context.admin, {id: firstSubscriber}))
.then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.length.should.be.above(0);
done();
}).catch(done);
});
it('can edit subscriber (external)', function (done) {
SubscribersAPI.edit({subscribers: [{email: newSubscriberEmail}]}, _.extend({}, context.external, {id: firstSubscriber}))
.then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.length.should.be.above(0);
done();
}).catch(done);
});
it('CANNOT edit a subscriber (editor)', function (done) {
SubscribersAPI.edit({subscribers: [{email: newSubscriberEmail}]}, _.extend({}, context.editor, {id: firstSubscriber}))
.then(function () {
done(new Error('Edit subscriber as author should have no access.'));
})
.catch(function (err) {
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
});
it('CANNOT edit subscriber (author)', function (done) {
SubscribersAPI.edit({subscribers: [{email: newSubscriberEmail}]}, _.extend({}, context.author, {id: firstSubscriber}))
.then(function () {
done(new Error('Edit subscriber as author should have no access.'));
})
.catch(function (err) {
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
});
it('CANNOT edit subscriber that doesn\'t exit', function (done) {
SubscribersAPI.edit({subscribers: [{email: newSubscriberEmail}]}, _.extend({}, context.internal, {id: 999}))
.then(function () {
done(new Error('Edit non-existent subscriber is possible.'));
}, function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
done();
}).catch(done);
});
});
describe('Destroy', function () {
var firstSubscriber = 1;
it('can destroy subscriber as admin', function (done) {
SubscribersAPI.destroy(_.extend({}, testUtils.context.admin, {id: firstSubscriber}))
.then(function (results) {
should.not.exist(results);
done();
}).catch(done);
});
it('CANNOT destroy subscriber', function (done) {
SubscribersAPI.destroy(_.extend({}, testUtils.context.editor, {id: firstSubscriber}))
.then(function () {
done(new Error('Destroy subscriber should not be possible as editor.'));
}, function (err) {
should.exist(err);
(err instanceof errors.NoPermissionError).should.eql(true);
done();
}).catch(done);
});
});
describe('Browse', function () {
it('can browse (internal)', function (done) {
SubscribersAPI.browse(testUtils.context.internal).then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.should.have.lengthOf(1);
testUtils.API.checkResponse(results.subscribers[0], 'subscriber');
results.subscribers[0].created_at.should.be.an.instanceof(Date);
results.meta.pagination.should.have.property('page', 1);
results.meta.pagination.should.have.property('limit', 15);
results.meta.pagination.should.have.property('pages', 1);
results.meta.pagination.should.have.property('total', 1);
results.meta.pagination.should.have.property('next', null);
results.meta.pagination.should.have.property('prev', null);
done();
}).catch(done);
});
it('CANNOT browse subscriber (external)', function (done) {
SubscribersAPI.browse(testUtils.context.external)
.then(function () {
done(new Error('Browse subscriber should be denied with external context.'));
})
.catch(function (err) {
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
});
});
describe('Read', function () {
function extractFirstSubscriber(subscribers) {
return _.filter(subscribers, {id: 1})[0];
}
it('with id', function (done) {
SubscribersAPI.browse({context: {user: 1}}).then(function (results) {
should.exist(results);
should.exist(results.subscribers);
results.subscribers.length.should.be.above(0);
var firstSubscriber = extractFirstSubscriber(results.subscribers);
return SubscribersAPI.read({context: {user: 1}, id: firstSubscriber.id});
}).then(function (found) {
should.exist(found);
testUtils.API.checkResponse(found.subscribers[0], 'subscriber');
done();
}).catch(done);
});
it('CANNOT fetch a subscriber which doesn\'t exist', function (done) {
SubscribersAPI.read({context: {user: 1}, id: 999}).then(function () {
done(new Error('Should not return a result'));
}).catch(function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
done();
});
});
});
describe('Read CSV', function () {
var scope = {};
beforeEach(function () {
sinon.stub(fs, 'unlink', function (path, cb) {
cb();
});
sinon.stub(apiUtils, 'checkFileExists').returns(true);
sinon.stub(serverUtils, 'readCSV', function () {
if (scope.csvError) {
return Promise.reject(new Error('csv'));
}
return Promise.resolve(scope.values);
});
});
afterEach(function () {
fs.unlink.restore();
apiUtils.checkFileExists.restore();
serverUtils.readCSV.restore();
});
it('check that fn works in general', function (done) {
scope.values = [{email: 'lol@hallo.de'}, {email: 'test'}, {email:'lol@hallo.de'}];
SubscribersAPI.importCSV(_.merge(testUtils.context.internal, {path: '/somewhere'}))
.then(function (result) {
result.meta.stats.imported.should.eql(1);
result.meta.stats.duplicates.should.eql(1);
result.meta.stats.invalid.should.eql(1);
done();
})
.catch(done);
});
it('check that fn works in general', function (done) {
scope.values = [{email: 'lol@hallo.de'}, {email: '1@kate.de'}];
SubscribersAPI.importCSV(_.merge(testUtils.context.internal, {path: '/somewhere'}))
.then(function (result) {
result.meta.stats.imported.should.eql(2);
result.meta.stats.duplicates.should.eql(0);
result.meta.stats.invalid.should.eql(0);
done();
})
.catch(done);
});
it('read csv throws an error', function (done) {
scope.csvError = true;
SubscribersAPI.importCSV(_.merge(testUtils.context.internal, {path: '/somewhere'}))
.then(function () {
done(new Error('we expected an error here!'));
})
.catch(function (err) {
err.message.should.eql('csv');
done();
});
});
});
});

View File

@ -135,6 +135,20 @@ describe('Database Migration (special functions)', function () {
permissions[33].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
permissions[34].name.should.eql('Delete clients');
permissions[34].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
console.log(permissions[38]);
// Subscribers
permissions[35].name.should.eql('Browse subscribers');
permissions[35].should.be.AssignedToRoles(['Administrator']);
permissions[36].name.should.eql('Read subscribers');
permissions[36].should.be.AssignedToRoles(['Administrator']);
permissions[37].name.should.eql('Edit subscribers');
permissions[37].should.be.AssignedToRoles(['Administrator']);
permissions[38].name.should.eql('Add subscribers');
permissions[38].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
permissions[39].name.should.eql('Delete subscribers');
permissions[39].should.be.AssignedToRoles(['Administrator']);
});
describe('Populate', function () {
@ -195,7 +209,7 @@ describe('Database Migration (special functions)', function () {
result.roles.at(3).get('name').should.eql('Owner');
// Permissions
result.permissions.length.should.eql(35);
result.permissions.length.should.eql(40);
result.permissions.toJSON().should.be.CompletePermissions();
done();

View File

@ -442,7 +442,7 @@ describe('API Utils', function () {
describe('handlePublicPermissions', function () {
it('should return empty options if passed empty options', function (done) {
apiUtils.handlePublicPermissions('tests', 'test')({}).then(function (options) {
options.should.eql({context: {app: null, internal: false, public: true, user: null}});
options.should.eql({context: {app: null, external: false, internal: false, public: true, user: null}});
done();
}).catch(done);
});
@ -451,7 +451,7 @@ describe('API Utils', function () {
var aPPStub = sandbox.stub(apiUtils, 'applyPublicPermissions').returns(Promise.resolve({}));
apiUtils.handlePublicPermissions('tests', 'test')({}).then(function (options) {
aPPStub.calledOnce.should.eql(true);
options.should.eql({context: {app: null, internal: false, public: true, user: null}});
options.should.eql({context: {app: null, external: false, internal: false, public: true, user: null}});
done();
}).catch(done);
});
@ -467,7 +467,7 @@ describe('API Utils', function () {
apiUtils.handlePublicPermissions('tests', 'test')({context: {user: 1}}).then(function (options) {
cTStub.calledOnce.should.eql(true);
cTMethodStub.test.test.calledOnce.should.eql(true);
options.should.eql({context: {app: null, internal: false, public: false, user: 1}});
options.should.eql({context: {app: null, external: false, internal: false, public: false, user: 1}});
done();
}).catch(done);
});

View File

@ -132,10 +132,12 @@ describe('Fixtures', function () {
});
describe('01-move-jquery-with-alert', function () {
var moveJquery = fixtures004[0];
it('tries to move jQuery to ghost_foot', function (done) {
getObjStub.get.returns('');
fixtures004[0]({}, loggerStub).then(function () {
moveJquery({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('ghost_foot').should.be.true();
settingsEditStub.calledOnce.should.be.true();
@ -152,7 +154,7 @@ describe('Fixtures', function () {
+ '<script type="text/javascript" src="https://code.jquery.com/jquery-1.11.3.min.js"></script>\n\n'
);
fixtures004[0]({}, loggerStub).then(function () {
moveJquery({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('ghost_foot').should.be.true();
settingsEditStub.calledOnce.should.be.false();
@ -166,7 +168,7 @@ describe('Fixtures', function () {
it('does not move jQuery to ghost_foot if the setting is missing', function (done) {
settingsOneStub.returns(Promise.resolve());
fixtures004[0]({}, loggerStub).then(function () {
moveJquery({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('ghost_foot').should.be.true();
settingsEditStub.called.should.be.false();
@ -182,7 +184,7 @@ describe('Fixtures', function () {
configUtils.set({privacy: {useGoogleFonts: false}});
getObjStub.get.returns('');
fixtures004[0]({}, loggerStub).then(function () {
moveJquery({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('ghost_foot').should.be.true();
settingsEditStub.calledOnce.should.be.true();
@ -196,8 +198,10 @@ describe('Fixtures', function () {
});
describe('02-update-private-setting-type', function () {
var updateSettingType = fixtures004[1];
it('tries to update setting type correctly', function (done) {
fixtures004[1]({}, loggerStub).then(function () {
updateSettingType({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('isPrivate').should.be.true();
getObjStub.get.calledOnce.should.be.true();
@ -215,7 +219,7 @@ describe('Fixtures', function () {
it('does not try to update setting type if it is already set', function (done) {
getObjStub.get.returns('private');
fixtures004[1]({}, loggerStub).then(function () {
updateSettingType({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('isPrivate').should.be.true();
getObjStub.get.calledOnce.should.be.true();
@ -233,8 +237,10 @@ describe('Fixtures', function () {
});
describe('03-update-password-setting-type', function () {
var updateSettingType = fixtures004[2];
it('tries to update setting type correctly', function (done) {
fixtures004[2]({}, loggerStub).then(function () {
updateSettingType({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('password').should.be.true();
settingsEditStub.calledOnce.should.be.true();
@ -250,7 +256,7 @@ describe('Fixtures', function () {
it('does not try to update setting type if it is already set', function (done) {
getObjStub.get.returns('private');
fixtures004[2]({}, loggerStub).then(function () {
updateSettingType({}, loggerStub).then(function () {
settingsOneStub.calledOnce.should.be.true();
settingsOneStub.calledWith('password').should.be.true();
getObjStub.get.calledOnce.should.be.true();
@ -268,8 +274,10 @@ describe('Fixtures', function () {
});
describe('04-update-ghost-admin-client', function () {
var updateClient = fixtures004[3];
it('tries to update client correctly', function (done) {
fixtures004[3]({}, loggerStub).then(function () {
updateClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-admin'}).should.be.true();
getObjStub.get.calledTwice.should.be.true();
@ -290,7 +298,7 @@ describe('Fixtures', function () {
getObjStub.get.withArgs('secret').returns('abc');
getObjStub.get.withArgs('status').returns('enabled');
fixtures004[3]({}, loggerStub).then(function () {
updateClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-admin'}).should.be.true();
getObjStub.get.calledTwice.should.be.true();
@ -309,7 +317,7 @@ describe('Fixtures', function () {
getObjStub.get.withArgs('secret').returns('abc');
getObjStub.get.withArgs('status').returns('development');
fixtures004[3]({}, loggerStub).then(function () {
updateClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-admin'}).should.be.true();
getObjStub.get.calledTwice.should.be.true();
@ -331,7 +339,7 @@ describe('Fixtures', function () {
getObjStub.get.withArgs('secret').returns('not_available');
getObjStub.get.withArgs('status').returns('enabled');
fixtures004[3]({}, loggerStub).then(function () {
updateClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-admin'}).should.be.true();
getObjStub.get.calledOnce.should.be.true();
@ -350,11 +358,13 @@ describe('Fixtures', function () {
});
describe('05-add-ghost-frontend-client', function () {
var addClient = fixtures004[4];
it('tries to add client correctly', function (done) {
var clientAddStub = sandbox.stub(models.Client, 'add').returns(Promise.resolve());
clientOneStub.returns(Promise.resolve());
fixtures004[4]({}, loggerStub).then(function () {
addClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-frontend'}).should.be.true();
clientAddStub.calledOnce.should.be.true();
@ -369,7 +379,7 @@ describe('Fixtures', function () {
it('does not try to add client if it already exists', function (done) {
var clientAddStub = sandbox.stub(models.Client, 'add').returns(Promise.resolve());
fixtures004[4]({}, loggerStub).then(function () {
addClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-frontend'}).should.be.true();
clientAddStub.called.should.be.false();
@ -382,7 +392,8 @@ describe('Fixtures', function () {
});
describe('06-clean-broken-tags', function () {
var tagObjStub, tagCollStub, tagAllStub;
var tagObjStub, tagCollStub, tagAllStub,
cleanBrokenTags = fixtures004[5];
beforeEach(function () {
tagObjStub = {
@ -396,7 +407,7 @@ describe('Fixtures', function () {
it('tries to clean broken tags correctly', function (done) {
tagObjStub.get.returns(',hello');
fixtures004[5]({}, loggerStub).then(function () {
cleanBrokenTags({}, loggerStub).then(function () {
tagAllStub.calledOnce.should.be.true();
tagCollStub.each.calledOnce.should.be.true();
tagObjStub.get.calledOnce.should.be.true();
@ -414,7 +425,7 @@ describe('Fixtures', function () {
it('tries can handle tags which end up empty', function (done) {
tagObjStub.get.returns(',');
fixtures004[5]({}, loggerStub).then(function () {
cleanBrokenTags({}, loggerStub).then(function () {
tagAllStub.calledOnce.should.be.true();
tagCollStub.each.calledOnce.should.be.true();
tagObjStub.get.calledOnce.should.be.true();
@ -432,7 +443,7 @@ describe('Fixtures', function () {
it('does not change tags if not necessary', function (done) {
tagObjStub.get.returns('hello');
fixtures004[5]({}, loggerStub).then(function () {
cleanBrokenTags({}, loggerStub).then(function () {
tagAllStub.calledOnce.should.be.true();
tagCollStub.each.calledOnce.should.be.true();
tagObjStub.get.calledOnce.should.be.true();
@ -449,7 +460,7 @@ describe('Fixtures', function () {
it('does nothing if there are no tags', function (done) {
tagAllStub.returns(Promise.resolve());
fixtures004[5]({}, loggerStub).then(function () {
cleanBrokenTags({}, loggerStub).then(function () {
tagAllStub.calledOnce.should.be.true();
tagCollStub.each.called.should.be.false();
tagObjStub.get.called.should.be.false();
@ -464,7 +475,8 @@ describe('Fixtures', function () {
});
describe('07-add-post-tag-order', function () {
var tagOp1Stub, tagOp2Stub, tagObjStub, postObjStub, postCollStub, postAllStub;
var tagOp1Stub, tagOp2Stub, tagObjStub, postObjStub, postCollStub, postAllStub,
addPostTagOrder = fixtures004[6];
beforeEach(function () {
tagOp1Stub = sandbox.stub().returns(Promise.resolve());
@ -489,7 +501,7 @@ describe('Fixtures', function () {
it('calls load on each post', function (done) {
// Fake mapThen behaviour
postCollStub.mapThen.callsArgWith(0, postObjStub).returns([]);
fixtures004[6]({}, loggerStub).then(function () {
addPostTagOrder({}, loggerStub).then(function () {
postAllStub.calledOnce.should.be.true();
postCollStub.mapThen.calledOnce.should.be.true();
postObjStub.load.calledOnce.should.be.true();
@ -508,7 +520,7 @@ describe('Fixtures', function () {
postCollStub.mapThen.returns([]);
postAllStub.returns(Promise.resolve());
fixtures004[6]({}, loggerStub).then(function () {
addPostTagOrder({}, loggerStub).then(function () {
loggerStub.info.calledOnce.should.be.true();
loggerStub.warn.calledOnce.should.be.true();
postAllStub.calledOnce.should.be.true();
@ -527,7 +539,7 @@ describe('Fixtures', function () {
// By returning from mapThen, we can skip doing tag.load in this test
postCollStub.mapThen.returns(postObjStub);
fixtures004[6]({}, loggerStub).then(function () {
addPostTagOrder({}, loggerStub).then(function () {
loggerStub.info.calledThrice.should.be.true();
loggerStub.warn.called.should.be.false();
postAllStub.calledOnce.should.be.true();
@ -551,7 +563,7 @@ describe('Fixtures', function () {
// By returning from mapThen, we can skip doing tag.load in this test
postCollStub.mapThen.returns(postObjStub);
fixtures004[6]({}, loggerStub).then(function () {
addPostTagOrder({}, loggerStub).then(function () {
loggerStub.info.calledThrice.should.be.true();
loggerStub.warn.called.should.be.false();
postAllStub.calledOnce.should.be.true();
@ -574,7 +586,7 @@ describe('Fixtures', function () {
// By returning from mapThen, we can skip doing tag.load in this test
postCollStub.mapThen.returns([postObjStub]);
fixtures004[6]({}, loggerStub).then(function () {
addPostTagOrder({}, loggerStub).then(function () {
loggerStub.info.calledOnce.should.be.true();
loggerStub.warn.calledOnce.should.be.true();
postAllStub.calledOnce.should.be.true();
@ -597,7 +609,7 @@ describe('Fixtures', function () {
// By returning from mapThen, we can skip doing tag.load in this test
postCollStub.mapThen.returns([postObjStub]);
fixtures004[6]({}, loggerStub).then(function () {
addPostTagOrder({}, loggerStub).then(function () {
loggerStub.info.calledThrice.should.be.true();
loggerStub.warn.called.should.be.false();
postAllStub.calledOnce.should.be.true();
@ -625,7 +637,7 @@ describe('Fixtures', function () {
// By returning from mapThen, we can skip doing tag.load in this test
postCollStub.mapThen.returns([postObjStub]);
fixtures004[6]({}, loggerStub).then(function () {
addPostTagOrder({}, loggerStub).then(function () {
loggerStub.info.calledThrice.should.be.true();
loggerStub.warn.called.should.be.false();
postAllStub.calledOnce.should.be.true();
@ -656,7 +668,8 @@ describe('Fixtures', function () {
});
describe('08-add-post-fixture', function () {
var postOneStub, postAddStub;
var postOneStub, postAddStub,
addPostFixture = fixtures004[7];
beforeEach(function () {
postOneStub = sandbox.stub(models.Post, 'findOne').returns(Promise.resolve());
@ -664,7 +677,7 @@ describe('Fixtures', function () {
});
it('tries to add a new post fixture correctly', function (done) {
fixtures004[7]({}, loggerStub).then(function () {
addPostFixture({}, loggerStub).then(function () {
postOneStub.calledOnce.should.be.true();
loggerStub.info.calledOnce.should.be.true();
loggerStub.warn.called.should.be.false();
@ -678,7 +691,7 @@ describe('Fixtures', function () {
it('does not try to add new post fixture if it already exists', function (done) {
postOneStub.returns(Promise.resolve({}));
fixtures004[7]({}, loggerStub).then(function () {
addPostFixture({}, loggerStub).then(function () {
postOneStub.calledOnce.should.be.true();
loggerStub.info.called.should.be.false();
loggerStub.warn.calledOnce.should.be.true();
@ -719,10 +732,11 @@ describe('Fixtures', function () {
sequenceStub.firstCall.args[0][0].should.be.a.Function().with.property('name', 'runVersionTasks');
sequenceStub.secondCall.calledWith(sinon.match.array, sinon.match.object, loggerStub).should.be.true();
sequenceStub.secondCall.args[0].should.be.an.Array().with.lengthOf(3);
sequenceStub.secondCall.args[0].should.be.an.Array().with.lengthOf(4);
sequenceStub.secondCall.args[0][0].should.be.a.Function().with.property('name', 'updateGhostClientsSecrets');
sequenceStub.secondCall.args[0][1].should.be.a.Function().with.property('name', 'addGhostFrontendClient');
sequenceStub.secondCall.args[0][2].should.be.a.Function().with.property('name', 'addClientPermissions');
sequenceStub.secondCall.args[0][3].should.be.a.Function().with.property('name', 'addSubscriberPermissions');
// Reset
sequenceReset();
@ -733,11 +747,12 @@ describe('Fixtures', function () {
describe('Tasks:', function () {
it('should have tasks for 005', function () {
should.exist(fixtures005);
fixtures005.should.be.an.Array().with.lengthOf(3);
fixtures005.should.be.an.Array().with.lengthOf(4);
});
describe('01-update-ghost-client-secrets', function () {
var queryStub, clientForgeStub, clientEditStub;
var queryStub, clientForgeStub, clientEditStub,
updateClient = fixtures005[0];
beforeEach(function () {
queryStub = {
@ -754,7 +769,7 @@ describe('Fixtures', function () {
queryStub.fetch.returns(new Promise.resolve({models: []}));
// Execute
fixtures005[0]({}, loggerStub).then(function () {
updateClient({}, loggerStub).then(function () {
clientForgeStub.calledOnce.should.be.true();
clientEditStub.called.should.be.false();
loggerStub.info.called.should.be.false();
@ -768,7 +783,7 @@ describe('Fixtures', function () {
queryStub.fetch.returns(new Promise.resolve({models: [{id: 1}]}));
// Execute
fixtures005[0]({}, loggerStub).then(function () {
updateClient({}, loggerStub).then(function () {
clientForgeStub.calledOnce.should.be.true();
clientEditStub.called.should.be.true();
loggerStub.info.calledOnce.should.be.true();
@ -779,7 +794,8 @@ describe('Fixtures', function () {
});
describe('02-add-ghost-scheduler-client', function () {
var clientOneStub;
var clientOneStub,
addClient = fixtures005[1];
beforeEach(function () {
clientOneStub = sandbox.stub(models.Client, 'findOne').returns(Promise.resolve({}));
@ -789,7 +805,7 @@ describe('Fixtures', function () {
var clientAddStub = sandbox.stub(models.Client, 'add').returns(Promise.resolve());
clientOneStub.returns(Promise.resolve());
fixtures005[1]({}, loggerStub).then(function () {
addClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-scheduler'}).should.be.true();
clientAddStub.calledOnce.should.be.true();
@ -804,7 +820,7 @@ describe('Fixtures', function () {
it('does not try to add client if it already exists', function (done) {
var clientAddStub = sandbox.stub(models.Client, 'add').returns(Promise.resolve());
fixtures005[1]({}, loggerStub).then(function () {
addClient({}, loggerStub).then(function () {
clientOneStub.calledOnce.should.be.true();
clientOneStub.calledWith({slug: 'ghost-scheduler'}).should.be.true();
clientAddStub.called.should.be.false();
@ -817,7 +833,8 @@ describe('Fixtures', function () {
});
describe('03-add-client-permissions', function () {
var modelResult, addModelStub, relationResult, addRelationStub;
var modelResult, addModelStub, relationResult, addRelationStub,
addClientPermissions = fixtures005[2];
beforeEach(function () {
modelResult = {expected: 1, done: 1};
@ -831,7 +848,7 @@ describe('Fixtures', function () {
it('should find the correct model & relation to add', function (done) {
// Execute
fixtures005[2]({}, loggerStub).then(function () {
addClientPermissions({}, loggerStub).then(function () {
addModelStub.calledOnce.should.be.true();
addModelStub.calledWith(
fixtureUtils.findModelFixtures('Permission', {object_type: 'client'})
@ -853,7 +870,7 @@ describe('Fixtures', function () {
// Setup
modelResult.expected = 3;
// Execute
fixtures005[2]({}, loggerStub).then(function () {
addClientPermissions({}, loggerStub).then(function () {
addModelStub.calledOnce.should.be.true();
addModelStub.calledWith(
fixtureUtils.findModelFixtures('Permission', {object_type: 'client'})
@ -871,6 +888,63 @@ describe('Fixtures', function () {
});
});
});
describe('04-add-subscriber-permissions', function () {
var modelResult, addModelStub, relationResult, addRelationStub,
addSubscriberPermissions = fixtures005[3];
beforeEach(function () {
modelResult = {expected: 1, done: 1};
addModelStub = sandbox.stub(fixtureUtils, 'addFixturesForModel')
.returns(Promise.resolve(modelResult));
relationResult = {expected: 1, done: 1};
addRelationStub = sandbox.stub(fixtureUtils, 'addFixturesForRelation')
.returns(Promise.resolve(relationResult));
});
it('should find the correct model & relation to add', function (done) {
// Execute
addSubscriberPermissions({}, loggerStub).then(function () {
addModelStub.calledOnce.should.be.true();
addModelStub.calledWith(
fixtureUtils.findModelFixtures('Permission', {object_type: 'subscriber'})
).should.be.true();
addRelationStub.calledOnce.should.be.true();
addRelationStub.calledWith(
fixtureUtils.findPermissionRelationsForObject('subscriber')
).should.be.true();
loggerStub.info.calledTwice.should.be.true();
loggerStub.warn.called.should.be.false();
done();
});
});
it('should warn the result shows less work was done than expected', function (done) {
// Setup
modelResult.expected = 3;
// Execute
addSubscriberPermissions({}, loggerStub).then(function () {
addModelStub.calledOnce.should.be.true();
addModelStub.calledWith(
fixtureUtils.findModelFixtures('Permission', {object_type: 'subscriber'})
).should.be.true();
addRelationStub.calledOnce.should.be.true();
addRelationStub.calledWith(
fixtureUtils.findPermissionRelationsForObject('subscriber')
).should.be.true();
loggerStub.info.calledOnce.should.be.true();
loggerStub.warn.calledOnce.should.be.true();
done();
});
});
});
});
});
});
@ -921,9 +995,9 @@ describe('Fixtures', function () {
clientOneStub.calledThrice.should.be.true();
clientAddStub.calledThrice.should.be.true();
permOneStub.callCount.should.eql(35);
permOneStub.callCount.should.eql(40);
permsAddStub.called.should.be.true();
permsAddStub.callCount.should.eql(35);
permsAddStub.callCount.should.eql(40);
permsAllStub.calledOnce.should.be.true();
rolesAllStub.calledOnce.should.be.true();
@ -932,8 +1006,8 @@ describe('Fixtures', function () {
// Relations
modelMethodStub.filter.called.should.be.true();
// 25 permissions, 1 tag
modelMethodStub.filter.callCount.should.eql(25 + 1);
// 26 permissions, 1 tag
modelMethodStub.filter.callCount.should.eql(28 + 1);
modelMethodStub.find.called.should.be.true();
// 3 roles, 1 post
modelMethodStub.find.callCount.should.eql(3 + 1);

View File

@ -152,21 +152,21 @@ describe('Utils', function () {
fixtureUtils.addFixturesForRelation(fixtures.relations[0]).then(function (result) {
should.exist(result);
result.should.be.an.Object();
result.should.have.property('expected', 25);
result.should.have.property('done', 25);
result.should.have.property('expected', 28);
result.should.have.property('done', 28);
// Permissions & Roles
permsAllStub.calledOnce.should.be.true();
rolesAllStub.calledOnce.should.be.true();
dataMethodStub.filter.callCount.should.eql(25);
dataMethodStub.filter.callCount.should.eql(28);
dataMethodStub.find.callCount.should.eql(3);
fromItem.related.callCount.should.eql(25);
fromItem.findWhere.callCount.should.eql(25);
toItem[0].get.callCount.should.eql(50);
fromItem.related.callCount.should.eql(28);
fromItem.findWhere.callCount.should.eql(28);
toItem[0].get.callCount.should.eql(56);
fromItem.permissions.callCount.should.eql(25);
fromItem.attach.callCount.should.eql(25);
fromItem.permissions.callCount.should.eql(28);
fromItem.attach.callCount.should.eql(28);
fromItem.attach.calledWith(toItem).should.be.true();
done();

View File

@ -32,8 +32,8 @@ var should = require('should'),
describe('DB version integrity', function () {
// Only these variables should need updating
var currentDbVersion = '005',
currentSchemaHash = 'be706cdbeb06103d90703ee733efc556',
currentFixturesHash = 'ba195b645386b019a69c4b79e6854138';
currentSchemaHash = 'f63f41ac97b5665a30c899409bbf9a83',
currentFixturesHash = '56f781fa3bba0fdbf98da5f232ec9b11';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,
// and the values above will need updating as confirmation
@ -405,12 +405,14 @@ describe('Migrations', function () {
});
describe('01-add-tour-column-to-users', function () {
var addTourColumn = updates004[0];
it('does not try to add a new column if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('users').returns(new Promise.resolve(false));
// Execute
updates004[0](loggerStub).then(function () {
addTourColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('users').should.be.true();
@ -431,7 +433,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('users', 'tour').returns(new Promise.resolve(true));
// Execute
updates004[0](loggerStub).then(function () {
addTourColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('users').should.be.true();
@ -453,7 +455,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('users', 'tour').returns(new Promise.resolve(false));
// Execute
updates004[0](loggerStub).then(function () {
addTourColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('users').should.be.true();
@ -472,12 +474,14 @@ describe('Migrations', function () {
});
describe('02-add-sortorder-column-to-poststags', function () {
var addSortOrderColumn = updates004[1];
it('does not try to add a new column if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('posts_tags').returns(new Promise.resolve(false));
// Execute
updates004[1](loggerStub).then(function () {
addSortOrderColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('posts_tags').should.be.true();
@ -498,7 +502,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('posts_tags', 'sort_order').returns(new Promise.resolve(true));
// Execute
updates004[1](loggerStub).then(function () {
addSortOrderColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('posts_tags').should.be.true();
@ -520,7 +524,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('posts_tags', 'sort_order').returns(new Promise.resolve(false));
// Execute
updates004[1](loggerStub).then(function () {
addSortOrderColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('posts_tags').should.be.true();
@ -539,12 +543,14 @@ describe('Migrations', function () {
});
describe('03-add-many-columns-to-clients', function () {
var addClientColumns = updates004[2];
it('does not try to add new columns if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('clients').returns(new Promise.resolve(false));
// Execute
updates004[2](loggerStub).then(function () {
addClientColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('clients').should.be.true();
@ -569,7 +575,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('clients', 'description').returns(new Promise.resolve(true));
// Execute
updates004[2](loggerStub).then(function () {
addClientColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('clients').should.be.true();
@ -599,7 +605,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('clients', 'description').returns(new Promise.resolve(false));
// Execute
updates004[2](loggerStub).then(function () {
addClientColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('clients').should.be.true();
@ -634,7 +640,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('clients', 'description').returns(new Promise.resolve(true));
// Execute
updates004[2](loggerStub).then(function () {
addClientColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('clients').should.be.true();
@ -658,12 +664,14 @@ describe('Migrations', function () {
});
describe('04-add-clienttrusteddomains-table', function () {
var addTrustedDomains = updates004[3];
it('does not try to add a new table if the table already exists', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('client_trusted_domains').returns(new Promise.resolve(true));
// Execute
updates004[3](loggerStub).then(function () {
addTrustedDomains(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('client_trusted_domains').should.be.true();
@ -681,7 +689,7 @@ describe('Migrations', function () {
knexMock.schema.hasTable.withArgs('client_trusted_domains').returns(new Promise.resolve(false));
// Execute
updates004[3](loggerStub).then(function () {
addTrustedDomains(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('client_trusted_domains').should.be.true();
@ -697,6 +705,8 @@ describe('Migrations', function () {
});
describe('05-drop-unique-on-clients-secret', function () {
var dropUnique = updates004[4];
it('does not try to drop unique if the table does not exist', function (done) {
// Setup
getIndexesStub.withArgs('clients').returns(new Promise.resolve(
@ -705,7 +715,7 @@ describe('Migrations', function () {
knexMock.schema.hasTable.withArgs('clients').returns(new Promise.resolve(false));
// Execute
updates004[4](loggerStub).then(function () {
dropUnique(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('clients').should.be.true();
@ -729,7 +739,7 @@ describe('Migrations', function () {
);
// Execute
updates004[4](loggerStub).then(function () {
dropUnique(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('clients').should.be.true();
@ -754,7 +764,7 @@ describe('Migrations', function () {
);
// Execute
updates004[4](loggerStub).then(function () {
dropUnique(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('clients').should.be.true();
@ -798,7 +808,7 @@ describe('Migrations', function () {
tasksSpy.calledOnce.should.be.true();
tasksSpy.calledWith('005', loggerStub).should.be.true();
tasksSpy.firstCall.returnValue.should.be.an.Array().with.lengthOf(4);
tasksSpy.firstCall.returnValue.should.be.an.Array().with.lengthOf(5);
sequenceStub.calledTwice.should.be.true();
@ -807,11 +817,12 @@ describe('Migrations', function () {
sequenceStub.firstCall.args[0][0].should.be.a.Function().with.property('name', 'runVersionTasks');
sequenceStub.secondCall.calledWith(sinon.match.array, loggerStub).should.be.true();
sequenceStub.secondCall.args[0].should.be.an.Array().with.lengthOf(4);
sequenceStub.secondCall.args[0].should.be.an.Array().with.lengthOf(5);
sequenceStub.secondCall.args[0][0].should.be.a.Function().with.property('name', 'dropHiddenColumnFromTags');
sequenceStub.secondCall.args[0][1].should.be.a.Function().with.property('name', 'addVisibilityColumnToKeyTables');
sequenceStub.secondCall.args[0][2].should.be.a.Function().with.property('name', 'addMobiledocColumnToPosts');
sequenceStub.secondCall.args[0][3].should.be.a.Function().with.property('name', 'addSocialMediaColumnsToUsers');
sequenceStub.secondCall.args[0][4].should.be.a.Function().with.property('name', 'addSubscribersTable');
// Reset sequence
sequenceReset();
@ -820,7 +831,7 @@ describe('Migrations', function () {
});
describe('Tasks:', function () {
var dropColumnStub, addColumnStub,
var dropColumnStub, addColumnStub, createTableStub,
knexStub, knexMock;
beforeEach(function () {
@ -834,6 +845,7 @@ describe('Migrations', function () {
dropColumnStub = sandbox.stub(schema.commands, 'dropColumn');
addColumnStub = sandbox.stub(schema.commands, 'addColumn');
createTableStub = sandbox.stub(schema.commands, 'createTable');
});
afterEach(function () {
@ -842,16 +854,18 @@ describe('Migrations', function () {
it('should have tasks for 005', function () {
should.exist(updates005);
updates005.should.be.an.Array().with.lengthOf(4);
updates005.should.be.an.Array().with.lengthOf(5);
});
describe('01-drop-hidden-column-from-tags', function () {
var dropHiddenColumn = updates005[0];
it('does not try to drop column if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('tags').returns(Promise.resolve(false));
// Execute
updates005[0](loggerStub).then(function () {
dropHiddenColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('tags').should.be.true();
@ -872,7 +886,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('tags', 'hidden').returns(Promise.resolve(false));
// Execute
updates005[0](loggerStub).then(function () {
dropHiddenColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('tags').should.be.true();
@ -894,7 +908,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('tags', 'hidden').returns(Promise.resolve(true));
// Execute
updates005[0](loggerStub).then(function () {
dropHiddenColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('tags').should.be.true();
@ -913,6 +927,8 @@ describe('Migrations', function () {
});
describe('02-add-visibility-column-to-key-tables', function () {
var addVisibilityColumn = updates005[1];
it('does not try to add new column if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('posts').returns(Promise.resolve(false));
@ -920,7 +936,7 @@ describe('Migrations', function () {
knexMock.schema.hasTable.withArgs('users').returns(Promise.resolve(false));
// Execute
updates005[1](loggerStub).then(function () {
addVisibilityColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledThrice.should.be.true();
knexMock.schema.hasTable.calledWith('posts').should.be.true();
knexMock.schema.hasTable.calledWith('tags').should.be.true();
@ -948,7 +964,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('users', 'visibility').returns(Promise.resolve(true));
// Execute
updates005[1](loggerStub).then(function () {
addVisibilityColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledThrice.should.be.true();
knexMock.schema.hasTable.calledWith('posts').should.be.true();
knexMock.schema.hasTable.calledWith('tags').should.be.true();
@ -979,7 +995,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('users', 'visibility').returns(Promise.resolve(false));
// Execute
updates005[1](loggerStub).then(function () {
addVisibilityColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledThrice.should.be.true();
knexMock.schema.hasTable.calledWith('posts').should.be.true();
knexMock.schema.hasTable.calledWith('tags').should.be.true();
@ -1012,7 +1028,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('tags', 'visibility').returns(Promise.resolve(true));
knexMock.schema.hasColumn.withArgs('users', 'visibility').returns(Promise.resolve(false));
// Execute
updates005[1](loggerStub).then(function () {
addVisibilityColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledThrice.should.be.true();
knexMock.schema.hasTable.calledWith('posts').should.be.true();
knexMock.schema.hasTable.calledWith('tags').should.be.true();
@ -1037,12 +1053,14 @@ describe('Migrations', function () {
});
describe('03-add-mobiledoc-column-to-posts', function () {
var addMobiledocColumn = updates005[2];
it('does not try to add a new column if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('posts').returns(Promise.resolve(false));
// Execute
updates005[2](loggerStub).then(function () {
addMobiledocColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('posts').should.be.true();
@ -1063,7 +1081,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('posts', 'mobiledoc').returns(Promise.resolve(true));
// Execute
updates005[2](loggerStub).then(function () {
addMobiledocColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('posts').should.be.true();
@ -1085,7 +1103,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('posts', 'mobiledoc').returns(Promise.resolve(false));
// Execute
updates005[2](loggerStub).then(function () {
addMobiledocColumn(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('posts').should.be.true();
@ -1104,12 +1122,14 @@ describe('Migrations', function () {
});
describe('04-add-social-media-columns-to-users', function () {
var addSocialMediaColumns = updates005[3];
it('does not try to add new columns if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('users').returns(Promise.resolve(false));
// Execute
updates005[3](loggerStub).then(function () {
addSocialMediaColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('users').should.be.true();
@ -1131,7 +1151,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('users', 'twitter').returns(Promise.resolve(true));
// Execute
updates005[3](loggerStub).then(function () {
addSocialMediaColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('users').should.be.true();
@ -1155,7 +1175,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('users', 'twitter').returns(Promise.resolve(false));
// Execute
updates005[3](loggerStub).then(function () {
addSocialMediaColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('users').should.be.true();
@ -1181,7 +1201,7 @@ describe('Migrations', function () {
knexMock.schema.hasColumn.withArgs('users', 'twitter').returns(Promise.resolve(true));
// Execute
updates005[3](loggerStub).then(function () {
addSocialMediaColumns(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('users').should.be.true();
@ -1200,6 +1220,47 @@ describe('Migrations', function () {
}).catch(done);
});
});
describe('05-add-subscribers-table', function () {
var addSubscribers = updates005[4];
it('does not try to add a new table if the table already exists', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('subscribers').returns(new Promise.resolve(true));
// Execute
addSubscribers(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('subscribers').should.be.true();
createTableStub.called.should.be.false();
loggerStub.info.called.should.be.false();
loggerStub.warn.calledOnce.should.be.true();
done();
}).catch(done);
});
it('tries to add a new table if the table does not exist', function (done) {
// Setup
knexMock.schema.hasTable.withArgs('subscribers').returns(new Promise.resolve(false));
// Execute
addSubscribers(loggerStub).then(function () {
knexMock.schema.hasTable.calledOnce.should.be.true();
knexMock.schema.hasTable.calledWith('subscribers').should.be.true();
createTableStub.calledOnce.should.be.true();
createTableStub.calledWith('subscribers').should.be.true();
loggerStub.info.calledOnce.should.be.true();
loggerStub.warn.called.should.be.false();
done();
}).catch(done);
});
});
});
});
});

View File

@ -50,12 +50,14 @@ describe('Permissions', function () {
it('should return public for no context', function () {
permissions.parseContext().should.eql({
internal: false,
external: false,
user: null,
app: null,
public: true
});
permissions.parseContext({}).should.eql({
internal: false,
external: false,
user: null,
app: null,
public: true
@ -65,12 +67,14 @@ describe('Permissions', function () {
it('should return public for random context', function () {
permissions.parseContext('public').should.eql({
internal: false,
external: false,
user: null,
app: null,
public: true
});
permissions.parseContext({client: 'thing'}).should.eql({
internal: false,
external: false,
user: null,
app: null,
public: true
@ -80,6 +84,7 @@ describe('Permissions', function () {
it('should return user if user populated', function () {
permissions.parseContext({user: 1}).should.eql({
internal: false,
external: false,
user: 1,
app: null,
public: false
@ -89,6 +94,7 @@ describe('Permissions', function () {
it('should return app if app populated', function () {
permissions.parseContext({app: 5}).should.eql({
internal: false,
external: false,
user: null,
app: 5,
public: false
@ -98,6 +104,7 @@ describe('Permissions', function () {
it('should return internal if internal provided', function () {
permissions.parseContext({internal: true}).should.eql({
internal: true,
external: false,
user: null,
app: null,
public: false
@ -105,6 +112,7 @@ describe('Permissions', function () {
permissions.parseContext('internal').should.eql({
internal: true,
external: false,
user: null,
app: null,
public: false

View File

@ -0,0 +1,173 @@
/*globals describe, beforeEach, afterEach, it*/
var utils = require('../../../server/utils'),
errors = require('../../../server/errors'),
sinon = require('sinon'),
should = require('should'),
fs = require('fs'),
lodash = require('lodash'),
readline = require('readline');
describe('read csv', function () {
var scope = {};
beforeEach(function () {
sinon.stub(fs, 'createReadStream');
sinon.stub(readline, 'createInterface', function () {
return {
on: function (eventName, cb) {
switch (eventName) {
case 'line':
lodash.each(scope.csv, function (line) {
cb(line);
});
break;
case 'close':
cb();
break;
}
}
};
});
});
afterEach(function () {
fs.createReadStream.restore();
readline.createInterface.restore();
});
it('read csv: one column', function (done) {
scope.csv = [
'email',
'hannah@ghost.org',
'kate@ghost.org'
];
utils.readCSV({
path: 'read-file-is-mocked',
columnsToExtract: ['email']
}).then(function (result) {
should.exist(result);
result.length.should.eql(2);
result[0].email.should.eql('hannah@ghost.org');
result[1].email.should.eql('kate@ghost.org');
done();
}).catch(done);
});
it('read csv: two columns', function (done) {
scope.csv = [
'id,email',
'1,hannah@ghost.org',
'1,kate@ghost.org'
];
utils.readCSV({
path: 'read-file-is-mocked',
columnsToExtract: ['email']
}).then(function (result) {
should.exist(result);
result.length.should.eql(2);
result[0].email.should.eql('hannah@ghost.org');
result[1].email.should.eql('kate@ghost.org');
should.not.exist(result[0].id);
done();
}).catch(done);
});
it('read csv: two columns', function (done) {
scope.csv = [
'id,email',
'1,hannah@ghost.org',
'2,kate@ghost.org'
];
utils.readCSV({
path: 'read-file-is-mocked',
columnsToExtract: ['email', 'id']
}).then(function (result) {
should.exist(result);
result.length.should.eql(2);
result[0].email.should.eql('hannah@ghost.org');
result[0].id.should.eql('1');
result[1].email.should.eql('kate@ghost.org');
result[1].id.should.eql('2');
done();
}).catch(done);
});
it('read csv: test email regex', function (done) {
scope.csv = [
'email_address',
'hannah@ghost.org',
'kate@ghost.org'
];
utils.readCSV({
path: 'read-file-is-mocked',
columnsToExtract: ['email']
}).then(function (result) {
should.exist(result);
result.length.should.eql(2);
result[0].email.should.eql('hannah@ghost.org');
result[1].email.should.eql('kate@ghost.org');
done();
}).catch(done);
});
it('read csv: support single column use case', function (done) {
scope.csv = [
'a_column',
'hannah@ghost.org',
'kate@ghost.org'
];
utils.readCSV({
path: 'read-file-is-mocked',
columnsToExtract: ['email']
}).then(function (result) {
should.exist(result);
result.length.should.eql(2);
result[0].email.should.eql('hannah@ghost.org');
result[1].email.should.eql('kate@ghost.org');
done();
}).catch(done);
});
it('read csv: support single column use case (we would loose the first entry)', function (done) {
scope.csv = [
'hannah@ghost.org',
'kate@ghost.org'
];
utils.readCSV({
path: 'read-file-is-mocked',
columnsToExtract: ['email']
}).then(function (result) {
should.exist(result);
result.length.should.eql(1);
result[0].email.should.eql('kate@ghost.org');
done();
}).catch(done);
});
it('read csv: broken', function (done) {
scope.csv = [
'id,test',
'1,2',
'1,2'
];
utils.readCSV({
path: 'read-file-is-mocked',
columnsToExtract: ['email', 'id']
}).then(function () {
return done(new Error('we expected an error from read csv!'));
}).catch(function (err) {
(err instanceof errors.ValidationError).should.eql(true);
done();
});
});
});

View File

@ -13,6 +13,7 @@ var _ = require('lodash'),
tags: ['tags', 'meta'],
users: ['users', 'meta'],
settings: ['settings', 'meta'],
subscribers: ['subscribers', 'meta'],
roles: ['roles'],
pagination: ['page', 'limit', 'pages', 'total', 'next', 'prev'],
slugs: ['slugs'],
@ -25,6 +26,7 @@ var _ = require('lodash'),
// Tag API swaps parent_id to parent
tag: _(schema.tags).keys().without('parent_id').concat('parent').value(),
setting: _.keys(schema.settings),
subscriber: _.keys(schema.subscribers),
accesstoken: _.keys(schema.accesstokens),
role: _.keys(schema.roles),
permission: _.keys(schema.permissions),

View File

@ -236,6 +236,15 @@ DataGenerator.Content = {
key: 'setting',
value: 'value'
}
],
subscribers: [
{
email: 'subscriber1@test.com'
},
{
email: 'subscriber2@test.com'
}
]
};
@ -440,6 +449,7 @@ DataGenerator.forKnex = (function () {
createAppField: createAppField,
createAppSetting: createAppSetting,
createToken: createToken,
createSubscriber: createBasic,
posts: posts,
tags: tags,

View File

@ -386,6 +386,7 @@ toDoList = {
role: function insertRole() { return fixtures.insertOne('roles', 'createRole'); },
roles: function insertRoles() { return fixtures.insertRoles(); },
tag: function insertTag() { return fixtures.insertOne('tags', 'createTag'); },
subscriber: function insertSubscriber() { return fixtures.insertOne('subscribers', 'createSubscriber'); },
posts: function insertPosts() { return fixtures.insertPosts(); },
'posts:mu': function insertMultiAuthorPosts() { return fixtures.insertMultiAuthorPosts(); },
@ -581,6 +582,7 @@ module.exports = {
// Helpers to make it easier to write tests which are easy to read
context: {
internal: {context: {internal: true}},
external: {context: {external: true}},
owner: {context: {user: 1}},
admin: {context: {user: 2}},
editor: {context: {user: 3}},