diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml index fcaffdf4bd5..97401fca189 100644 --- a/.github/workflows/migration-review.yml +++ b/.github/workflows/migration-review.yml @@ -11,7 +11,7 @@ jobs: if: github.repository_owner == 'TryGhost' name: Create checklist comment steps: - - uses: peter-evans/create-or-update-comment@6fcd282399b3c9ad0bd9bd8025b8fb2c18b085dd + - uses: peter-evans/create-or-update-comment@73054821735f5750dfe2ed26313889fde739e425 with: issue-number: ${{ github.event.pull_request.number }} body: | diff --git a/ghost/adapter-manager/package.json b/ghost/adapter-manager/package.json index 04d9616a7cc..6f6b83f4cdc 100644 --- a/ghost/adapter-manager/package.json +++ b/ghost/adapter-manager/package.json @@ -17,7 +17,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, diff --git a/ghost/admin/app/components/gh-explore-iframe.js b/ghost/admin/app/components/gh-explore-iframe.js index 3a50c68e3eb..446e2c63d84 100644 --- a/ghost/admin/app/components/gh-explore-iframe.js +++ b/ghost/admin/app/components/gh-explore-iframe.js @@ -45,6 +45,6 @@ export default class GhExploreIframe extends Component { } _handleSiteDataUpdate(data) { - this.explore.siteData = data.siteData; + this.explore.siteData = data?.siteData ?? {}; } } diff --git a/ghost/admin/app/components/gh-resource-select.hbs b/ghost/admin/app/components/gh-resource-select.hbs index 01417f6418e..ae6524352a4 100644 --- a/ghost/admin/app/components/gh-resource-select.hbs +++ b/ghost/admin/app/components/gh-resource-select.hbs @@ -1,14 +1,19 @@ - - {{resource.name}} - + {{resource.title}} + diff --git a/ghost/admin/app/components/gh-resource-select.js b/ghost/admin/app/components/gh-resource-select.js index cbeb446f449..9bc2f08f59c 100644 --- a/ghost/admin/app/components/gh-resource-select.js +++ b/ghost/admin/app/components/gh-resource-select.js @@ -1,5 +1,10 @@ import Component from '@glimmer/component'; -import {action} from '@ember/object'; +import {A} from '@ember/array'; +import {action, get} from '@ember/object'; +import { + defaultMatcher, + filterOptions +} from 'ember-power-select/utils/group-utils'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; @@ -13,6 +18,45 @@ export default class GhResourceSelect extends Component { return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; } + get searchField() { + return this.args.searchField === undefined ? 'name' : this.args.searchField; + } + + @action + searchAndSuggest(term, select) { + return this.searchAndSuggestTask.perform(term, select); + } + + @task + *searchAndSuggestTask(term) { + let newOptions = this.flatOptions.toArray(); + + if (term.length === 0) { + return newOptions; + } + + // todo: we can do actual filtering on posts here (allow searching when we have lots and lots of posts) + yield undefined; + + newOptions = this._filter(A(newOptions), term); + + return newOptions; + } + + get matcher() { + return this.args.matcher || defaultMatcher; + } + + _filter(options, searchText) { + let matcher; + if (this.searchField) { + matcher = (option, text) => this.matcher(get(option, this.searchField), text); + } else { + matcher = (option, text) => this.matcher(option, text); + } + return filterOptions(options || [], searchText, matcher); + } + constructor() { super(...arguments); this.fetchOptionsTask.perform(); @@ -38,9 +82,12 @@ export default class GhResourceSelect extends Component { return options; } - get selectedOptions() { - const resources = this.args.resources || []; - return this.flatOptions.filter(option => resources.find(resource => resource.id === option.id)); + get selectedOption() { + if (this.args.resource.title) { + return this.args.resource; + } + const resource = this.args.resource ?? {}; + return this.flatOptions.find(option => resource.id === option.id); } @action @@ -55,16 +102,20 @@ export default class GhResourceSelect extends Component { return 'Select a page/post'; } + get searchPlaceholderText() { + if (this.args.type === 'email') { + return 'Search emails'; + } + return 'Search posts/pages'; + } + @task *fetchOptionsTask() { const options = yield []; if (this.args.type === 'email') { const posts = yield this.store.query('post', {filter: '(status:published,status:sent)+newsletter_id:-null', limit: 'all'}); - options.push({ - groupName: 'Emails', - options: posts.map(mapResource) - }); + options.push(...posts.map(mapResource)); this._options = options; return; } @@ -74,8 +125,8 @@ export default class GhResourceSelect extends Component { function mapResource(resource) { return { - name: resource.title, - id: resource.id + id: resource.id, + title: resource.title }; } diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js index 4aa23059f99..05a6660a201 100644 --- a/ghost/admin/app/components/koenig-lexical-editor.js +++ b/ghost/admin/app/components/koenig-lexical-editor.js @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/ember'; import Component from '@glimmer/component'; import React, {Suspense} from 'react'; +import ghostPaths from 'ghost-admin/utils/ghost-paths'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; @@ -108,11 +109,44 @@ export default class KoenigLexicalEditor extends Component { } ReactComponent = () => { + const [uploadProgressPercentage] = React.useState(0); // not in use right now, but will need to decide how to handle the percentage state and pass to the Image Cards + + // const uploadProgress = (event) => { + // const percentComplete = (event.loaded / event.total) * 100; + // setUploadProgressPercentage(percentComplete); + // }; + + async function imageUploader(files) { + function uploadToUrl(formData, url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + // xhr.upload.onprogress = (event) => { + // uploadProgress(event); + // }; + xhr.onload = () => resolve(xhr.response); + xhr.onerror = () => reject(xhr.statusText); + xhr.send(formData); + }); + } + const formData = new FormData(); + formData.append('file', files[0]); + const url = `${ghostPaths().apiRoot}/images/upload/`; + const response = await uploadToUrl(formData, url); + const dataset = JSON.parse(response); + const imageUrl = dataset?.images?.[0].url; + return { + src: imageUrl + }; + } return (
Loading editor...

}> - +
diff --git a/ghost/admin/app/components/member-attribution/source-attribution-table.js b/ghost/admin/app/components/member-attribution/source-attribution-table.js index 71bf61d2db3..1045d828828 100644 --- a/ghost/admin/app/components/member-attribution/source-attribution-table.js +++ b/ghost/admin/app/components/member-attribution/source-attribution-table.js @@ -36,7 +36,7 @@ export default class SourceAttributionTable extends Component { return null; } - if (this.sortedSources.length === 5 && !this.unavailableSource.length) { + if (this.sortedSources.length === 5 && !this.unavailableSource?.length) { return null; } diff --git a/ghost/admin/app/components/member/activity-feed.hbs b/ghost/admin/app/components/member/activity-feed.hbs index 8c22e50d032..95d7d54747a 100644 --- a/ghost/admin/app/components/member/activity-feed.hbs +++ b/ghost/admin/app/components/member/activity-feed.hbs @@ -6,7 +6,7 @@
{{else}} - {{#let (members-event-fetcher filter=(members-event-filter member=@member.id) pageSize=5) as |eventsFetcher|}} + {{#let (members-event-fetcher filter=(members-event-filter member=@member.id excludedEvents=this.excludedEventTypes) pageSize=5) as |eventsFetcher|}}
diff --git a/ghost/admin/app/components/member/activity-feed.js b/ghost/admin/app/components/member/activity-feed.js index d9f5654a5a2..ec9e34e1483 100644 --- a/ghost/admin/app/components/member/activity-feed.js +++ b/ghost/admin/app/components/member/activity-feed.js @@ -3,6 +3,7 @@ import {action} from '@ember/object'; export default class ActivityFeed extends Component { linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions + excludedEventTypes = ['email_sent_event']; @action enterLinkURL(event) { @@ -29,4 +30,4 @@ export default class ActivityFeed extends Component { child.style.transform = 'translateX(0)'; parent.classList.remove('scroller'); } -} \ No newline at end of file +} diff --git a/ghost/admin/app/components/members-activity/event-type-filter.js b/ghost/admin/app/components/members-activity/event-type-filter.js index 3ef84165539..920e53dc8e3 100644 --- a/ghost/admin/app/components/members-activity/event-type-filter.js +++ b/ghost/admin/app/components/members-activity/event-type-filter.js @@ -22,6 +22,12 @@ export default class MembersActivityEventTypeFilter extends Component { if (this.settings.commentsEnabled !== 'off') { extended.push({event: 'comment_event', icon: 'event-comment', name: 'Comments'}); } + if (this.settings.emailTrackClicks) { + extended.push({event: 'click_event', icon: 'event-click', name: 'Clicked link'}); + } + if (this.feature.audienceFeedback) { + extended.push({event: 'feedback_event', icon: 'event-more-like-this', name: 'Feedback'}); + } if (this.args.hiddenEvents?.length) { return extended.filter(t => !this.args.hiddenEvents.includes(t.event)); diff --git a/ghost/admin/app/components/members/filter-value.hbs b/ghost/admin/app/components/members/filter-value.hbs index 8db00eff3be..fcf85471aa8 100644 --- a/ghost/admin/app/components/members/filter-value.hbs +++ b/ghost/admin/app/components/members/filter-value.hbs @@ -47,47 +47,15 @@
-{{else if (eq @filter.type 'subscribed')}} +{{else if (eq @filter.valueType 'options')}} - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'last_seen_at')}} - - -{{else if (eq @filter.type 'created_at')}} - - -{{else if (eq @filter.type 'status')}} - -
- -{{else if (eq @filter.type 'subscriptions.plan_interval')}} - - - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'subscriptions.status')}} - - - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'subscriptions.start_date')}} +{{else if (or (eq @filter.type 'last_seen_at') (eq @filter.type 'created_at'))}} -{{else if (eq @filter.type 'subscriptions.current_period_end')}} +{{else if (eq @filter.valueType 'date')}} { - return { - id: resource - }; - }); + const resource = this.args.filter?.resource || undefined; + const resourceId = this.args.filter?.value || undefined; + return resource ?? { + id: resourceId + }; } @action - setResourceFilterValue(filter, resources) { - this.args.setFilterValue(filter, resources.map(resource => resource.id)); + setResourceFilterValue(filter, resource) { + this.args.setResourceValue(filter, resource); } } diff --git a/ghost/admin/app/components/members/filter.hbs b/ghost/admin/app/components/members/filter.hbs index fc935acefdd..f4bd1ae9c02 100644 --- a/ghost/admin/app/components/members/filter.hbs +++ b/ghost/admin/app/components/members/filter.hbs @@ -50,6 +50,7 @@ @index={{index}} @filter={{filter}} @setFilterValue={{this.setFilterValue}} + @setResourceValue={{this.setResourceValue}} @onLabelEdit={{@onLabelEdit}} /> diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js index 3223d3b4ff5..22536e9ade4 100644 --- a/ghost/admin/app/components/members/filter.js +++ b/ghost/admin/app/components/members/filter.js @@ -7,41 +7,9 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; -const FILTER_PROPERTIES = [ - // Basic - {label: 'Name', name: 'name', group: 'Basic', valueType: 'text'}, - {label: 'Email', name: 'email', group: 'Basic', valueType: 'text'}, - // {label: 'Location', name: 'location', group: 'Basic'}, - {label: 'Label', name: 'label', group: 'Basic', valueType: 'array'}, - {label: 'Newsletter subscription', name: 'subscribed', group: 'Basic'}, - {label: 'Last seen', name: 'last_seen_at', group: 'Basic', valueType: 'date'}, - {label: 'Created', name: 'created_at', group: 'Basic', valueType: 'date'}, - {label: 'Signed up on post/page', name: 'signup', group: 'Basic', valueType: 'array', feature: 'memberAttribution'}, - - // Member subscription - {label: 'Membership tier', name: 'tier', group: 'Subscription', valueType: 'array'}, - {label: 'Member status', name: 'status', group: 'Subscription'}, - {label: 'Billing period', name: 'subscriptions.plan_interval', group: 'Subscription'}, - {label: 'Stripe subscription status', name: 'subscriptions.status', group: 'Subscription'}, - {label: 'Paid start date', name: 'subscriptions.start_date', valueType: 'date', group: 'Subscription'}, - {label: 'Next billing date', name: 'subscriptions.current_period_end', valueType: 'date', group: 'Subscription'}, - {label: 'Subscription started on post/page', name: 'conversion', group: 'Subscription', valueType: 'array', feature: 'memberAttribution'}, - - // Emails - {label: 'Emails sent (all time)', name: 'email_count', group: 'Email'}, - {label: 'Emails opened (all time)', name: 'email_opened_count', group: 'Email'}, - {label: 'Open rate (all time)', name: 'email_open_rate', group: 'Email'}, - {label: 'Received email', name: 'emails.post_id', group: 'Email', valueType: 'array'}, - {label: 'Opened email', name: 'opened_emails.post_id', group: 'Email', valueType: 'array'}, - {label: 'Clicked email', name: 'clicked_links.post_id', group: 'Email', valueType: 'array'} - - // {label: 'Emails sent (30 days)', name: 'x', group: 'Email'}, - // {label: 'Emails opened (30 days)', name: 'x', group: 'Email'}, - // {label: 'Open rate (30 days)', name: 'x', group: 'Email'}, - // {label: 'Emails sent (60 days)', name: 'x', group: 'Email'}, - // {label: 'Emails opened (60 days)', name: 'x', group: 'Email'}, - // {label: 'Open rate (60 days)', name: 'x', group: 'Email'}, -]; +function escapeNqlString(value) { + return '\'' + value.replace(/'/g, '\\\'') + '\''; +} const MATCH_RELATION_OPTIONS = [ {label: 'is', name: 'is'}, @@ -56,13 +24,14 @@ const CONTAINS_RELATION_OPTIONS = [ {label: 'ends with', name: 'ends-with'} ]; +const FEEDBACK_RELATION_OPTIONS = [ + {label: 'More like this', name: 1}, + {label: 'Less like this', name: 0} +]; + const DATE_RELATION_OPTIONS = [ {label: 'before', name: 'is-less'}, {label: 'on or before', name: 'is-or-less'}, - // TODO: these cause problems because they require multiple NQL statements, eg: - // created_at:>='2022-03-02 00:00'+created_at:<'2022-03-03 00:00' - // {label: 'on', name: 'is'}, - // {label: 'not on', name: 'is-not'}, {label: 'after', name: 'is-greater'}, {label: 'on or after', name: 'is-or-greater'} ]; @@ -73,76 +42,369 @@ const NUMBER_RELATION_OPTIONS = [ {label: 'is less than', name: 'is-less'} ]; -const FILTER_RELATIONS_OPTIONS = { - name: CONTAINS_RELATION_OPTIONS, - email: CONTAINS_RELATION_OPTIONS, - label: MATCH_RELATION_OPTIONS, - tier: MATCH_RELATION_OPTIONS, - subscribed: MATCH_RELATION_OPTIONS, - last_seen_at: DATE_RELATION_OPTIONS, - created_at: DATE_RELATION_OPTIONS, - status: MATCH_RELATION_OPTIONS, - 'subscriptions.plan_interval': MATCH_RELATION_OPTIONS, - 'subscriptions.status': MATCH_RELATION_OPTIONS, - 'subscriptions.start_date': DATE_RELATION_OPTIONS, - 'subscriptions.current_period_end': DATE_RELATION_OPTIONS, - email_count: NUMBER_RELATION_OPTIONS, - email_opened_count: NUMBER_RELATION_OPTIONS, - email_open_rate: NUMBER_RELATION_OPTIONS, - signup: MATCH_RELATION_OPTIONS, - conversion: MATCH_RELATION_OPTIONS, - 'emails.post_id': MATCH_RELATION_OPTIONS, - 'clicked_links.post_id': MATCH_RELATION_OPTIONS, - 'opened_emails.post_id': MATCH_RELATION_OPTIONS +// Ideally we should move all the filter definitions to separate files +const NAME_FILTER = { + label: 'Name', + name: 'name', + group: 'Basic', + valueType: 'string', + relationOptions: CONTAINS_RELATION_OPTIONS }; -const FILTER_VALUE_OPTIONS = { - 'subscriptions.plan_interval': [ - {label: 'Monthly', name: 'month'}, - {label: 'Yearly', name: 'year'} - ], - status: [ - {label: 'Paid', name: 'paid'}, - {label: 'Free', name: 'free'}, - {label: 'Complimentary', name: 'comped'} - ], - subscribed: [ - {label: 'Subscribed', name: 'true'}, - {label: 'Unsubscribed', name: 'false'} - ], - 'subscriptions.status': [ - {label: 'Active', name: 'active'}, - {label: 'Trialing', name: 'trialing'}, - {label: 'Canceled', name: 'canceled'}, - {label: 'Unpaid', name: 'unpaid'}, - {label: 'Past Due', name: 'past_due'}, - {label: 'Incomplete', name: 'incomplete'}, - {label: 'Incomplete - Expired', name: 'incomplete_expired'} - ] -}; +const FILTER_PROPERTIES = [ + // Basic + NAME_FILTER, + { + label: 'Email', + name: 'email', + group: 'Basic', + valueType: 'string', + relationOptions: CONTAINS_RELATION_OPTIONS + }, + { + label: 'Label', + name: 'label', + group: 'Basic', + valueType: 'array', + columnLabel: 'Label', + relationOptions: MATCH_RELATION_OPTIONS + }, + { + label: 'Newsletter subscription', + name: 'subscribed', + group: 'Basic', + columnLabel: 'Subscribed', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Subscribed', name: 'true'}, + {label: 'Unsubscribed', name: 'false'} + ] + }, + { + label: 'Last seen', + name: 'last_seen_at', + group: 'Basic', + valueType: 'date', + columnLabel: 'Last seen at', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Created', + name: 'created_at', + group: 'Basic', + valueType: 'date', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Signed up on post/page', + name: 'signup', + group: 'Basic', + valueType: 'string', + resource: 'post', + feature: 'memberAttribution', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Signed up on', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + + // Member subscription + { + label: 'Membership tier', + name: 'tier', + group: 'Subscription', + valueType: 'array', + columnLabel: 'Membership tier', + relationOptions: MATCH_RELATION_OPTIONS + }, + { + label: 'Member status', + name: 'status', + group: 'Subscription', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Paid', name: 'paid'}, + {label: 'Free', name: 'free'}, + {label: 'Complimentary', name: 'comped'} + ] + }, + { + label: 'Billing period', + name: 'subscriptions.plan_interval', + group: 'Subscription', + columnLabel: 'Billing period', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Monthly', name: 'month'}, + {label: 'Yearly', name: 'year'} + ] + }, + { + label: 'Stripe subscription status', + name: 'subscriptions.status', + group: 'Subscription', + columnLabel: 'Subscription Status', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Active', name: 'active'}, + {label: 'Trialing', name: 'trialing'}, + {label: 'Canceled', name: 'canceled'}, + {label: 'Unpaid', name: 'unpaid'}, + {label: 'Past Due', name: 'past_due'}, + {label: 'Incomplete', name: 'incomplete'}, + {label: 'Incomplete - Expired', name: 'incomplete_expired'} + ] + }, + { + label: 'Paid start date', + name: 'subscriptions.start_date', + valueType: 'date', + group: 'Subscription', + columnLabel: 'Paid start date', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Next billing date', + name: 'subscriptions.current_period_end', + valueType: 'date', + group: 'Subscription', + columnLabel: 'Next billing date', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Subscription started on post/page', + name: 'conversion', + group: 'Subscription', + valueType: 'string', + resource: 'post', + feature: 'memberAttribution', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Subscription started on', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + + // Emails + { + label: 'Emails sent (all time)', + name: 'email_count', + group: 'Email', + columnLabel: 'Email count', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS + }, + { + label: 'Emails opened (all time)', + name: 'email_opened_count', + group: 'Email', + columnLabel: 'Email opened count', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS + }, + { + label: 'Open rate (all time)', + name: 'email_open_rate', + group: 'Email', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS + }, + { + label: 'Received email', + name: 'emails.post_id', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Received email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + { + label: 'Opened email', + name: 'opened_emails.post_id', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Opened email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + { + label: 'Clicked email', + name: 'clicked_links.post_id', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Clicked email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + { + label: 'Responded with feedback', + name: 'newsletter_feedback', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: FEEDBACK_RELATION_OPTIONS, + feature: 'audienceFeedback', + buildNqlFilter: (filter) => { + // Added brackets to make sure we can parse as a single AND filter + return `(feedback.post_id:${filter.value}+feedback.score:${filter.relation})`; + }, + parseNqlFilter: (filter) => { + if (!filter.$and) { + return; + } + if (filter.$and.length === 2) { + if (filter.$and[0]['feedback.post_id'] && filter.$and[1]['feedback.score'] !== undefined) { + return { + relation: parseInt(filter.$and[1]['feedback.score']), + value: filter.$and[0]['feedback.post_id'] + }; + } + } + }, + getColumns: filter => [ + { + label: 'Email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + }, + { + label: 'Feedback', + getValue: () => { + return { + class: 'gh-members-list-feedback', + text: filter.relation === 1 ? 'More like this' : 'Less like this', + icon: filter.relation === 1 ? 'event-more-like-this' : 'event-less-like-this' + }; + } + } + ] + } +]; class Filter { - @tracked type; @tracked value; @tracked relation; - @tracked relationOptions; + @tracked properties; + @tracked resource; constructor(options) { - this.type = options.type; - this.relation = options.relation; - this.relationOptions = options.relationOptions; - this.timezone = options.timezone || 'Etc/UTC'; + this.properties = options.properties; + this.timezone = options.timezone ?? 'Etc/UTC'; - const filterProperty = FILTER_PROPERTIES.find(prop => this.type === prop.name); + let defaultRelation = options.properties.relationOptions[0].name; + if (options.properties.valueType === 'date') { + defaultRelation = 'is-or-less'; + } + + let defaultValue = ''; + if (options.properties.valueType === 'options' && options.properties.options.length > 0) { + defaultValue = options.properties.options[0].name; + } else if (options.properties.valueType === 'array') { + defaultValue = []; + } else if (options.properties.valueType === 'date') { + defaultValue = moment(moment.tz(this.timezone).format('YYYY-MM-DD')).toDate(); + } + + this.relation = options.relation ?? defaultRelation; // date string values are passed in as UTC strings // we need to convert them to the site timezone and make a local date that matches // so the date string output in the filter inputs is correct - const value = filterProperty.valueType === 'date' && typeof options.value === 'string' - ? moment(moment.tz(moment.utc(options.value), this.timezone).format('YYYY-MM-DD')).toDate() - : options.value; + this.value = options.value ?? defaultValue; + + if (this.properties.valueType === 'date' && typeof this.value === 'string') { + // Convert string to Date + this.value = moment(moment.tz(moment.utc(options.value), this.timezone).format('YYYY-MM-DD')).toDate(); + } + + // Validate value + if (options.properties.valueType === 'options') { + if (!options.properties.options.find(option => option.name === this.value)) { + this.value = defaultValue; + } + } + + this.resource = null; + } - this.value = value; + get valueType() { + return this.properties.valueType; + } + + get type() { + return this.properties.name; + } + + get isResourceFilter() { + return typeof this.properties.resource === 'string' && this.properties.valueType === 'string'; + } + + get relationOptions() { + return this.properties.relationOptions; + } + + get options() { + return this.properties.options ?? []; + } + + get isValid() { + if (Array.isArray(this.value)) { + return !!this.value.length; + } + return !!this.value; } } @@ -154,16 +416,10 @@ export default class MembersFilter extends Component { @tracked filters = new TrackedArray([ new Filter({ - type: 'name', - relation: 'is', - value: '', - relationOptions: FILTER_RELATIONS_OPTIONS.name + properties: NAME_FILTER }) ]); - availableFilterRelationsOptions = FILTER_RELATIONS_OPTIONS; - availableFilterValueOptions = FILTER_VALUE_OPTIONS; - get availableFilterProperties() { let availableFilters = FILTER_PROPERTIES; const hasMultipleTiers = this.store.peekAll('tier').length > 1; @@ -212,21 +468,24 @@ export default class MembersFilter extends Component { @action parseDefaultFilters() { if (this.args.defaultFilterParam) { - this.parseNqlFilter(this.args.defaultFilterParam); + // check if it is different before parsing + const validFilters = this.validFilters; + const currentFilter = this.generateNqlFilter(validFilters); - // Pass the parsed filter to the parent component - // this doesn't start a new network request, and doesn't update filterParam again - this.applyParsedFilter(); + if (currentFilter !== this.args.defaultFilterParam) { + this.parseNqlFilterString(this.args.defaultFilterParam); + + // Pass the parsed filter to the parent component + // this doesn't start a new network request, and doesn't update filterParam again + this.applyParsedFilter(); + } } } @action addFilter() { this.filters.push(new Filter({ - type: 'name', - relation: 'is', - value: '', - relationOptions: FILTER_RELATIONS_OPTIONS.name + properties: NAME_FILTER })); this.applySoftFilter(); } @@ -241,14 +500,19 @@ export default class MembersFilter extends Component { let query = ''; filters.forEach((filter) => { - const relationStr = this.getFilterRelationOperator(filter.relation); const filterProperty = FILTER_PROPERTIES.find(prop => prop.name === filter.type); + if (filterProperty.buildNqlFilter) { + query += `${filterProperty.buildNqlFilter(filter)}+`; + return; + } + const relationStr = this.getFilterRelationOperator(filter.relation); + if (filterProperty.valueType === 'array' && filter.value?.length) { const filterValue = '[' + filter.value.join(',') + ']'; query += `${filter.type}:${relationStr}${filterValue}+`; - } else if (filterProperty.valueType === 'text') { - const filterValue = '\'' + filter.value.replace(/'/g, '\\\'') + '\''; + } else if (filterProperty.valueType === 'string') { + let filterValue = escapeNqlString(filter.value); query += `${filter.type}:${relationStr}${filterValue}+`; } else if (filterProperty.valueType === 'date') { let filterValue; @@ -278,6 +542,74 @@ export default class MembersFilter extends Component { return query.slice(0, -1); } + parseNqlFilterString(filterParam) { + let filters; + + try { + filters = nql.parse(filterParam); + } catch (e) { + // Invalid nql filter + this.filters = new TrackedArray([]); + return; + } + this.filters = new TrackedArray(this.parseNqlFilter(filters)); + } + + parseNqlFilter(filter) { + const parsedFilters = []; + + // Check custom parsing + for (const filterProperties of FILTER_PROPERTIES) { + if (filterProperties.parseNqlFilter) { + // This filter has a custom parsing function + const parsedFilter = filterProperties.parseNqlFilter(filter); + if (parsedFilter) { + parsedFilters.push(new Filter({ + properties: filterProperties, + timezone: this.settings.timezone, + ...parsedFilter + })); + return parsedFilters; + } + } + } + + if (filter.$and) { + parsedFilters.push(...this.parseNqlFilters(filter.$and)); + } else if (filter.yg) { + // Single filter grouped in backets + parsedFilters.push(...this.parseNqlFilter(filter.yg)); + } else { + const filterKeys = Object.keys(filter); + const validKeys = FILTER_PROPERTIES.map(prop => prop.name); + + for (const key of filterKeys) { + if (validKeys.includes(key)) { + const parsedFilter = this.parseNqlFilterKey({ + [key]: filter[key] + }); + if (parsedFilter) { + parsedFilters.push(parsedFilter); + } + } + } + } + return parsedFilters; + } + + /** + * Parses an array of filters + */ + parseNqlFilters(filters) { + const parsedFilters = []; + + for (const filter of filters) { + parsedFilters.push(...this.parseNqlFilter(filter)); + } + + return parsedFilters; + } + parseNqlFilterKey(nqlFilter) { const keys = Object.keys(nqlFilter); const key = keys[0]; @@ -359,53 +691,16 @@ export default class MembersFilter extends Component { } if (relation && value) { - return new Filter({ - type: key, - relation, - relationOptions: FILTER_RELATIONS_OPTIONS[key], - value, - timezone: this.settings.timezone - }); - } - } - - parseNqlFilter(filterParam) { - const validKeys = Object.keys(FILTER_RELATIONS_OPTIONS); - let filters; - - try { - filters = nql.parse(filterParam); - } catch (e) { - // Invalid nql filter - this.filters = new TrackedArray([]); - return; - } - - const filterKeys = Object.keys(filters); - - let filterData = []; - - if (filterKeys?.length === 1 && validKeys.includes(filterKeys[0])) { - const filterObj = this.parseNqlFilterKey(filters); - if (filterObj) { - filterData = [filterObj]; + const properties = FILTER_PROPERTIES.find(prop => key === prop.name); + if (FILTER_PROPERTIES.find(prop => key === prop.name)) { + return new Filter({ + properties, + relation, + value, + timezone: this.settings.timezone + }); } - } else if (filters?.$and) { - const andFilters = filters?.$and || []; - filterData = andFilters.filter((nqlFilter) => { - const _filterKeys = Object.keys(nqlFilter); - if (_filterKeys?.length === 1 && validKeys.includes(_filterKeys[0])) { - return true; - } - return false; - }).map((nqlFilter) => { - return this.parseNqlFilterKey(nqlFilter); - }).filter((nqlFilter) => { - return !!nqlFilter; - }); } - - this.filters = new TrackedArray(filterData); } getFilterRelationOperator(relation) { @@ -456,40 +751,21 @@ export default class MembersFilter extends Component { const newProp = FILTER_PROPERTIES.find(prop => prop.name === newType); - let defaultValue = this.availableFilterValueOptions[newType] - ? this.availableFilterValueOptions[newType][0].name - : ''; - - if (newProp.valueType === 'array' && !defaultValue) { - defaultValue = []; - } - - if (newProp.valueType === 'date' && !defaultValue) { - defaultValue = moment(moment.tz(this.settings.timezone).format('YYYY-MM-DD')).toDate(); - } - - let defaultRelation = this.availableFilterRelationsOptions[newType][0].name; - - if (newProp.valueType === 'date') { - defaultRelation = 'is-or-less'; + if (!newProp) { + // eslint-disable-next-line no-console + console.warn('Invalid Filter Type Selected', newType); + return; } const newFilter = new Filter({ - type: newType, - relation: defaultRelation, - relationOptions: this.availableFilterRelationsOptions[newType], - value: defaultValue, + properties: newProp, timezone: this.settings.timezone }); const filterToSwap = this.filters.find(f => f === filter); this.filters[this.filters.indexOf(filterToSwap)] = newFilter; - if (newType !== 'label' && defaultValue) { - this.applySoftFilter(); - } - - if (newType !== 'tier' && defaultValue) { + if (newFilter.isValid) { this.applySoftFilter(); } } @@ -503,44 +779,48 @@ export default class MembersFilter extends Component { @action setFilterValue(filter, newValue) { filter.value = newValue; + filter.resource = null; this.applySoftFilter(); } + @action + setResourceValue(filter, resource) { + filter.value = resource.id; + filter.resource = resource; + this.applySoftFilter(); + } + + get validFilters() { + return this.filters.filter(filter => filter.isValid); + } + @action applySoftFilter() { - const validFilters = this.filters.filter((filter) => { - if (Array.isArray(filter.value)) { - return filter.value.length; - } - return filter.value; - }); + const validFilters = this.validFilters; const query = this.generateNqlFilter(validFilters); this.args.onApplySoftFilter(query, validFilters); + this.fetchFilterResourcesTask.perform(); } @action applyFilter() { - const validFilters = this.filters.filter((filter) => { - if (Array.isArray(filter.value)) { - return filter.value.length; - } - return filter.value; - }); - + const validFilters = this.validFilters; const query = this.generateNqlFilter(validFilters); this.args.onApplyFilter(query, validFilters); + this.fetchFilterResourcesTask.perform(); + } + + @action + applyFiltersPressed(dropdown) { + dropdown?.actions.close(); + this.applyFilter(); } @action applyParsedFilter() { - const validFilters = this.filters.filter((filter) => { - if (Array.isArray(filter.value)) { - return filter.value.length; - } - return filter.value; - }); - + const validFilters = this.validFilters; this.args.onApplyParsedFilter(validFilters); + this.fetchFilterResourcesTask.perform(); } @action @@ -548,10 +828,7 @@ export default class MembersFilter extends Component { const filters = []; filters.push(new Filter({ - type: 'name', - relation: 'is', - value: '', - relationOptions: FILTER_RELATIONS_OPTIONS.name + properties: NAME_FILTER })); this.filters = new TrackedArray(filters); @@ -563,4 +840,32 @@ export default class MembersFilter extends Component { const response = yield this.store.query('tier', {filter: 'type:paid'}); this.tiersList = response; } + + @task({restartable: true}) + *fetchFilterResourcesTask() { + const ids = []; + for (const filter of this.filters) { + if (filter.isResourceFilter) { + // for now we only support post filters + if (filter.value && !ids.includes(filter.value)) { + ids.push(filter.value); + } + } + } + if (ids.length > 0) { + const posts = yield this.store.query('post', {limit: 'all', filter: `id:[${ids.join(',')}]`}); + + for (const filter of this.filters) { + if (filter.isResourceFilter) { + // for now we only support post filters + if (filter.value) { + const post = posts.find(p => p.id === filter.value); + if (post) { + filter.resource = post; + } + } + } + } + } + } } diff --git a/ghost/admin/app/components/members/list-item-column.hbs b/ghost/admin/app/components/members/list-item-column.hbs index 8045c8d72d0..9fe5c609850 100644 --- a/ghost/admin/app/components/members/list-item-column.hbs +++ b/ghost/admin/app/components/members/list-item-column.hbs @@ -1,24 +1,15 @@ -{{#if (eq @filterColumn 'label')}} - +{{#if (eq this.columnName 'label')}} + {{this.labels}} -{{else if (eq @filterColumn 'tier')}} - +{{else if (eq this.columnName 'tier')}} + {{this.tiers}} -{{else if (eq @filterColumn 'status')}} - - {{#if (not (is-empty @member.status))}} - {{capitalize @member.status}} - {{else}} - - - {{/if}} - - -{{else if (eq @filterColumn 'last_seen_at')}} - +{{else if (eq this.columnName 'last_seen_at')}} + {{#if (not (is-empty @member.lastSeenAtUTC))}} {{moment-format (moment-site-tz @member.lastSeenAtUTC) "DD MMM YYYY"}}
{{moment-from-now @member.lastSeenAtUTC}}
@@ -27,8 +18,8 @@ {{/if}}
-{{else if (eq @filterColumn 'email_count')}} - +{{else if (eq this.columnName 'email_count')}} + {{#if (not (is-empty @member.emailCount))}} {{@member.emailCount}} {{else}} @@ -36,8 +27,8 @@ {{/if}} -{{else if (eq @filterColumn 'email_opened_count')}} - +{{else if (eq this.columnName 'email_opened_count')}} + {{#if (not (is-empty @member.emailOpenedCount))}} {{@member.emailOpenedCount}} {{else}} @@ -45,8 +36,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscribed')}} - +{{else if (eq this.columnName 'subscribed')}} + {{#if (not (is-empty @member.subscribed))}} {{if @member.subscribed "Yes" "No"}} {{else}} @@ -54,8 +45,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscriptions.status')}} - +{{else if (eq this.columnName 'subscriptions.status')}} + {{#if (not (is-empty this.mostRecentSubscription.status))}} {{capitalize this.mostRecentSubscription.status}} {{else}} @@ -63,8 +54,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscriptions.plan_interval')}} - +{{else if (eq this.columnName 'subscriptions.plan_interval')}} + {{#if (not (is-empty this.mostRecentSubscription.price.interval))}} {{capitalize this.mostRecentSubscription.price.interval}} {{else}} @@ -72,8 +63,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscriptions.start_date')}} - +{{else if (eq this.columnName 'subscriptions.start_date')}} + {{#if (not (is-empty this.mostRecentSubscription.start_date))}} {{moment-format (moment-site-tz this.mostRecentSubscription.start_date) "DD MMM YYYY"}}
{{moment-from-now this.mostRecentSubscription.start_date}}
@@ -82,8 +73,8 @@ {{/if}}
-{{else if (eq @filterColumn 'subscriptions.current_period_end')}} - +{{else if (eq this.columnName 'subscriptions.current_period_end')}} + {{#if (not (is-empty this.mostRecentSubscription.current_period_end))}} {{moment-format (moment-site-tz this.mostRecentSubscription.current_period_end) "DD MMM YYYY"}}
{{moment-from-now this.mostRecentSubscription.current_period_end}}
@@ -91,4 +82,17 @@ - {{/if}}
+{{else}} + + {{#if this.columnValue}} +
+ {{#if this.columnValue.icon}} + {{svg-jar this.columnValue.icon}} + {{/if}} + {{this.columnValue.text}} +
+ {{else}} + - + {{/if}} +
{{/if}} diff --git a/ghost/admin/app/components/members/list-item-column.js b/ghost/admin/app/components/members/list-item-column.js index 3f19c6410c8..3537ca55f29 100644 --- a/ghost/admin/app/components/members/list-item-column.js +++ b/ghost/admin/app/components/members/list-item-column.js @@ -20,4 +20,12 @@ export default class MembersListItemColumn extends Component { get mostRecentSubscription() { return mostRecentlyUpdated(get(this.args.member, 'subscriptions')); } + + get columnName() { + return this.args.filterColumn.name; + } + + get columnValue() { + return this.args.filterColumn?.getValue ? this.args.filterColumn?.getValue(this.args.member) : null; + } } diff --git a/ghost/admin/app/components/modals/newsletters/edit/settings.hbs b/ghost/admin/app/components/modals/newsletters/edit/settings.hbs index 4efcf9d12bf..72a0df9f6eb 100644 --- a/ghost/admin/app/components/modals/newsletters/edit/settings.hbs +++ b/ghost/admin/app/components/modals/newsletters/edit/settings.hbs @@ -114,22 +114,10 @@
- - - {{/liquid-if}} - {{/let}} - {{#if (feature "audienceFeedback")}} - {{#let (eq @openSection "audienceFeedback") as |isOpen|}} - - {{#liquid-if isOpen}} - + + {{/liquid-if}} + {{/let}} + diff --git a/ghost/admin/app/components/posts/analytics.hbs b/ghost/admin/app/components/posts/analytics.hbs index 4c74febbdaf..e07d441674d 100644 --- a/ghost/admin/app/components/posts/analytics.hbs +++ b/ghost/admin/app/components/posts/analytics.hbs @@ -1,5 +1,4 @@ -
- +
@@ -42,103 +41,127 @@
-

- Engagement -

-
+ {{#if this.post.hasBeenEmailed}} -
- -

{{format-number this.post.email.emailCount}}

-

Sent

-
-
+ +

Sent

+

{{format-number this.post.email.emailCount}}

+
+ + + + {{#if this.post.showEmailOpenAnalytics }} -
- -

{{format-number this.post.email.openedCount}}

-

Opened — {{this.post.email.openRate}}%

-
-
+ +

Opened

+

{{format-number this.post.email.openedCount}} {{this.post.email.openRate}}%

+
+ + + + {{/if}} {{#if this.post.showEmailClickAnalytics }} -
- -

{{format-number this.post.count.clicks}}

-

Clicked — {{this.post.clickRate}}%

-
-
+ +

Clicked

+

{{format-number this.post.count.clicks}} {{this.post.clickRate}}%

+
+ + + + {{/if}} {{/if}} - {{#if this.post.showAttributionAnalytics }} -
- -

{{format-number this.post.count.signups}}

-

{{gh-pluralize this.post.count.signups "signup" without-count=true}}

-
-
+ {{#if this.post.showAudienceFeedback }} + +

Positive feedback

+

+ {{format-number this.post.count.positive_feedback}} + {{!-- {{this.post.sentiment}}% --}} +

+
- {{#if this.post.showPaidAttributionAnalytics }} -
- -

{{format-number this.post.count.paid_conversions}}

-

Paid {{gh-pluralize this.post.count.paid_conversions "conversion" without-count=true}}

-
-
- {{/if}} + + + {{/if}} - {{#if this.post.showAudienceFeedback }} -
-

{{format-number this.post.count.positive_feedback}}

-

More like this — {{this.post.count.sentiment}}%

-
+ {{#if this.post.showAttributionAnalytics }} + +

{{gh-pluralize this.post.count.conversions "Conversions" without-count=true}}

+

{{format-number this.post.count.conversions}}

+
+ + + + {{/if}} -
+ {{#if this.isLoaded }} - {{#if this.showLinks }} - {{#if (is-empty this.links) }} - {{!-- Empty state --}} - {{else}} - +
+ {{#if this.showSources }} +
+

Post growth

+ {{#if (is-empty this.sources) }} +
+ {{svg-jar "members-outline"}} +

No new members for this post

+

Once someone signs up, you'll be able to see where they came from here.

+
+ {{else}} + + {{/if}} +
{{/if}} - {{/if}} - {{#if this.showSources }} - {{#if (is-empty this.sources) }} + {{#if this.showLinks }} + {{#if (is-empty this.links) }} {{!-- Empty state --}} - {{else}} -

- Growth from this post -

-
-
-
- -
-
-
+ {{else}} + + {{/if}} {{/if}} - {{/if}} +
-

- Get started with analytics -

-

Understanding analytics in Ghost

-

Find out how to review the performance of your content and get the most out of post analytics in Ghost.

+

+ Ghost help +

+
+

Understanding analytics in Ghost

+

Find out how to review the performance of your content and get the most out of post analytics in Ghost.

+
@@ -147,8 +170,13 @@
-

How to get your content seen online

-

Use these content distribution tactics to get more people to discover your work and increase engagement.

+

+ Ghost resources +

+
+

How to get your content seen online

+

Use these content distribution tactics to get more people to discover your work and increase engagement.

+
diff --git a/ghost/admin/app/components/posts/analytics.js b/ghost/admin/app/components/posts/analytics.js index e751179533b..e784d9b15b0 100644 --- a/ghost/admin/app/components/posts/analytics.js +++ b/ghost/admin/app/components/posts/analytics.js @@ -94,6 +94,23 @@ export default class Analytics extends Component { this.sortColumn = column; } + @action + updateLink(linkId, linkTo) { + this.links = this.links?.map((link) => { + if (link.link.link_id === linkId) { + return { + ...link, + link: { + ...link.link, + to: this.utils.cleanTrackedUrl(linkTo, false), + title: this.utils.cleanTrackedUrl(linkTo, true) + } + }; + } + return link; + }); + } + @action loadData() { if (this.showSources) { diff --git a/ghost/admin/app/components/posts/feedback-events-chart.hbs b/ghost/admin/app/components/posts/feedback-events-chart.hbs new file mode 100644 index 00000000000..ac0c1ab645a --- /dev/null +++ b/ghost/admin/app/components/posts/feedback-events-chart.hbs @@ -0,0 +1,7 @@ + diff --git a/ghost/admin/app/components/posts/feedback-events-chart.js b/ghost/admin/app/components/posts/feedback-events-chart.js new file mode 100644 index 00000000000..25edcf924eb --- /dev/null +++ b/ghost/admin/app/components/posts/feedback-events-chart.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import {inject as service} from '@ember/service'; + +const CHART_COLORS = [ + '#F080B2', + '#8452f633' +]; + +const linksClass = ['gh-post-activity-chart-positive-feedback', 'gh-post-activity-chart-negative-feedback']; + +export default class FeedbackEventsChart extends Component { + @service feature; + + getSumOfData() { + return this.args.data.reduce((acc, value) => { + return acc + value; + }, 0); + } + + get chartOptions() { + return { + cutoutPercentage: 70, + title: { + display: false + }, + legend: { + display: false + }, + tooltips: { + enabled: false, + mode: 'label', + custom: function (tooltip) { + // get tooltip element + const tooltipEl = document.getElementById('gh-feedback-events-tooltip'); + + let offsetX = -50; + let offsetY = -100; + + // update tooltip styles + tooltipEl.style.opacity = 1; + tooltipEl.style.position = 'absolute'; + tooltipEl.style.left = tooltip.x + offsetX + 'px'; + tooltipEl.style.top = tooltip.y + offsetY + 'px'; + tooltipEl.style.pointerEvents = 'all'; + }, + callbacks: { + label: (tooltipItems, data) => { + const tooltipTextEl = document.getElementById('gh-feedback-events-tooltip-body'); + const label = data.labels[tooltipItems.index] || ''; + const value = data.datasets[tooltipItems.datasetIndex].data[tooltipItems.index] || 0; + const formattedValue = value.toLocaleString('en-US'); + const percent = Math.round(value / this.getSumOfData() * 100); + const links = document.querySelectorAll(`.gh-feedback-events-tooltip-template .gh-post-activity-chart-link`); + links.forEach((link) => { + link.setAttribute('hidden', 'true'); + }); + const linkNode = document.querySelector(`.${linksClass[tooltipItems.index]}`); + linkNode.setAttribute('hidden', 'false'); + + tooltipTextEl.innerHTML = (` +
+ + ${formattedValue} + ${percent}% +
+ + ${label} + `); + }, + title: () => { + return null; + } + } + }, + aspectRatio: 1 + }; + } + + get chartData() { + let borderColor = this.feature.nightShift ? '#101114' : '#fff'; + + return { + labels: ['More like this', 'Less like this'], + datasets: [{ + label: 'Feedback events', + data: this.args.data, + backgroundColor: CHART_COLORS, + borderWidth: 2, + borderColor: borderColor, + hoverBorderWidth: 2, + hoverBorderColor: borderColor + }] + }; + } +} diff --git a/ghost/admin/app/components/posts/feedback-events-tooltip-template.hbs b/ghost/admin/app/components/posts/feedback-events-tooltip-template.hbs new file mode 100644 index 00000000000..a23d3bda580 --- /dev/null +++ b/ghost/admin/app/components/posts/feedback-events-tooltip-template.hbs @@ -0,0 +1,19 @@ +
+ + {{svg-jar "filter"}} + See members + + + + {{svg-jar "filter"}} + See members + +
\ No newline at end of file diff --git a/ghost/admin/app/components/posts/links-table.hbs b/ghost/admin/app/components/posts/links-table.hbs index 8095e6966c3..1c74a5f4ec9 100644 --- a/ghost/admin/app/components/posts/links-table.hbs +++ b/ghost/admin/app/components/posts/links-table.hbs @@ -1,37 +1,157 @@ -

- Newsletter clicks -

-
+{{#if (not (feature "audienceFeedback"))}} +

+ Newsletter clicks +

+{{/if}} +
+ {{#if (feature "audienceFeedback")}}

Newsletter clicks

{{/if}}
{{/if}} diff --git a/ghost/admin/ember-cli-build.js b/ghost/admin/ember-cli-build.js index 36e2d291d47..58d904a7ac2 100644 --- a/ghost/admin/ember-cli-build.js +++ b/ghost/admin/ember-cli-build.js @@ -118,7 +118,7 @@ module.exports = function (defaults) { includePolyfill: false }, 'ember-composable-helpers': { - only: ['join', 'optional', 'pick', 'toggle', 'toggle-action'] + only: ['join', 'optional', 'pick', 'toggle', 'toggle-action', 'compute'] }, 'ember-promise-modals': { excludeCSS: true diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 96bb0198cad..65ee83c23a5 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.18.0", + "version": "5.20.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", @@ -31,7 +31,7 @@ "devDependencies": { "@babel/eslint-parser": "7.19.1", "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-decorators": "7.19.3", + "@babel/plugin-proposal-decorators": "7.19.6", "@ember/jquery": "2.0.0", "@ember/optional-features": "2.0.0", "@ember/render-modifiers": "2.0.4", @@ -44,8 +44,8 @@ "@tryghost/color-utils": "0.1.21", "@tryghost/ember-promise-modals": "2.0.1", "@tryghost/helpers": "1.1.74", - "@tryghost/kg-clean-basic-html": "2.2.20", - "@tryghost/kg-parser-plugins": "2.12.5", + "@tryghost/kg-clean-basic-html": "2.2.23", + "@tryghost/kg-parser-plugins": "2.12.8", "@tryghost/limit-service": "1.2.3", "@tryghost/members-csv": "0.0.0", "@tryghost/mobiledoc-kit": "0.12.5-ghost.2", @@ -69,7 +69,7 @@ "element-resize-detector": "1.2.4", "ember-ajax": "5.1.2", "ember-assign-helper": "0.4.0", - "ember-auto-import": "2.4.2", + "ember-auto-import": "2.4.3", "ember-classic-decorator": "3.0.1", "ember-cli": "3.24.0", "ember-cli-app-version": "5.0.0", @@ -113,12 +113,12 @@ "ember-simple-auth": "4.2.2", "ember-sinon": "5.0.0", "ember-source": "3.24.0", - "ember-svg-jar": "2.4.0", - "ember-template-lint": "4.15.0", + "ember-svg-jar": "2.4.2", + "ember-template-lint": "4.16.1", "ember-test-selectors": "6.0.0", "ember-tooltips": "3.6.0", "ember-truth-helpers": "3.1.1", - "eslint": "8.25.0", + "eslint": "8.26.0", "eslint-plugin-babel": "5.3.1", "eslint-plugin-react": "7.31.10", "faker": "5.5.3", @@ -152,7 +152,7 @@ "testem": "3.9.0", "top-gh-contribs": "2.0.4", "tracked-built-ins": "3.1.0", - "util": "0.12.4", + "util": "0.12.5", "validator": "7.2.0", "walk-sync": "3.0.0" }, @@ -181,4 +181,4 @@ "path-browserify": "1.0.1", "webpack": "5.74.0" } -} +} \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/empty-clicked.svg b/ghost/admin/public/assets/icons/empty-clicked.svg new file mode 100644 index 00000000000..00917ca16d2 --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-clicked.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/empty-conversion.svg b/ghost/admin/public/assets/icons/empty-conversion.svg new file mode 100644 index 00000000000..d6693e34374 --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-conversion.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/empty-feedback.svg b/ghost/admin/public/assets/icons/empty-feedback.svg new file mode 100644 index 00000000000..f68ed6ac0ce --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-feedback.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/empty-opened.svg b/ghost/admin/public/assets/icons/empty-opened.svg new file mode 100644 index 00000000000..76b86926a24 --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-opened.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/empty-sent.svg b/ghost/admin/public/assets/icons/empty-sent.svg new file mode 100644 index 00000000000..352a57e217a --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-sent.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/event-less-like-this--feature-attribution.svg b/ghost/admin/public/assets/icons/event-less-like-this--feature-attribution.svg new file mode 100644 index 00000000000..81b7b896932 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-less-like-this--feature-attribution.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/event-less-like-this.svg b/ghost/admin/public/assets/icons/event-less-like-this.svg new file mode 100644 index 00000000000..539880fe3d7 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-less-like-this.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/event-more-like-this--feature-attribution.svg b/ghost/admin/public/assets/icons/event-more-like-this--feature-attribution.svg new file mode 100644 index 00000000000..ff9f1c8df86 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-more-like-this--feature-attribution.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/event-more-like-this.svg b/ghost/admin/public/assets/icons/event-more-like-this.svg new file mode 100644 index 00000000000..c9c8db2fc78 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-more-like-this.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/tests/acceptance/authentication-test.js b/ghost/admin/tests/acceptance/authentication-test.js index 36bf6910ab8..918f0a9ba02 100644 --- a/ghost/admin/tests/acceptance/authentication-test.js +++ b/ghost/admin/tests/acceptance/authentication-test.js @@ -1,8 +1,9 @@ +import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; import windowProxy from 'ghost-admin/utils/window-proxy'; import {Response} from 'miragejs'; import {afterEach, beforeEach, describe, it} from 'mocha'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; -import {click, currentRouteName, currentURL, fillIn, findAll, visit} from '@ember/test-helpers'; +import {currentRouteName, currentURL, fillIn, findAll, triggerKeyEvent, visit} from '@ember/test-helpers'; import {expect} from 'chai'; import {run} from '@ember/runloop'; import {setupApplicationTest} from 'ember-mocha'; @@ -49,7 +50,7 @@ describe('Acceptance: Authentication', function () { it('invalidates session on 401 API response', async function () { // return a 401 when attempting to retrieve users - this.server.get('/users/', () => new Response(401, {}, { + this.server.get('/users/me', () => new Response(401, {}, { errors: [ {message: 'Access denied.', type: 'UnauthorizedError'} ] @@ -68,6 +69,27 @@ describe('Acceptance: Authentication', function () { expect(currentURL(), 'url after 401').to.equal('/signin'); }); + it('invalidates session on 403 API response', async function () { + // return a 401 when attempting to retrieve users + this.server.get('/users/me', () => new Response(403, {}, { + errors: [ + {message: 'Authorization failed', type: 'NoPermissionError'} + ] + })); + + await authenticateSession(); + await visit('/settings/staff'); + + // running `visit(url)` inside windowProxy.replaceLocation breaks + // the async behaviour so we need to run `visit` here to simulate + // the browser visiting the new page + if (newLocation) { + await visit(newLocation); + } + + expect(currentURL(), 'url after 403').to.equal('/signin'); + }); + it('doesn\'t show navigation menu on invalid url when not authenticated', async function () { await invalidateSession(); @@ -94,7 +116,7 @@ describe('Acceptance: Authentication', function () { }); // TODO: re-enable once modal reappears correctly - describe.skip('editor', function () { + describe('editor', function () { let origDebounce = run.debounce; let origThrottle = run.throttle; @@ -107,13 +129,14 @@ describe('Acceptance: Authentication', function () { it('displays re-auth modal attempting to save with invalid session', async function () { let role = this.server.create('role', {name: 'Administrator'}); this.server.create('user', {roles: [role]}); + let testOn = 'save'; // use marker for different type of server.put result // simulate an invalid session when saving the edited post - this.server.put('/posts/:id/', function ({posts}, {params}) { + this.server.put('/posts/:id/', function ({posts, db}, {params}) { let post = posts.find(params.id); - let attrs = this.normalizedRequestAttrs(); + let attrs = db.posts.find(params.id); // use attribute from db.posts to avoid hasInverseFor error - if (attrs.mobiledoc.cards[0][1].markdown === 'Edited post body') { + if (testOn === 'edit') { return new Response(401, {}, { errors: [ {message: 'Access denied.', type: 'UnauthorizedError'} @@ -129,9 +152,12 @@ describe('Acceptance: Authentication', function () { await visit('/editor'); // create the post - await fillIn('#entry-title', 'Test Post'); + await fillIn('.gh-editor-title', 'Test Post'); await fillIn('.__mobiledoc-editor', 'Test post body'); - await click('.js-publish-button'); + await triggerKeyEvent('.gh-editor-title', 'keydown', 83, { + metaKey: ctrlOrCmd === 'command', + ctrlKey: ctrlOrCmd === 'ctrl' + }); // we shouldn't have a modal at this point expect(findAll('.modal-container #login').length, 'modal exists').to.equal(0); @@ -139,8 +165,12 @@ describe('Acceptance: Authentication', function () { expect(findAll('.gh-alert').length, 'no of alerts').to.equal(0); // update the post + testOn = 'edit'; await fillIn('.__mobiledoc-editor', 'Edited post body'); - await click('.js-publish-button'); + await triggerKeyEvent('.gh-editor-title', 'keydown', 83, { + metaKey: ctrlOrCmd === 'command', + ctrlKey: ctrlOrCmd === 'ctrl' + }); // we should see a re-auth modal expect(findAll('.fullscreen-modal #login').length, 'modal exists').to.equal(1); diff --git a/ghost/admin/tests/acceptance/members/filter-test.js b/ghost/admin/tests/acceptance/members/filter-test.js index 02c5629f72d..f750af32a26 100644 --- a/ghost/admin/tests/acceptance/members/filter-test.js +++ b/ghost/admin/tests/acceptance/members/filter-test.js @@ -20,6 +20,7 @@ describe('Acceptance: Members filtering', function () { beforeEach(async function () { this.server.loadFixtures('configs'); this.server.loadFixtures('settings'); + this.server.loadFixtures('newsletters'); enableStripe(this.server); enableNewsletters(this.server, true); @@ -203,6 +204,22 @@ describe('Acceptance: Members filtering', function () { expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') .to.equal(7); + + // Can set filter by path + await visit('/'); + await visit('/members?filter=' + encodeURIComponent('subscribed:true')); + expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true - from URL') + .to.equal(3); + await click('[data-test-button="members-filter-actions"]'); + expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('true'); + + // Can set filter by path + await visit('/'); + await visit('/members?filter=' + encodeURIComponent('subscribed:false')); + expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false - from URL') + .to.equal(4); + await click('[data-test-button="members-filter-actions"]'); + expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('false'); }); it('can filter by member status', async function () { diff --git a/ghost/admin/tests/integration/components/tabs/tabs-test.js b/ghost/admin/tests/integration/components/tabs/tabs-test.js new file mode 100644 index 00000000000..bc66ddbed96 --- /dev/null +++ b/ghost/admin/tests/integration/components/tabs/tabs-test.js @@ -0,0 +1,105 @@ +import {click, findAll, render, triggerKeyEvent} from '@ember/test-helpers'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {hbs} from 'ember-cli-htmlbars'; +import {setupRenderingTest} from 'ember-mocha'; + +describe('Integration: Component: tabs/tabs', function () { + setupRenderingTest(); + + it('renders', async function () { + await render(hbs` + + Tab 1 + Tab 2 + + Content 1 + Content 2 + `); + + const tabButtons = findAll('.tab'); + const tabPanels = findAll('.tab-panel'); + + expect(findAll('.test-tab').length).to.equal(1); + expect(findAll('.tab-list').length).to.equal(1); + expect(tabPanels.length).to.equal(2); + expect(tabButtons.length).to.equal(2); + + expect(findAll('.tab-selected').length).to.equal(1); + expect(findAll('.tab-panel-selected').length).to.equal(1); + expect(tabButtons[0]).to.have.class('tab-selected'); + expect(tabPanels[0]).to.have.class('tab-panel-selected'); + + expect(tabButtons[0]).to.have.trimmed.text('Tab 1'); + expect(tabButtons[1]).to.have.trimmed.text('Tab 2'); + + expect(tabPanels[0]).to.have.trimmed.text('Content 1'); + expect(tabPanels[1]).to.have.trimmed.text(''); + }); + + it('renders expected content on click', async function () { + await render(hbs` + + Tab 1 + Tab 2 + + Content 1 + Content 2 + `); + + const tabButtons = findAll('.tab'); + const tabPanels = findAll('.tab-panel'); + + await click(tabButtons[1]); + + expect(findAll('.tab-selected').length).to.equal(1); + expect(findAll('.tab-panel-selected').length).to.equal(1); + expect(tabButtons[1]).to.have.class('tab-selected'); + expect(tabPanels[1]).to.have.class('tab-panel-selected'); + + expect(tabPanels[0]).to.have.trimmed.text(''); + expect(tabPanels[1]).to.have.trimmed.text('Content 2'); + }); + + it('renders expected content on keyup event', async function () { + await render(hbs` + + Tab 0 + Tab 1 + Tab 2 + + Content 0 + Content 1 + Content 2 + `); + + const tabButtons = findAll('.tab'); + const tabPanels = findAll('.tab-panel'); + + const isTabRenders = (num) => { + expect(tabButtons[num]).to.have.class('tab-selected'); + expect(tabPanels[num]).to.have.class('tab-panel-selected'); + + expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`); + }; + + await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight'); + await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight'); + isTabRenders(2); + + await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight'); + isTabRenders(0); + + await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft'); + isTabRenders(2); + + await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft'); + isTabRenders(1); + + await triggerKeyEvent(tabButtons[0], 'keyup', 'Home'); + isTabRenders(0); + + await triggerKeyEvent(tabButtons[0], 'keyup', 'End'); + isTabRenders(2); + }); +}); diff --git a/ghost/api-framework/package.json b/ghost/api-framework/package.json index 9b71a61a996..5fd5e69b181 100644 --- a/ghost/api-framework/package.json +++ b/ghost/api-framework/package.json @@ -20,15 +20,15 @@ "devDependencies": { "bluebird": "3.7.2", "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, "dependencies": { - "@tryghost/debug": "0.1.18", + "@tryghost/debug": "0.1.19", "@tryghost/errors": "1.2.18", "@tryghost/promise": "0.1.22", - "@tryghost/tpl": "0.1.18", + "@tryghost/tpl": "0.1.19", "@tryghost/validator": "0.1.29", "jsonpath": "1.1.1", "lodash": "4.17.21" diff --git a/ghost/api-version-compatibility-service/package.json b/ghost/api-version-compatibility-service/package.json index af88e0dfa60..c376cb99c2c 100644 --- a/ghost/api-version-compatibility-service/package.json +++ b/ghost/api-version-compatibility-service/package.json @@ -19,7 +19,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "sinon": "14.0.1" }, "dependencies": { diff --git a/ghost/audience-feedback/lib/AudienceFeedbackService.js b/ghost/audience-feedback/lib/AudienceFeedbackService.js index 3836a31d45c..66240fd4167 100644 --- a/ghost/audience-feedback/lib/AudienceFeedbackService.js +++ b/ghost/audience-feedback/lib/AudienceFeedbackService.js @@ -1,7 +1,36 @@ class AudienceFeedbackService { - buildLink() { - // todo - return new URL('https://example.com'); + /** @type URL */ + #baseURL; + /** @type {Object} */ + #urlService; + /** + * @param {object} deps + * @param {object} deps.config + * @param {URL} deps.config.baseURL + * @param {object} deps.urlService + */ + constructor(deps) { + this.#baseURL = deps.config.baseURL; + this.#urlService = deps.urlService; + } + /** + * @param {string} uuid + * @param {string} postId + * @param {0 | 1} score + */ + buildLink(uuid, postId, score) { + let postUrl = this.#urlService.getUrlByResourceId(postId, {absolute: true}); + + if (postUrl.match(/\/404\//)) { + postUrl = this.#baseURL; + } + const url = new URL(postUrl); + url.searchParams.set('action', 'feedback'); + url.searchParams.set('post', postId); + url.searchParams.set('uuid', uuid); + url.searchParams.set('score', `${score}`); + + return url; } } diff --git a/ghost/audience-feedback/package.json b/ghost/audience-feedback/package.json index 2f4d739070d..80a85a6dbdf 100644 --- a/ghost/audience-feedback/package.json +++ b/ghost/audience-feedback/package.json @@ -19,13 +19,13 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, "dependencies": { "@tryghost/errors": "1.2.18", - "@tryghost/tpl": "0.1.18", + "@tryghost/tpl": "0.1.19", "bson-objectid": "2.0.3" } } diff --git a/ghost/audience-feedback/test/AudienceFeedbackService.test.js b/ghost/audience-feedback/test/AudienceFeedbackService.test.js new file mode 100644 index 00000000000..fb88c4a8256 --- /dev/null +++ b/ghost/audience-feedback/test/AudienceFeedbackService.test.js @@ -0,0 +1,45 @@ +const assert = require('assert'); +const {AudienceFeedbackService} = require('../index'); + +describe('audienceFeedbackService', function () { + it('exported', function () { + assert.equal(require('../index').AudienceFeedbackService, AudienceFeedbackService); + }); + + const mockData = { + uuid: '7b11de3c-dff9-4563-82ae-a281122d201d', + postId: '634fc3901e0a291855d8b135', + postTitle: 'somepost', + score: 1 + }; + + describe('build link', function () { + it('Can build link to post', async function () { + const instance = new AudienceFeedbackService({ + urlService: { + getUrlByResourceId: () => `https://localhost:2368/${mockData.postTitle}/` + }, + config: { + baseURL: new URL('https://localhost:2368') + } + }); + const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score); + const expectedLink = `https://localhost:2368/${mockData.postTitle}/?action=feedback&post=${mockData.postId}&uuid=${mockData.uuid}&score=${mockData.score}`; + assert.equal(link.href, expectedLink); + }); + + it('Can build link to home page if post wasn\'t published', async function () { + const instance = new AudienceFeedbackService({ + urlService: { + getUrlByResourceId: () => `https://localhost:2368/${mockData.postTitle}/404/` + }, + config: { + baseURL: new URL('https://localhost:2368') + } + }); + const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score); + const expectedLink = `https://localhost:2368/?action=feedback&post=${mockData.postId}&uuid=${mockData.uuid}&score=${mockData.score}`; + assert.equal(link.href, expectedLink); + }); + }); +}); diff --git a/ghost/audience-feedback/test/hello.test.js b/ghost/audience-feedback/test/hello.test.js deleted file mode 100644 index 85d69d1e08c..00000000000 --- a/ghost/audience-feedback/test/hello.test.js +++ /dev/null @@ -1,10 +0,0 @@ -// Switch these lines once there are useful utils -// const testUtils = require('./utils'); -require('./utils'); - -describe('Hello world', function () { - it('Runs a test', function () { - // TODO: Write me! - 'hello'.should.eql('hello'); - }); -}); diff --git a/ghost/bootstrap-socket/package.json b/ghost/bootstrap-socket/package.json index 64cafbd61b2..d5c8e42d317 100644 --- a/ghost/bootstrap-socket/package.json +++ b/ghost/bootstrap-socket/package.json @@ -17,7 +17,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, diff --git a/ghost/constants/package.json b/ghost/constants/package.json index 27d1c9d1110..841153cde52 100644 --- a/ghost/constants/package.json +++ b/ghost/constants/package.json @@ -17,7 +17,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" } diff --git a/ghost/core/core/frontend/public/robots.txt b/ghost/core/core/frontend/public/robots.txt index d3e413eeb5a..5895cc5cdaa 100644 --- a/ghost/core/core/frontend/public/robots.txt +++ b/ghost/core/core/frontend/public/robots.txt @@ -3,3 +3,4 @@ Sitemap: {{blog-url}}/sitemap.xml Disallow: /ghost/ Disallow: /p/ Disallow: /email/ +Disallow: /r/ diff --git a/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js b/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js index c7045d30f6b..6f494369a49 100644 --- a/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js +++ b/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js @@ -63,6 +63,10 @@ const ids = Array.from(fetchingIds); fetchingIds.clear(); + if (!ids.length) { + return; + } + const rawRes = await fetch(api, { method: 'POST', headers: { @@ -72,6 +76,10 @@ body: JSON.stringify({ids}) }); + if (rawRes.status !== 200) { + return; + } + const res = await rawRes.json(); for (const [id, count] of Object.entries(res)) { diff --git a/ghost/core/core/server/api/endpoints/links.js b/ghost/core/core/server/api/endpoints/links.js index cd42e200472..6af56a4a527 100644 --- a/ghost/core/core/server/api/endpoints/links.js +++ b/ghost/core/core/server/api/endpoints/links.js @@ -6,7 +6,7 @@ module.exports = { options: [ 'filter' ], - permissions: false, + permissions: true, async query(frame) { const links = await linkTrackingService.service.getLinks(frame.options); @@ -21,5 +21,37 @@ module.exports = { } }; } + }, + bulkEdit: { + statusCode: 200, + headers: { + cacheInvalidate: true + }, + options: [ + 'filter' + ], + data: [ + 'action', + 'meta' + ], + validation: { + data: { + action: { + required: true, + values: ['updateLink'] + } + }, + options: { + filter: { + required: true + } + } + }, + permissions: { + method: 'edit' + }, + async query(frame) { + return await linkTrackingService.service.bulkEdit(frame.data.bulk, frame.options); + } } }; diff --git a/ghost/core/core/server/api/endpoints/members.js b/ghost/core/core/server/api/endpoints/members.js index 9cee71bab74..30dbba7b13f 100644 --- a/ghost/core/core/server/api/endpoints/members.js +++ b/ghost/core/core/server/api/endpoints/members.js @@ -435,10 +435,7 @@ module.exports = { method: 'browse' }, async query(frame) { - const events = await membersService.api.events.getEventTimeline(frame.options); - return { - events - }; + return await membersService.api.events.getEventTimeline(frame.options); } } }; diff --git a/ghost/core/core/server/api/endpoints/posts-public.js b/ghost/core/core/server/api/endpoints/posts-public.js index a9eb750eb64..0da7e6aaaf3 100644 --- a/ghost/core/core/server/api/endpoints/posts-public.js +++ b/ghost/core/core/server/api/endpoints/posts-public.js @@ -1,7 +1,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); -const allowedIncludes = ['tags', 'authors', 'tiers']; +const allowedIncludes = ['tags', 'authors', 'tiers', 'sentiment']; const messages = { postNotFound: 'Post not found.' diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index f7de213ad60..d854d569d82 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -11,7 +11,8 @@ const allowedIncludes = [ 'newsletter', 'count.signups', 'count.paid_conversions', - 'count.clicks' + 'count.clicks', + 'sentiment' ]; const unsafeAttrs = ['status', 'authors', 'visibility']; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js index e1232660592..178009a5126 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js @@ -16,7 +16,26 @@ function removeSourceFormats(frame) { } } +/** + * Map names of relations to the internal names + */ +function mapWithRelated(frame) { + if (frame.options.withRelated) { + // Map sentiment to count.sentiment + if (labs.isSet('audienceFeedback')) { + frame.options.withRelated = frame.options.withRelated.map((relation) => { + return relation === 'sentiment' ? 'count.sentiment' : relation; + }); + } + return; + } +} + function defaultRelations(frame) { + // Apply same mapping as content API + mapWithRelated(frame); + + // Addditional defaults for admin API if (frame.options.withRelated) { return; } @@ -26,7 +45,7 @@ function defaultRelations(frame) { } if (labs.isSet('audienceFeedback')) { - frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.signups', 'count.paid_conversions', 'count.clicks', 'count.sentiment', 'count.positive_feedback']; + frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.conversions', 'count.clicks', 'count.sentiment', 'count.positive_feedback', 'count.negative_feedback']; } else { frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.signups', 'count.paid_conversions', 'count.clicks']; } @@ -111,6 +130,7 @@ module.exports = { setDefaultOrder(frame); forceVisibilityColumn(frame); + mapWithRelated(frame); } if (!localUtils.isContentAPI(frame)) { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js index 7dc5768b643..d23d86a4d32 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js @@ -127,5 +127,9 @@ module.exports = { get members_stripe_connect() { return require('./members-stripe-connect'); + }, + + get links() { + return require('./links'); } }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js new file mode 100644 index 00000000000..c030f8c44a3 --- /dev/null +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js @@ -0,0 +1,5 @@ +module.exports = { + bulkEdit(data, apiConfig, frame) { + frame.response = data; + } +}; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js index f659569f7fb..f2e1ff24fc3 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js @@ -2,6 +2,21 @@ const mapComment = require('./comments'); const url = require('../utils/url'); const _ = require('lodash'); +const memberFields = [ + 'id', + 'uuid', + 'name', + 'email', + 'avatar_image' +]; + +const postFields = [ + 'id', + 'uuid', + 'title', + 'url' +]; + const commentEventMapper = (json, frame) => { return { ...json, @@ -10,26 +25,11 @@ const commentEventMapper = (json, frame) => { }; const clickEventMapper = (json, frame) => { - const memberFields = [ - 'id', - 'uuid', - 'name', - 'email', - 'avatar_image' - ]; - const linkFields = [ 'from', 'to' ]; - const postFields = [ - 'id', - 'uuid', - 'title', - 'url' - ]; - const data = json.data; const response = {}; @@ -59,6 +59,35 @@ const clickEventMapper = (json, frame) => { }; }; +const feedbackEventMapper = (json, frame) => { + const feedbackFields = [ + 'id', + 'score', + 'created_at' + ]; + + const data = json.data; + const response = _.pick(data, feedbackFields); + + if (data.post) { + url.forPost(data.post.id, data.post, frame); + response.post = _.pick(data.post, postFields); + } else { + response.post = null; + } + + if (data.member) { + response.member = _.pick(data.member, memberFields); + } else { + response.member = null; + } + + return { + ...json, + data: response + }; +}; + function serializeAttribution(attribution) { if (!attribution) { return attribution; @@ -82,6 +111,9 @@ const activityFeedMapper = (event, frame) => { if (event.type === 'click_event') { return clickEventMapper(event, frame); } + if (event.type === 'feedback_event') { + return feedbackEventMapper(event, frame); + } if (event.data?.attribution) { event.data.attribution = serializeAttribution(event.data.attribution); } diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js index 75dfcc305ad..bb7ceeee1e3 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js @@ -8,7 +8,7 @@ module.exports = (model, frame) => { const replacements = mega.postEmailSerializer.parseReplacements(jsonModel); replacements.forEach((replacement) => { jsonModel[replacement.format] = jsonModel[replacement.format].replace( - replacement.match, + replacement.regexp, replacement.fallback || '' ); }); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js index ed97dca1789..2083347db3d 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js @@ -119,13 +119,30 @@ module.exports = async (model, frame, options = {}) => { ); } - if (jsonModel.count && !jsonModel.count.sentiment) { - jsonModel.count.sentiment = 0; + // The sentiment has been loaded as a count relation in count.sentiment. But externally in the API we use just 'sentiment' instead of count.sentiment + // This part moves count.sentiment to just 'sentiment' when it has been loaded + if (frame.options.withRelated && frame.options.withRelated.includes('count.sentiment')) { + if (!jsonModel.count) { + jsonModel.sentiment = 0; + } else { + jsonModel.sentiment = jsonModel.count.sentiment ?? 0; + + // Delete it from the original location + delete jsonModel.count.sentiment; + + if (Object.keys(jsonModel.count).length === 0) { + delete jsonModel.count; + } + } } if (jsonModel.count && !jsonModel.count.positive_feedback) { jsonModel.count.positive_feedback = 0; } + if (jsonModel.count && !jsonModel.count.negative_feedback) { + jsonModel.count.negative_feedback = 0; + } + return jsonModel; }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js index 6331d6e3e9a..0e5ae4adb4a 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js @@ -22,8 +22,8 @@ module.exports = (snippet, frame) => { /** * @typedef {Object} SerializedSnippet * @prop {string} id - * @prop {string=} name - * @prop {string=} mobiledoc + * @prop {string} [name] + * @prop {string} [mobiledoc] * @prop {string} created_at * @prop {string} updated_at * @prop {string} created_by diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js index 7e3d689030d..88f61759c22 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js @@ -76,11 +76,12 @@ function bulkAction(bulkActionResult, _apiConfig, frame) { /** * - * @returns {{events: any[]}} + * @returns {{events: any[], meta: any}} */ function activityFeed(data, _apiConfig, frame) { return { - events: data.events.map(e => mappers.activityFeedEvents(e, frame)) + events: data.events.map(e => mappers.activityFeedEvents(e, frame)), + meta: data.meta }; } @@ -216,15 +217,15 @@ function createSerializer(debugString, serialize) { * @prop {string} id * @prop {string} uuid * @prop {string} email - * @prop {string=} name - * @prop {string=} note + * @prop {string} [name] + * @prop {string} [note] * @prop {null|string} geolocation * @prop {boolean} subscribed * @prop {string} created_at * @prop {string} updated_at * @prop {string[]} labels * @prop {SerializedMemberStripeSubscription[]} subscriptions - * @prop {SerializedMemberProduct[]=} products + * @prop {SerializedMemberProduct[]} [products] * @prop {string} avatar_image * @prop {boolean} comped * @prop {number} email_count diff --git a/ghost/core/core/server/data/importer/importers/data/custom-theme-settings.js b/ghost/core/core/server/data/importer/importers/data/custom-theme-settings.js new file mode 100644 index 00000000000..55283e1ba22 --- /dev/null +++ b/ghost/core/core/server/data/importer/importers/data/custom-theme-settings.js @@ -0,0 +1,81 @@ +const _ = require('lodash'); +const Promise = require('bluebird'); +const debug = require('@tryghost/debug')('importer:roles'); +const BaseImporter = require('./base'); +const models = require('../../../../models'); +const {activate} = require('../../../../services/themes/activate'); + +class CustomThemeSettingsImporter extends BaseImporter { + constructor(allDataFromFile) { + super(allDataFromFile, { + modelName: 'CustomThemeSetting', + dataKeyToImport: 'custom_theme_settings' + }); + } + + beforeImport() { + debug('beforeImport'); + return super.beforeImport(); + } + + doImport(options, importOptions) { + debug('doImport', this.modelName, this.dataToImport.length); + + let ops = []; + + _.each(this.dataToImport, (item) => { + ops.push(models.CustomThemeSetting.findOne({theme: item.theme, key: item.key}, options) + .then((setting) => { + if (_.isObject(item.value)) { + item.value = JSON.stringify(item.value); + } + + if (setting) { + setting.set('value', item.value); + if (setting.hasChanged()) { + return setting.save(null, options) + .then((importedModel) => { + if (importOptions.returnImportedData) { + this.importedDataToReturn.push(importedModel.toJSON()); + } + return importedModel; + }) + .catch((err) => { + return this.handleError(err, item); + }); + } + + return Promise.resolve(); + } + + return models.CustomThemeSetting.add(item, options) + .then((importedModel) => { + if (importOptions.returnImportedData) { + this.importedDataToReturn.push(importedModel.toJSON()); + } + return importedModel; + }) + .catch((err) => { + return this.handleError(err, item); + }); + }) + .reflect()); + }); + + const opsPromise = Promise.all(ops); + + // activate function is called to refresh cache when importing custom theme settings for active theme + opsPromise.then(() => { + models.Settings.findOne({key: 'active_theme'}) + .then((theme) => { + const currentTheme = theme.get('value'); + if (this.dataToImport.some(themeSetting => themeSetting.theme === currentTheme)) { + activate(currentTheme); + } + }); + }); + + return opsPromise; + } +} +module.exports = CustomThemeSettingsImporter; \ No newline at end of file diff --git a/ghost/core/core/server/data/importer/importers/data/data-importer.js b/ghost/core/core/server/data/importer/importers/data/data-importer.js index cff048d567f..9c746870327 100644 --- a/ghost/core/core/server/data/importer/importers/data/data-importer.js +++ b/ghost/core/core/server/data/importer/importers/data/data-importer.js @@ -13,6 +13,7 @@ const NewslettersImporter = require('./newsletters'); const ProductsImporter = require('./products'); const StripeProductsImporter = require('./stripe-products'); const StripePricesImporter = require('./stripe-prices'); +const CustomThemeSettingsImporter = require('./custom-theme-settings'); const RolesImporter = require('./roles'); let importers = {}; let DataImporter; @@ -35,6 +36,7 @@ DataImporter = { importers.stripe_products = new StripeProductsImporter(importData.data); importers.stripe_prices = new StripePricesImporter(importData.data); importers.posts = new PostsImporter(importData.data); + importers.custom_theme_settings = new CustomThemeSettingsImporter(importData.data); return importData; }, diff --git a/ghost/core/core/server/data/migrations/utils/permissions.js b/ghost/core/core/server/data/migrations/utils/permissions.js index 66b2685e39b..2982770df6d 100644 --- a/ghost/core/core/server/data/migrations/utils/permissions.js +++ b/ghost/core/core/server/data/migrations/utils/permissions.js @@ -10,6 +10,10 @@ const messages = { permissionRoleActionError: 'Cannot {action} permission({permission}) with role({role}) - {resource} does not exist' }; +/** + * @param {import('knex').Knex} connection + * @param {PermissionConfig} config + */ async function addPermissionHelper(connection, config) { const existingPermission = await connection('permissions').where({ name: config.name, @@ -38,6 +42,10 @@ async function addPermissionHelper(connection, config) { }); } +/** + * @param {import('knex').Knex} connection + * @param {PermissionConfig} config + */ async function removePermissionHelper(connection, config) { const existingPermission = await connection('permissions').where({ name: config.name, @@ -61,10 +69,7 @@ async function removePermissionHelper(connection, config) { /** * Creates a migration which will add a permission to the database * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission + * @param {PermissionConfig} config * * @returns {Migration} */ @@ -82,10 +87,7 @@ function addPermission(config) { /** * Creates a migration which will remove a permission from the database * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission + * @param {PermissionConfig} config * * @returns {Migration} */ @@ -100,6 +102,10 @@ function removePermission(config) { ); } +/** + * @param {import('knex').Knex} connection + * @param {PermissionRoleConfig} config + */ async function addPermissionToRoleHelper(connection, config) { const permission = await connection('permissions').where({ name: config.permission @@ -149,6 +155,10 @@ async function addPermissionToRoleHelper(connection, config) { }); } +/** + * @param {import('knex').Knex} connection + * @param {PermissionRoleConfig} config + */ async function removePermissionFromRoleHelper(connection, config) { const permission = await connection('permissions').where({ name: config.permission @@ -188,9 +198,7 @@ async function removePermissionFromRoleHelper(connection, config) { /** * Creates a migration which will link a permission to a role in the database * - * @param {Object} config - * @param {string} config.permission - The name of the permission - * @param {string} config.role - The name of the role + * @param {PermissionRoleConfig} config * * @returns {Migration} */ @@ -208,9 +216,7 @@ function addPermissionToRole(config) { /** * Creates a migration which will remove the permission from roles * - * @param {Object} config - * @param {string} config.permission - The name of the permission - * @param {string} config.role - The name of the role + * @param {PermissionRoleConfig} config * * @returns {Migration} */ @@ -228,11 +234,7 @@ function removePermissionFromRole(config) { /** * Creates a migration which will add a permission to the database, and then link it to roles * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission - * + * @param {PermissionConfig} config * @param {string[]} roles - A list of role names * * @returns {Migration} @@ -247,11 +249,7 @@ function addPermissionWithRoles(config, roles) { /** * Creates a migration which will remove permissions from roles, and then remove the permission * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission - * + * @param {PermissionConfig} config * @param {string[]} roles - A list of role names * * @returns {Migration} @@ -270,6 +268,19 @@ module.exports = { createRemovePermissionMigration }; +/** + * @typedef {Object} PermissionConfig + * @prop {string} config.name - The name of the permission + * @prop {string} config.action - The action_type of the permission + * @prop {string} config.object - The object_type of the permission + */ + +/** + * @typedef {Object} PermissionRoleConfig + * @prop {string} config.permission - The name of the permission + * @prop {string} config.role - The role to assign the Permission to + */ + /** * @typedef {Object} TransactionalMigrationFunctionOptions * diff --git a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-25-add-columns-to-products-table.js b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-25-add-columns-to-products-table.js new file mode 100644 index 00000000000..fad8edc59b6 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-25-add-columns-to-products-table.js @@ -0,0 +1,19 @@ +const {createAddColumnMigration, combineNonTransactionalMigrations} = require('../../utils'); + +module.exports = combineNonTransactionalMigrations( + createAddColumnMigration('products', 'monthly_price', { + type: 'integer', + unsigned: true, + nullable: true + }), + createAddColumnMigration('products', 'yearly_price', { + type: 'integer', + unsigned: true, + nullable: true + }), + createAddColumnMigration('products', 'currency', { + type: 'string', + maxlength: 50, + nullable: true + }) +); diff --git a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js new file mode 100644 index 00000000000..565294ce4b7 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js @@ -0,0 +1,37 @@ +const logging = require('@tryghost/logging'); + +const {createTransactionalMigration} = require('../../utils'); + +module.exports = createTransactionalMigration( + async function up(knex) { + const rows = await knex('products as t') // eslint-disable-line no-restricted-syntax + .select( + 't.id as id', + 'mp.amount as monthly_price', + 'yp.amount as yearly_price', + knex.raw('coalesce(yp.currency, mp.currency) as currency') + ) + .leftJoin('stripe_prices AS mp', 't.monthly_price_id', 'mp.id') + .leftJoin('stripe_prices AS yp', 't.yearly_price_id', 'yp.id') + .where('t.type', 'paid'); + + if (!rows.length) { + logging.info('Did not find any active paid Tiers'); + return; + } else { + logging.info(`Updating ${rows.length} Tiers with price and currency information`); + } + + for (const row of rows) { // eslint-disable-line no-restricted-syntax + await knex('products').update(row).where('id', row.id); + } + }, + async function down(knex) { + logging.info('Removing currency and price information for all tiers'); + await knex('products').update({ + currency: null, + monthly_price: null, + yearly_price: null + }); + } +); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js new file mode 100644 index 00000000000..937753ee098 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js @@ -0,0 +1,3 @@ +const {createDropNullableMigration} = require('../../utils'); + +module.exports = createDropNullableMigration('subscriptions', 'tier_id'); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js new file mode 100644 index 00000000000..e852d02ea6b --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js @@ -0,0 +1,10 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('members_stripe_customers_subscriptions', 'ghost_subscription_id', { + type: 'string', + maxlength: 24, + nullable: true, + references: 'subscriptions.id', + constraintName: 'mscs_ghost_subscription_id_foreign', + cascadeDelete: true +}); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js new file mode 100644 index 00000000000..7ffd3d34dec --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js @@ -0,0 +1,10 @@ +const {addPermissionWithRoles} = require('../../utils'); + +module.exports = addPermissionWithRoles({ + name: 'Browse links', + action: 'browse', + object: 'link' +}, [ + 'Administrator', + 'Admin Integration' +]); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js new file mode 100644 index 00000000000..db5d83cfe37 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js @@ -0,0 +1,10 @@ +const {addPermissionWithRoles} = require('../../utils'); + +module.exports = addPermissionWithRoles({ + name: 'Edit links', + action: 'edit', + object: 'link' +}, [ + 'Administrator', + 'Admin Integration' +]); diff --git a/ghost/core/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js new file mode 100644 index 00000000000..f3ad8ee3a34 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js @@ -0,0 +1,20 @@ +const logging = require('@tryghost/logging'); +const {createTransactionalMigration} = require('../../utils'); + +module.exports = createTransactionalMigration( + async function up(connection) { + const affectedRows = await connection('newsletters') + .update({ + feedback_enabled: false + }) + .where('feedback_enabled', true); + + if (affectedRows > 0) { + // Only log if this site was affected by the issue. + logging.info(`Disabled feedback for ${affectedRows} newsletter(s)`); + } + }, + async function down() { + // no-op: we don't need to change it back + } +); diff --git a/ghost/core/core/server/data/schema/commands.js b/ghost/core/core/server/data/schema/commands.js index 04dc071cb98..5f748fea41a 100644 --- a/ghost/core/core/server/data/schema/commands.js +++ b/ghost/core/core/server/data/schema/commands.js @@ -14,20 +14,26 @@ const messages = { noSupportForDatabase: 'No support for database client {client}' }; -function addTableColumn(tableName, table, columnName, columnSpec = schema[tableName][columnName]) { +/** + * @param {string} tableName + * @param {import('knex').knex.TableBuilder} tableBuilder + * @param {string} columnName + * @param {object} [columnSpec] + */ +function addTableColumn(tableName, tableBuilder, columnName, columnSpec = schema[tableName][columnName]) { let column; // creation distinguishes between text with fieldtype, string with maxlength and all others if (columnSpec.type === 'text' && Object.prototype.hasOwnProperty.call(columnSpec, 'fieldtype')) { - column = table[columnSpec.type](columnName, columnSpec.fieldtype); + column = tableBuilder[columnSpec.type](columnName, columnSpec.fieldtype); } else if (columnSpec.type === 'string') { if (Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength')) { - column = table[columnSpec.type](columnName, columnSpec.maxlength); + column = tableBuilder[columnSpec.type](columnName, columnSpec.maxlength); } else { - column = table[columnSpec.type](columnName, 191); + column = tableBuilder[columnSpec.type](columnName, 191); } } else { - column = table[columnSpec.type](columnName); + column = tableBuilder[columnSpec.type](columnName); } if (Object.prototype.hasOwnProperty.call(columnSpec, 'nullable') && columnSpec.nullable === true) { @@ -48,6 +54,10 @@ function addTableColumn(tableName, table, columnName, columnSpec = schema[tableN // check if table exists? column.references(columnSpec.references); } + if (Object.prototype.hasOwnProperty.call(columnSpec, 'constraintName')) { + column.withKeyName(columnSpec.constraintName); + } + if (Object.prototype.hasOwnProperty.call(columnSpec, 'cascadeDelete') && columnSpec.cascadeDelete === true) { column.onDelete('CASCADE'); } else if (Object.prototype.hasOwnProperty.call(columnSpec, 'setNullDelete') && columnSpec.setNullDelete === true) { @@ -61,18 +71,34 @@ function addTableColumn(tableName, table, columnName, columnSpec = schema[tableN } } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex} [transaction] + */ function setNullable(tableName, column, transaction = db.knex) { return transaction.schema.table(tableName, function (table) { table.setNullable(column); }); } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex} [transaction] + */ function dropNullable(tableName, column, transaction = db.knex) { return transaction.schema.table(tableName, function (table) { table.dropNullable(column); }); } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex.Transaction} [transaction] + * @param {object} columnSpec + */ async function addColumn(tableName, column, transaction = db.knex, columnSpec) { const addColumnBuilder = transaction.schema.table(tableName, function (table) { addTableColumn(tableName, table, column, columnSpec); @@ -85,41 +111,51 @@ async function addColumn(tableName, column, transaction = db.knex, columnSpec) { return; } - let sql = addColumnBuilder.toSQL()[0].sql; + for (const sqlQuery of addColumnBuilder.toSQL()) { + let sql = sqlQuery.sql; - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; - } + if (DatabaseInfo.isMySQL(transaction)) { + // Guard against an ending semicolon + sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; + } - await transaction.raw(sql); + await transaction.raw(sql); + } } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex} [transaction] + * @param {object} [columnSpec] + */ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = {}) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) { const [toTable, toColumn] = columnSpec.references.split('.'); - await dropForeign({fromTable: tableName, fromColumn: column, toTable, toColumn, transaction}); + await dropForeign({fromTable: tableName, fromColumn: column, toTable, toColumn, constraintName: columnSpec.constraintName, transaction}); } - const dropTableBuilder = transaction.schema.table(tableName, function (table) { + const dropColumnBuilder = transaction.schema.table(tableName, function (table) { table.dropColumn(column); }); // Use the default flow for SQLite because .toSQL() is tricky with SQLite when // it does the table dance if (DatabaseInfo.isSQLite(transaction)) { - await dropTableBuilder; + await dropColumnBuilder; return; } - let sql = dropTableBuilder.toSQL()[0].sql; + for (const sqlQuery of dropColumnBuilder.toSQL()) { + let sql = sqlQuery.sql; - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; - } + if (DatabaseInfo.isMySQL(transaction)) { + // Guard against an ending semicolon + sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; + } - await transaction.raw(sql); + await transaction.raw(sql); + } } /** @@ -127,7 +163,7 @@ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = * * @param {string} tableName - name of the table to add unique constraint to * @param {string|string[]} columns - column(s) to form unique constraint with - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} [transaction] - connection object containing knex reference */ async function addUnique(tableName, columns, transaction = db.knex) { try { @@ -154,7 +190,7 @@ async function addUnique(tableName, columns, transaction = db.knex) { * * @param {string} tableName - name of the table to drop unique constraint from * @param {string|string[]} columns - column(s) unique constraint was formed - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} transaction - connection object containing knex reference */ async function dropUnique(tableName, columns, transaction = db.knex) { try { @@ -184,7 +220,7 @@ async function dropUnique(tableName, columns, transaction = db.knex) { * @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.toTable - name of the table to point the foreign key to * @param {string} configuration.toColumn - column of the table to point the foreign key to - * @param {import('knex')} configuration.transaction - connection object containing knex reference + * @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference */ async function hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction = db.knex}) { if (!DatabaseInfo.isSQLite(transaction)) { @@ -208,11 +244,12 @@ async function hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, trans * @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.toTable - name of the table to point the foreign key to * @param {string} configuration.toColumn - column of the table to point the foreign key to + * @param {string} [configuration.constraintName] - name of the FK to create * @param {Boolean} [configuration.cascadeDelete] - adds the "on delete cascade" option if true * @param {Boolean} [configuration.setNullDelete] - adds the "on delete SET NULL" option if true - * @param {import('knex')} configuration.transaction - connection object containing knex reference + * @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference */ -async function addForeign({fromTable, fromColumn, toTable, toColumn, cascadeDelete = false, setNullDelete = false, transaction = db.knex}) { +async function addForeign({fromTable, fromColumn, toTable, toColumn, constraintName, cascadeDelete = false, setNullDelete = false, transaction = db.knex}) { if (DatabaseInfo.isSQLite(transaction)) { const foreignKeyExists = await hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction}); if (foreignKeyExists) { @@ -233,12 +270,18 @@ async function addForeign({fromTable, fromColumn, toTable, toColumn, cascadeDele } await transaction.schema.table(fromTable, function (table) { + let fkBuilder; + if (cascadeDelete) { - table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('CASCADE'); + fkBuilder = table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('CASCADE'); } else if (setNullDelete) { - table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('SET NULL'); + fkBuilder = table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('SET NULL'); } else { - table.foreign(fromColumn).references(`${toTable}.${toColumn}`); + fkBuilder = table.foreign(fromColumn).references(`${toTable}.${toColumn}`); + } + + if (constraintName) { + fkBuilder.withKeyName(constraintName); } }); @@ -264,9 +307,10 @@ async function addForeign({fromTable, fromColumn, toTable, toColumn, cascadeDele * @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.toTable - name of the table to point the foreign key to * @param {string} configuration.toColumn - column of the table to point the foreign key to - * @param {import('knex')} configuration.transaction - connection object containing knex reference + * @param {string} [configuration.constraintName] - name of the FK to delete + * @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference */ -async function dropForeign({fromTable, fromColumn, toTable, toColumn, transaction = db.knex}) { +async function dropForeign({fromTable, fromColumn, toTable, toColumn, constraintName, transaction = db.knex}) { if (DatabaseInfo.isSQLite(transaction)) { const foreignKeyExists = await hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction}); if (!foreignKeyExists) { @@ -287,7 +331,7 @@ async function dropForeign({fromTable, fromColumn, toTable, toColumn, transactio } await transaction.schema.table(fromTable, function (table) { - table.dropForeign(fromColumn); + table.dropForeign(fromColumn, constraintName); }); if (DatabaseInfo.isSQLite(transaction)) { @@ -308,7 +352,7 @@ async function dropForeign({fromTable, fromColumn, toTable, toColumn, transactio * Checks if primary key index exists in a table over the given columns. * * @param {string} tableName - name of the table to check primary key constraint on - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} [transaction] - connection object containing knex reference */ async function hasPrimaryKeySQLite(tableName, transaction = db.knex) { if (!DatabaseInfo.isSQLite(transaction)){ @@ -328,7 +372,7 @@ async function hasPrimaryKeySQLite(tableName, transaction = db.knex) { * * @param {string} tableName - name of the table to add primaykey constraint to * @param {string|string[]} columns - column(s) to form primary key constraint with - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} [transaction] - connection object containing knex reference */ async function addPrimaryKey(tableName, columns, transaction = db.knex) { if (DatabaseInfo.isSQLite(transaction)) { @@ -359,7 +403,7 @@ async function addPrimaryKey(tableName, columns, transaction = db.knex) { * utils if you want that * * @param {String} table - name of the table to create - * @param {import('knex').Transaction} transaction - connection to the DB + * @param {import('knex').Knex} [transaction] - connection to the DB * @param {Object} [tableSpec] - table schema to generate table with */ function createTable(table, transaction = db.knex, tableSpec = schema[table]) { @@ -377,10 +421,17 @@ function createTable(table, transaction = db.knex, tableSpec = schema[table]) { }); } +/** + * @param {string} table + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function deleteTable(table, transaction = db.knex) { return transaction.schema.dropTableIfExists(table); } +/** + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function getTables(transaction = db.knex) { const client = transaction.client.config.client; @@ -391,6 +442,10 @@ function getTables(transaction = db.knex) { return Promise.reject(tpl(messages.noSupportForDatabase, {client: client})); } +/** + * @param {string} table + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function getIndexes(table, transaction = db.knex) { const client = transaction.client.config.client; @@ -401,6 +456,10 @@ function getIndexes(table, transaction = db.knex) { return Promise.reject(tpl(messages.noSupportForDatabase, {client: client})); } +/** + * @param {string} table + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function getColumns(table, transaction = db.knex) { const client = transaction.client.config.client; @@ -441,20 +500,20 @@ function createColumnMigration(...migrations) { } module.exports = { - createTable: createTable, - deleteTable: deleteTable, - getTables: getTables, - getIndexes: getIndexes, - addUnique: addUnique, - dropUnique: dropUnique, - addPrimaryKey: addPrimaryKey, - addForeign: addForeign, - dropForeign: dropForeign, - addColumn: addColumn, - dropColumn: dropColumn, - setNullable: setNullable, - dropNullable: dropNullable, - getColumns: getColumns, + createTable, + deleteTable, + getTables, + getIndexes, + addUnique, + dropUnique, + addPrimaryKey, + addForeign, + dropForeign, + addColumn, + dropColumn, + setNullable, + dropNullable, + getColumns, createColumnMigration, // NOTE: below are exposed for testing purposes only _hasForeignSQLite: hasForeignSQLite, diff --git a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js index cb67a831b77..02f86c1d3e1 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js +++ b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js @@ -1,5 +1,4 @@ const _ = require('lodash'); -const Promise = require('bluebird'); const logging = require('@tryghost/logging'); const {sequence} = require('@tryghost/promise'); @@ -83,16 +82,16 @@ class FixtureManager { const userRolesRelation = this.fixtures.relations.find(r => r.from.relation === 'roles'); await this.addFixturesForRelation(userRolesRelation, localOptions); - await Promise.mapSeries(this.fixtures.models.filter(m => !['User', 'Role'].includes(m.name)), (model) => { + await sequence(this.fixtures.models.filter(m => !['User', 'Role'].includes(m.name)).map(model => () => { logging.info('Model: ' + model.name); return this.addFixturesForModel(model, localOptions); - }); + })); - await Promise.mapSeries(this.fixtures.relations.filter(r => r.from.relation !== 'roles'), (relation) => { + await sequence(this.fixtures.relations.filter(r => r.from.relation !== 'roles').map(relation => () => { logging.info('Relation: ' + relation.from.model + ' to ' + relation.to.model); return this.addFixturesForRelation(relation, localOptions); - }); + })); } /* @@ -191,12 +190,15 @@ class FixtureManager { fetchRelationData(relation, options) { const fromOptions = _.extend({}, options, {withRelated: [relation.from.relation]}); - const props = { - from: models[relation.from.model].findAll(fromOptions), - to: models[relation.to.model].findAll(options) - }; + const fromRelations = models[relation.from.model].findAll(fromOptions); + const toRelations = models[relation.to.model].findAll(options); - return Promise.props(props); + return Promise.all([fromRelations, toRelations]).then(([from, to]) => { + return { + from: from, + to: to + }; + }); } /** @@ -223,7 +225,7 @@ class FixtureManager { }); } - const results = await Promise.mapSeries(modelFixture.entries, async (entry) => { + const results = await sequence(modelFixture.entries.map(entry => async () => { let data = {}; // CASE: if id is specified, only query by id @@ -243,7 +245,7 @@ class FixtureManager { if (!found) { return models[modelFixture.name].add(entry, options); } - }); + })); return {expected: modelFixture.entries.length, done: _.compact(results).length}; } @@ -308,12 +310,12 @@ class FixtureManager { } async removeFixturesForModel(modelFixture, options) { - const results = await Promise.mapSeries(modelFixture.entries, async (entry) => { + const results = await sequence(modelFixture.entries.map(entry => async () => { const found = models[modelFixture.name].findOne(entry.id ? {id: entry.id} : entry, options); if (found) { return models[modelFixture.name].destroy(_.extend(options, {id: found.id})); } - }); + })); return {expected: modelFixture.entries.length, done: results.length}; } diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json index d03a57823a1..46f8dd717f3 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixtures.json +++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json @@ -15,7 +15,10 @@ "slug": "default-product", "type": "paid", "active": true, - "visibility": "public" + "visibility": "public", + "currency": "usd", + "monthly_price": 500, + "yearly_price": 5000 } ] }, @@ -615,6 +618,16 @@ "name": "Report comments", "action_type": "report", "object_type": "comment" + }, + { + "name": "Browse links", + "action_type": "browse", + "object_type": "link" + }, + { + "name": "Edit links", + "action_type": "edit", + "object_type": "link" } ] }, @@ -744,7 +757,8 @@ "members_stripe_connect": "auth", "newsletter": "all", "explore": "read", - "comment": "all" + "comment": "all", + "link": "all" }, "DB Backup Integration": { "db": "all" @@ -778,7 +792,8 @@ "offer": ["browse", "read", "add", "edit"], "newsletter": ["browse", "read", "add", "edit"], "explore": "read", - "comment": "all" + "comment": "all", + "link": "all" }, "Editor": { "notification": "all", diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index cb2d0dfbde0..567b9228cc6 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -435,12 +435,24 @@ module.exports = { validations: {isIn: [['public', 'none']]} }, trial_days: {type: 'integer', unsigned: true, nullable: false, defaultTo: 0}, - monthly_price_id: {type: 'string', maxlength: 24, nullable: true}, - yearly_price_id: {type: 'string', maxlength: 24, nullable: true}, description: {type: 'string', maxlength: 191, nullable: true}, - type: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'paid', validations: {isIn: [['paid', 'free']]}}, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'paid', + validations: { + isIn: [['paid', 'free']] + } + }, + currency: {type: 'string', maxlength: 50, nullable: true}, + monthly_price: {type: 'integer', unsigned: true, nullable: true}, + yearly_price: {type: 'integer', unsigned: true, nullable: true}, created_at: {type: 'dateTime', nullable: false}, - updated_at: {type: 'dateTime', nullable: true} + updated_at: {type: 'dateTime', nullable: true}, + // To be removed in future + monthly_price_id: {type: 'string', maxlength: 24, nullable: true}, + yearly_price_id: {type: 'string', maxlength: 24, nullable: true} }, offers: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, @@ -618,7 +630,7 @@ module.exports = { } }, member_id: {type: 'string', maxlength: 24, nullable: false, unique: false, references: 'members.id', cascadeDelete: true}, - tier_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'products.id'}, + tier_id: {type: 'string', maxlength: 24, nullable: false, unique: false, references: 'products.id'}, // These are null if type !== 'paid' cadence: { @@ -645,6 +657,7 @@ module.exports = { members_stripe_customers_subscriptions: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, customer_id: {type: 'string', maxlength: 255, nullable: false, unique: false, references: 'members_stripe_customers.customer_id', cascadeDelete: true}, + ghost_subscription_id: {type: 'string', maxlength: 24, nullable: true, references: 'subscriptions.id', constraintName: 'mscs_ghost_subscription_id_foreign', cascadeDelete: true}, subscription_id: {type: 'string', maxlength: 255, nullable: false, unique: true}, stripe_price_id: {type: 'string', maxlength: 255, nullable: false, unique: false, index: true, defaultTo: ''}, status: {type: 'string', maxlength: 50, nullable: false}, diff --git a/ghost/core/core/server/models/base/plugins/actions.js b/ghost/core/core/server/models/base/plugins/actions.js index 2eca8847258..d76285c8541 100644 --- a/ghost/core/core/server/models/base/plugins/actions.js +++ b/ghost/core/core/server/models/base/plugins/actions.js @@ -72,7 +72,7 @@ module.exports = function (Bookshelf) { * * We protect adding too many and uncontrolled events. * - * We could embedd adding actions more nicely in the future e.g. plugin. + * We could embed adding actions more nicely in the future e.g. plugin. */ addAction: (model, event, options) => { if (!model.wasChanged()) { diff --git a/ghost/core/core/server/models/email-recipient.js b/ghost/core/core/server/models/email-recipient.js index 894094923ef..46b9e594430 100644 --- a/ghost/core/core/server/models/email-recipient.js +++ b/ghost/core/core/server/models/email-recipient.js @@ -3,6 +3,20 @@ const ghostBookshelf = require('./base'); const EmailRecipient = ghostBookshelf.Model.extend({ tableName: 'email_recipients', hasTimestamps: false, + + filterRelations: function filterRelations() { + return { + email: { + // Mongo-knex doesn't support belongsTo relations + tableName: 'emails', + tableNameAs: 'email', + type: 'manyToMany', + joinTable: 'email_recipients', + joinFrom: 'id', + joinTo: 'email_id' + } + }; + }, email() { return this.belongsTo('Email', 'email_id'); diff --git a/ghost/core/core/server/models/email.js b/ghost/core/core/server/models/email.js index 0d49e2ccb05..a0788b8fe69 100644 --- a/ghost/core/core/server/models/email.js +++ b/ghost/core/core/server/models/email.js @@ -81,11 +81,7 @@ const Email = ghostBookshelf.Model.extend({ model.emitChange('deleted', options); } -}, { - post() { - return this.belongsTo('Post'); - } -}); +}, {}); const Emails = ghostBookshelf.Collection.extend({ model: Email diff --git a/ghost/core/core/server/models/member-click-event.js b/ghost/core/core/server/models/member-click-event.js index 7c86860ff8c..29250c625f5 100644 --- a/ghost/core/core/server/models/member-click-event.js +++ b/ghost/core/core/server/models/member-click-event.js @@ -10,7 +10,31 @@ const MemberClickEvent = ghostBookshelf.Model.extend({ member() { return this.belongsTo('Member', 'member_id', 'id'); + }, + + filterExpansions: function filterExpansions() { + const expansions = [{ + key: 'post_id', + replacement: 'link.post_id' + }]; + + return expansions; + }, + + filterRelations() { + return { + link: { + // Mongo-knex doesn't support belongsTo relations + tableName: 'redirects', + tableNameAs: 'link', + type: 'manyToMany', + joinTable: 'members_click_events', + joinFrom: 'id', + joinTo: 'redirect_id' + } + }; } + }, { async edit() { throw new errors.IncorrectUsageError({message: 'Cannot edit MemberClickEvent'}); diff --git a/ghost/core/core/server/models/member-paid-subscription-event.js b/ghost/core/core/server/models/member-paid-subscription-event.js index 7769d6823d1..d3315648123 100644 --- a/ghost/core/core/server/models/member-paid-subscription-event.js +++ b/ghost/core/core/server/models/member-paid-subscription-event.js @@ -25,6 +25,21 @@ const MemberPaidSubscriptionEvent = ghostBookshelf.Model.extend({ .groupByRaw('currency, DATE(created_at)') .orderByRaw('DATE(created_at)'); } + }, + + filterRelations() { + return { + subscriptionCreatedEvent: { + // Mongo-knex doesn't support belongsTo relations + tableName: 'members_subscription_created_events', + tableNameAs: 'subscriptionCreatedEvent', + type: 'manyToMany', + joinTable: 'members_paid_subscription_events', + joinFrom: 'id', + joinToForeign: 'subscription_id', + joinTo: 'subscription_id' + } + }; } }, { permittedOptions(methodName) { diff --git a/ghost/core/core/server/models/member.js b/ghost/core/core/server/models/member.js index 5e88a418cd0..741bc424d24 100644 --- a/ghost/core/core/server/models/member.js +++ b/ghost/core/core/server/models/member.js @@ -114,6 +114,12 @@ const Member = ghostBookshelf.Model.extend({ joinTable: 'email_recipients', joinFrom: 'member_id', joinTo: 'email_id' + }, + feedback: { + tableName: 'members_feedback', + tableNameAs: 'feedback', + type: 'oneToOne', + joinFrom: 'member_id' } }; }, diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index 64bc9509cff..bcccf85ded0 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -236,6 +236,17 @@ Post = ghostBookshelf.Model.extend({ }, orderRawQuery: function orderRawQuery(field, direction, withRelated) { + if (field === 'sentiment') { + if (withRelated.includes('count.sentiment')) { + // Internally sentiment can be included via the count.sentiment relation. We can do a quick optimisation of the query in that case. + return { + orderByRaw: `count__sentiment ${direction}` + }; + } + return { + orderByRaw: `(select AVG(score) from \`members_feedback\` where posts.id = members_feedback.post_id) ${direction}` + }; + } if (field === 'email.open_rate' && withRelated && withRelated.indexOf('email') > -1) { return { // *1.0 is needed on one of the columns to prevent sqlite from @@ -1346,6 +1357,26 @@ Post = ghostBookshelf.Model.extend({ .as('count__paid_conversions'); }); }, + /** + * Combination of sigups and paid conversions, but unique per member + */ + conversions(modelOrCollection) { + modelOrCollection.query('columns', 'posts.*', (qb) => { + qb.count('*') + .from('k') + .with('k', (q) => { + q.select('member_id') + .from('members_subscription_created_events') + .whereRaw('posts.id = members_subscription_created_events.attribution_id') + .union(function () { + this.select('member_id') + .from('members_created_events') + .whereRaw('posts.id = members_created_events.attribution_id'); + }); + }) + .as('count__conversions'); + }); + }, clicks(modelOrCollection) { modelOrCollection.query('columns', 'posts.*', (qb) => { qb.countDistinct('members_click_events.member_id') @@ -1357,7 +1388,7 @@ Post = ghostBookshelf.Model.extend({ }, sentiment(modelOrCollection) { modelOrCollection.query('columns', 'posts.*', (qb) => { - qb.select(qb.client.raw('ROUND(AVG(score) * 100)')) + qb.select(qb.client.raw('COALESCE(ROUND(AVG(score) * 100), 0)')) .from('members_feedback') .whereRaw('posts.id = members_feedback.post_id') .as('count__sentiment'); @@ -1368,7 +1399,7 @@ Post = ghostBookshelf.Model.extend({ qb.count('*') .from('members_feedback') .whereRaw('posts.id = members_feedback.post_id AND members_feedback.score = 0') - .as('count__positive_feedback'); + .as('count__negative_feedback'); }); }, positive_feedback(modelOrCollection) { diff --git a/ghost/core/core/server/models/redirect.js b/ghost/core/core/server/models/redirect.js index 7a58e58278c..b857c3e1a3c 100644 --- a/ghost/core/core/server/models/redirect.js +++ b/ghost/core/core/server/models/redirect.js @@ -53,6 +53,7 @@ const Redirect = ghostBookshelf.Model.extend({ qb.countDistinct('members_click_events.member_id') .from('members_click_events') .whereRaw('redirects.id = members_click_events.redirect_id') + .whereRaw('redirects.updated_at <= members_click_events.created_at') .as('count__clicks'); }); } diff --git a/ghost/core/core/server/services/audience-feedback/index.js b/ghost/core/core/server/services/audience-feedback/index.js index a111db6e5a7..39ea79bc178 100644 --- a/ghost/core/core/server/services/audience-feedback/index.js +++ b/ghost/core/core/server/services/audience-feedback/index.js @@ -1,3 +1,5 @@ +const urlUtils = require('../../../shared/url-utils'); +const urlService = require('../../services/url'); const FeedbackRepository = require('./FeedbackRepository'); class AudienceFeedbackServiceWrapper { @@ -20,7 +22,12 @@ class AudienceFeedbackServiceWrapper { }); // Expose the service - this.service = new AudienceFeedbackService(); + this.service = new AudienceFeedbackService({ + urlService, + config: { + baseURL: new URL(urlUtils.urlFor('home', true)) + } + }); this.controller = new AudienceFeedbackController({repository: this.repository}); } } diff --git a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js index 88514e5a711..10f67433213 100644 --- a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js +++ b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js @@ -222,6 +222,10 @@ module.exports = { const startTime = Date.now(); debug(`sending message to ${recipients.length} recipients`); + // Update email content for this segment before searching replacements + emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment); + + // Check all the used replacements in this email const replacements = postEmailSerializer.parseReplacements(emailData); // collate static and dynamic data for each recipient ready for provider @@ -245,8 +249,6 @@ module.exports = { recipientData[recipient.member_email] = data; }); - emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment); - try { const response = await mailgunClient.send(emailData, recipientData, replacements); debug(`sent message (${Date.now() - startTime}ms)`); diff --git a/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js b/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js index 83597462097..008d34cc391 100644 --- a/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js +++ b/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js @@ -18,7 +18,7 @@ module.exports = class LinkRedirectRepository { } /** - * @param {InstanceType} linkRedirect + * @param {InstanceType} linkRedirect * @returns {Promise} */ async save(linkRedirect) { @@ -36,10 +36,14 @@ module.exports = class LinkRedirectRepository { } fromModel(model) { + // Store if link has been edited + const edited = model.get('created_at')?.getTime() !== model.get('updated_at')?.getTime(); + return new LinkRedirect({ id: model.id, from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)), - to: new URL(model.get('to')) + to: new URL(model.get('to')), + edited }); } @@ -55,10 +59,17 @@ module.exports = class LinkRedirectRepository { return result; } + async getFilteredIds(options) { + const linkRows = await this.#LinkRedirect.getFilteredCollectionQuery(options) + .select('redirects.id') + .distinct(); + return linkRows.map(row => row.id); + } + /** - * - * @param {URL} url - * @returns {Promise|undefined>} linkRedirect + * + * @param {URL} url + * @returns {Promise|undefined>} linkRedirect */ async getByURL(url) { // Strip subdirectory from path diff --git a/ghost/core/core/server/services/link-tracking/PostLinkRepository.js b/ghost/core/core/server/services/link-tracking/PostLinkRepository.js index 46c08ba93ad..844a10028d6 100644 --- a/ghost/core/core/server/services/link-tracking/PostLinkRepository.js +++ b/ghost/core/core/server/services/link-tracking/PostLinkRepository.js @@ -1,4 +1,5 @@ const {FullPostLink} = require('@tryghost/link-tracking'); +const _ = require('lodash'); /** * @typedef {import('bson-objectid').default} ObjectID @@ -22,8 +23,8 @@ module.exports = class PostLinkRepository { } /** - * - * @param {*} options + * + * @param {*} options * @returns {Promise[]>} */ async getAll(options) { @@ -48,6 +49,29 @@ module.exports = class PostLinkRepository { return result; } + async updateLinks(linkIds, updateData, options) { + const bulkUpdateOptions = _.pick(options, ['transacting']); + + const bulkActionResult = await this.#LinkRedirect.bulkEdit(linkIds, 'redirects', { + ...bulkUpdateOptions, + data: updateData + }); + + return { + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: bulkActionResult.successful, + unsuccessful: bulkActionResult.unsuccessful + }, + errors: bulkActionResult.errors, + unsuccessfulData: bulkActionResult.unsuccessfulData + } + } + }; + } + /** * @param {PostLink} postLink * @returns {Promise} diff --git a/ghost/core/core/server/services/link-tracking/index.js b/ghost/core/core/server/services/link-tracking/index.js index 7a72c5a87d8..a3608eaa9f1 100644 --- a/ghost/core/core/server/services/link-tracking/index.js +++ b/ghost/core/core/server/services/link-tracking/index.js @@ -1,6 +1,7 @@ const LinkClickRepository = require('./LinkClickRepository'); const PostLinkRepository = require('./PostLinkRepository'); const errors = require('@tryghost/errors'); +const urlUtils = require('../../../shared/url-utils'); class LinkTrackingServiceWrapper { async init() { @@ -38,7 +39,8 @@ class LinkTrackingServiceWrapper { linkRedirectService: linkRedirection.service, linkClickRepository: this.linkClickRepository, postLinkRepository, - DomainEvents + DomainEvents, + urlUtils }); await this.service.init(); diff --git a/ghost/core/core/server/services/mega/email-preview.js b/ghost/core/core/server/services/mega/email-preview.js index b13b7d83471..608008656ce 100644 --- a/ghost/core/core/server/services/mega/email-preview.js +++ b/ghost/core/core/server/services/mega/email-preview.js @@ -32,7 +32,7 @@ class EmailPreview { replacements.forEach((replacement) => { emailContent[replacement.format] = emailContent[replacement.format].replace( - replacement.match, + replacement.regexp, replacement.fallback || '' ); }); diff --git a/ghost/core/core/server/services/mega/feedback-buttons.js b/ghost/core/core/server/services/mega/feedback-buttons.js new file mode 100644 index 00000000000..763cc38631a --- /dev/null +++ b/ghost/core/core/server/services/mega/feedback-buttons.js @@ -0,0 +1,69 @@ +const {Color} = require('@tryghost/color-utils'); +const audienceFeedback = require('../audience-feedback'); + +const templateStrings = { + like: '%{feedback_button_like}%', + dislike: '%{feedback_button_dislike}%' +}; + +const generateLinks = (postId, uuid, html) => { + const positiveLink = audienceFeedback.service.buildLink( + uuid, + postId, + 1 + ); + const negativeLink = audienceFeedback.service.buildLink( + uuid, + postId, + 0 + ); + + html = html.replace(templateStrings.like, positiveLink.href); + html = html.replace(templateStrings.dislike, negativeLink.href); + + return html; +}; + +const getTemplate = (accentColor) => { + const likeButtonHtml = getButtonHtml(templateStrings.like, 'More like this', accentColor); + const dislikeButtonHtml = getButtonHtml(templateStrings.dislike, 'Less like this', accentColor); + + return (` + + +

What did you think of this post?

+ + + ${likeButtonHtml} + ${dislikeButtonHtml} + +
+ + + `); +}; + +function getButtonHtml(href, buttonText, accentColor) { + const color = new Color(accentColor); + const bgColor = `${accentColor}10`; + const textColor = color.darken(0.6).hex(); + + return (` + + + + + +
+ + ${buttonText} + +
+ + `); +} + +module.exports = { + generateLinks, + getTemplate +}; diff --git a/ghost/core/core/server/services/mega/post-email-serializer.js b/ghost/core/core/server/services/mega/post-email-serializer.js index 87d1d3ad525..a194e340b4f 100644 --- a/ghost/core/core/server/services/mega/post-email-serializer.js +++ b/ghost/core/core/server/services/mega/post-email-serializer.js @@ -16,11 +16,13 @@ const urlService = require('../../services/url'); const linkReplacer = require('@tryghost/link-replacer'); const linkTracking = require('../link-tracking'); const memberAttribution = require('../member-attribution'); +const feedbackButtons = require('./feedback-buttons'); +const labs = require('../../../shared/labs'); const ALLOWED_REPLACEMENTS = ['first_name', 'uuid']; const PostEmailSerializer = { - + // Format a full html document ready for email by inlining CSS, adjusting links, // and performing any client-specific fixes formatHtmlForEmail(html) { @@ -107,6 +109,19 @@ const PostEmailSerializer = { return signupUrl.href; }, + /** + * replaceFeedbackLinks + * + * Replace the button template links with real links + * + * @param {string} html + * @param {string} postId (will be url encoded) + * @param {string} memberUuid member uuid to use in the URL (will be url encoded) + */ + replaceFeedbackLinks(html, postId, memberUuid) { + return feedbackButtons.generateLinks(postId, memberUuid, html); + }, + // NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute async serializePostModel(model) { // fetch mobiledoc rather than html and plaintext so we can render email-specific contents @@ -163,12 +178,21 @@ const PostEmailSerializer = { const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g; const REPLACEMENT_STRING_REGEX = /\{(?\w*?)(?:,? *(?:"|")(?.*?)(?:"|"))?\}/; + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + const replacements = []; ['html', 'plaintext'].forEach((format) => { let result; while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) { const [replacementMatch, replacementStr] = result; + + // Did we already found this match and added it to the replacements array? + if (replacements.find(r => r.match === replacementMatch && r.format === format)) { + continue; + } const match = replacementStr.match(REPLACEMENT_STRING_REGEX); if (match) { @@ -181,6 +205,7 @@ const PostEmailSerializer = { format, id, match: replacementMatch, + regexp: new RegExp(escapeRegExp(replacementMatch), 'g'), recipientProperty: `member_${recipientProperty}`, fallback }); @@ -206,6 +231,7 @@ const PostEmailSerializer = { titleAlignment: newsletter.get('title_alignment'), bodyFontCategory: newsletter.get('body_font_category'), showBadge: newsletter.get('show_badge'), + feedbackEnabled: newsletter.get('feedback_enabled') && labs.isSet('audienceFeedback'), footerContent: newsletter.get('footer_content'), showHeaderName: newsletter.get('show_header_name'), accentColor, @@ -335,7 +361,7 @@ const PostEmailSerializer = { plaintext: post.plaintext }; - /** + /** * If a part of the email is members-only and the post is paid-only, add a paywall: * - Just before sending the email, we'll hide the paywall or paid content depending on the member segment it is sent to. * - We already need to do URL-replacement on the HTML here @@ -369,13 +395,21 @@ const PostEmailSerializer = { // Add link click tracking url = await linkTracking.service.addTrackingToUrl(url, post, '--uuid--'); - + // We need to convert to a string at this point, because we need invalid string characters in the URL const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%'); return str; }); } + // Add buttons + if (labs.isSet('audienceFeedback')) { + // create unique urls for every recipient (for example, for feedback buttons) + // Note, we need to use a different member uuid in the links because `%%{uuid}%%` would get escaped by the URL object when set as a search param + const urlSafeToken = '--' + new Date().getTime() + 'url-safe-uuid--'; + result.html = this.replaceFeedbackLinks(result.html, post.id, urlSafeToken).replace(new RegExp(urlSafeToken, 'g'), '%%{uuid}%%'); + } + // Clean up any unknown replacements strings to get our final content const {html, plaintext} = this.normalizeReplacementStrings(result); const data = { @@ -490,7 +524,7 @@ const PostEmailSerializer = { }); result.html = this.formatHtmlForEmail($.html()); - result.plaintext = htmlToPlaintext.email(result.html); + result.plaintext = htmlToPlaintext.email(result.html); delete result.post; return result; diff --git a/ghost/core/core/server/services/mega/template.js b/ghost/core/core/server/services/mega/template.js index 42d6635e26d..d67bcc15d59 100644 --- a/ghost/core/core/server/services/mega/template.js +++ b/ghost/core/core/server/services/mega/template.js @@ -1,4 +1,5 @@ const {escapeHtml: escape} = require('@tryghost/string'); +const feedbackButtons = require('./feedback-buttons'); /* eslint indent: warn, no-irregular-whitespace: warn */ const iff = (cond, yes, no) => (cond ? yes : no); @@ -1265,6 +1266,8 @@ ${ templateSettings.showBadge ? ` + ${iff(templateSettings.feedbackEnabled, feedbackButtons.getTemplate(templateSettings.accentColor), '')} + diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 944999141bf..f3e1d01a9d5 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -194,7 +194,8 @@ function createApiInstance(config) { StripePrice: models.StripePrice, Product: models.Product, Settings: models.Settings, - Comment: models.Comment + Comment: models.Comment, + MemberFeedback: models.MemberFeedback }, stripeAPIService: stripeService.api, offersAPI: offersService.api, diff --git a/ghost/core/core/server/services/newsletters/index.js b/ghost/core/core/server/services/newsletters/index.js index 9f09b22576f..e9315c95619 100644 --- a/ghost/core/core/server/services/newsletters/index.js +++ b/ghost/core/core/server/services/newsletters/index.js @@ -4,6 +4,7 @@ const mail = require('../mail'); const models = require('../../models'); const urlUtils = require('../../../shared/url-utils'); const limitService = require('../limits'); +const labs = require('../../../shared/labs'); const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000; @@ -13,5 +14,6 @@ module.exports = new NewslettersService({ mail, singleUseTokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY), urlUtils, - limitService + limitService, + labs }); diff --git a/ghost/core/core/server/services/newsletters/service.js b/ghost/core/core/server/services/newsletters/service.js index 0d0bc89436f..0ffaf5a17eb 100644 --- a/ghost/core/core/server/services/newsletters/service.js +++ b/ghost/core/core/server/services/newsletters/service.js @@ -21,13 +21,16 @@ class NewslettersService { * @param {Object} options.singleUseTokenProvider * @param {Object} options.urlUtils * @param {ILimitService} options.limitService + * @param {Object} options.labs */ - constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService}) { + constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService, labs}) { this.NewsletterModel = NewsletterModel; this.MemberModel = MemberModel; this.urlUtils = urlUtils; /** @private */ this.limitService = limitService; + /** @private */ + this.labs = labs; /* email verification setup */ @@ -251,6 +254,13 @@ class NewslettersService { } } + if (cleanedAttrs.feedback_enabled) { + if (!this.labs.isSet('audienceFeedback')) { + // Not allowed to set to true + cleanedAttrs.feedback_enabled = false; + } + } + return {cleanedAttrs, emailsToVerify}; } diff --git a/ghost/core/core/server/services/url/UrlGenerator.js b/ghost/core/core/server/services/url/UrlGenerator.js index fc60f7097df..8d95dc86c05 100644 --- a/ghost/core/core/server/services/url/UrlGenerator.js +++ b/ghost/core/core/server/services/url/UrlGenerator.js @@ -135,7 +135,9 @@ class UrlGenerator { /** * @description Listener which get's called when a resource was added on runtime. - * @param {String} event + * @param {Object} event + * @param {String} event.type + * @param {String} event.id * @private */ _onAdded(event) { @@ -188,7 +190,7 @@ class UrlGenerator { } /** - * @description Generate url based on the permlink configuration of the target router. + * @description Generate url based on the permalink configuration of the target router. * * @NOTE We currently generate relative urls (https://github.com/TryGhost/Ghost/commit/7b0d5d465ba41073db0c3c72006da625fa11df32). */ diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 038d6950752..ea5cf098928 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -310,6 +310,7 @@ module.exports = function apiRoutes() { router.put('/newsletters/:id', mw.authAdminApi, http(api.newsletters.edit)); router.get('/links', mw.authAdminApi, http(api.links.browse)); + router.put('/links/bulk', mw.authAdminApi, http(api.links.bulkEdit)); return router; }; diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 1afb191c5cd..f0cf01b154f 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -158,7 +158,7 @@ }, "portal": { "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js", - "version": "2.14" + "version": "2.18" }, "sodoSearch": { "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js", diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 7464068269a..02ce9685a26 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -19,7 +19,8 @@ const GA_FEATURES = [ 'freeTrial', 'compExpiring', 'searchHelper', - 'emailAlerts' + 'emailAlerts', + 'fixNewsletterLinks' ]; // NOTE: this allowlist is meant to be used to filter out any unexpected @@ -35,8 +36,7 @@ const ALPHA_FEATURES = [ 'beforeAfterCard', 'lexicalEditor', 'exploreApp', - 'audienceFeedback', - 'fixNewsletterLinks' + 'audienceFeedback' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/package.json b/ghost/core/package.json index 8f1e5f6bd6e..768b74c4479 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.18.0", + "version": "5.20.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -21,7 +21,7 @@ "license": "MIT", "scripts": { "start": "node index", - "setup": "knex-migrator init && grunt symlink || (exit 0)", + "setup": "knex-migrator init", "build": "npm pack --pack-destination ../..", "test": "yarn test:unit", "test:single": "mocha --require=./test/utils/overrides.js --exit --trace-warnings --recursive --extension=test.js --timeout=60000", @@ -59,14 +59,14 @@ "@tryghost/api-framework": "0.0.0", "@tryghost/api-version-compatibility-service": "0.0.0", "@tryghost/audience-feedback": "0.0.0", - "@tryghost/bookshelf-plugins": "0.5.2", + "@tryghost/bookshelf-plugins": "0.5.4", "@tryghost/bootstrap-socket": "0.0.0", "@tryghost/color-utils": "0.1.21", "@tryghost/config-url-helpers": "1.0.3", "@tryghost/constants": "0.0.0", "@tryghost/custom-theme-settings-service": "0.0.0", - "@tryghost/database-info": "0.3.11", - "@tryghost/debug": "0.1.18", + "@tryghost/database-info": "0.3.12", + "@tryghost/debug": "0.1.19", "@tryghost/domain-events": "0.0.0", "@tryghost/email-analytics-provider-mailgun": "0.0.0", "@tryghost/email-analytics-service": "0.0.0", @@ -78,11 +78,11 @@ "@tryghost/http-cache-utils": "0.1.4", "@tryghost/image-transform": "1.2.2", "@tryghost/job-manager": "0.0.0", - "@tryghost/kg-card-factory": "3.1.5", + "@tryghost/kg-card-factory": "3.1.7", "@tryghost/kg-default-atoms": "3.1.4", - "@tryghost/kg-default-cards": "5.18.3", - "@tryghost/kg-lexical-html-renderer": "0.0.8", - "@tryghost/kg-mobiledoc-html-renderer": "5.3.7", + "@tryghost/kg-default-cards": "5.18.5", + "@tryghost/kg-lexical-html-renderer": "0.0.10", + "@tryghost/kg-mobiledoc-html-renderer": "5.3.9", "@tryghost/limit-service": "1.2.3", "@tryghost/link-redirects": "0.0.0", "@tryghost/link-replacer": "0.0.0", @@ -120,7 +120,7 @@ "@tryghost/staff-service": "0.0.0", "@tryghost/stats-service": "0.0.0", "@tryghost/string": "0.2.1", - "@tryghost/tpl": "0.1.18", + "@tryghost/tpl": "0.1.19", "@tryghost/update-check-service": "0.0.0", "@tryghost/url-utils": "4.2.0", "@tryghost/validator": "0.1.29", @@ -185,22 +185,22 @@ "xml": "1.0.1" }, "optionalDependencies": { - "@tryghost/html-to-mobiledoc": "1.8.16", + "@tryghost/html-to-mobiledoc": "1.8.19", "sqlite3": "5.1.2" }, "devDependencies": { "@playwright/test": "1.27.1", "@tryghost/express-test": "0.11.5", - "@tryghost/webhook-mock-receiver": "0.2.0", + "@tryghost/webhook-mock-receiver": "0.2.1", "@types/common-tags": "1.8.1", "c8": "7.12.0", "cli-progress": "3.11.2", "cssnano": "5.1.13", - "eslint": "8.25.0", - "html-validate": "7.6.0", - "inquirer": "8.2.4", + "eslint": "8.26.0", + "html-validate": "7.7.0", + "inquirer": "8.2.5", "jwks-rsa": "2.1.5", - "mocha": "10.0.0", + "mocha": "10.1.0", "mocha-slow-test-reporter": "0.1.2", "mock-knex": "TryGhost/mock-knex#8ecb8c227bf463c991c3d820d33f59efc3ab9682", "nock": "13.2.9", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap new file mode 100644 index 00000000000..7d1bf8e8cab --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -0,0 +1,750 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Activity Feed API Can filter events by post id 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": "20", + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 15, + }, + }, +} +`; + +exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "23031", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Can limit events 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": "2", + "next": null, + "page": null, + "pages": 8, + "prev": null, + "total": 15, + }, + }, +} +`; + +exports[`Activity Feed API Can limit events 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1240", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns click events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/0", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "member1@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Mr Egg", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "HTML Ipsum", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/1", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "member2@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": null, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Ghostly Kitchen Sink", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/2", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Egon Spengler", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Short and Sweet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/3", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "trialing@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Ray Stantz", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not finished yet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/4", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "comped@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Vinz Clortho", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not so short, bit complex", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/5", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "vip@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Winston Zeddemore", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/6", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "vip-paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Peter Venkman", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a draft static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/7", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "with-product@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Dana Barrett", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a scheduled post!!", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, +} +`; + +exports[`Activity Feed API Returns click events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "3722", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns comments in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 2, + }, + }, +} +`; + +exports[`Activity Feed API Returns comments in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1238", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns email delivered events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 1, + }, + }, +} +`; + +exports[`Activity Feed API Returns email delivered events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1246", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns email opened events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 1, + }, + }, +} +`; + +exports[`Activity Feed API Returns email opened events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1243", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns email sent events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 5, + }, + }, +} +`; + +exports[`Activity Feed API Returns email sent events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "5774", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns feedback events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "member1@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Mr Egg", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "HTML Ipsum", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "member2@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": null, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Ghostly Kitchen Sink", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Egon Spengler", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Short and Sweet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "trialing@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Ray Stantz", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not finished yet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "comped@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Vinz Clortho", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not so short, bit complex", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "vip@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Winston Zeddemore", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "vip-paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Peter Venkman", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a draft static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "with-product@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Dana Barrett", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a scheduled post!!", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, +} +`; + +exports[`Activity Feed API Returns feedback events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "3690", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns signup events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, +} +`; + +exports[`Activity Feed API Returns signup events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "23027", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap index c8989d40bb2..677ab84a1ba 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap @@ -416,6 +416,8 @@ table.body figcaption a { + +
@@ -468,7 +470,7 @@ exports[`Email Preview API Read can read post email preview with email card and Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18188", + "content-length": "18216", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -806,6 +808,8 @@ table.body figcaption a { + +
@@ -870,7 +874,7 @@ exports[`Email Preview API Read can read post email preview with fields 2: [head Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "23013", + "content-length": "23041", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1234,6 +1238,8 @@ table.body figcaption a { + +
@@ -1280,7 +1286,7 @@ exports[`Email Preview API Read has custom content transformations for email com Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "17950", + "content-length": "17978", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1618,6 +1624,8 @@ table.body figcaption a { + +
@@ -1664,7 +1672,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18316", + "content-length": "18344", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2388,6 +2396,8 @@ table.body figcaption a { + +
@@ -2434,7 +2444,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 2: [headers Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18316", + "content-length": "18344", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap new file mode 100644 index 00000000000..13cbb6dae69 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap @@ -0,0 +1,572 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Links API Can browse all links 1: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can browse all links 2: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can browse all links 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "930", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can browse all links 3: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "885", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update links with external redirect 1: [body] 1`] = ` +Object { + "bulk": Object { + "action": "updateLink", + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 1, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Links API Can bulk update links with external redirect 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "117", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update links with external redirect 3: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": true, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "https://example.com/subscribe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update links with external redirect 4: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "https://example.com/subscribe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update links with external redirect 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "929", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update links with external redirect 5: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "885", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 1: [body] 1`] = ` +Object { + "bulk": Object { + "action": "updateLink", + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 2, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "117", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 3: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": true, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": true, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 4: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "884", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 5: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "841", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can call bulk update link with 0 matches 1: [body] 1`] = ` +Object { + "bulk": Object { + "action": "updateLink", + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 0, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Links API Can call bulk update link with 0 matches 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "117", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can call bulk update link with 0 matches 3: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "https://example.com/subscripe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can call bulk update link with 0 matches 4: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "https://example.com/subscripe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can call bulk update link with 0 matches 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "930", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can call bulk update link with 0 matches 5: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "885", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap index 25e7262a360..253fcf6d495 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Members API — exportCSV Can export a member without products 1: [headers] 1`] = ` +exports[`Members API — exportCSV Can export a member without tiers 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -13,7 +13,7 @@ Object { } `; -exports[`Members API — exportCSV Can export a member without products 2: [headers] 1`] = ` +exports[`Members API — exportCSV Can export a member without tiers 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -26,7 +26,7 @@ Object { } `; -exports[`Members API — exportCSV Can export a member without products 3: [headers] 1`] = ` +exports[`Members API — exportCSV Can export a member without tiers 3: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -234,7 +234,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 1: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -247,7 +247,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 2: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -260,7 +260,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 3: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 3: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -273,7 +273,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 4: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap index 8f875f54553..75bd11156e7 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap @@ -63,7 +63,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2088", + "content-length": "2145", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -218,7 +218,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "11872", + "content-length": "12043", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -289,7 +289,7 @@ exports[`Members API - With Newsletters Can fetch members who are NOT subscribed Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2088", + "content-length": "2145", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -444,7 +444,7 @@ exports[`Members API - With Newsletters Can fetch members who are subscribed 2: Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "11872", + "content-length": "12043", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index dac852d6e5a..9d56a0e19ee 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -274,6 +274,12 @@ Object { "type": Any, }, ], + "meta": Object { + "pagination": Object { + "limit": 10, + "total": 5, + }, + }, } `; @@ -281,7 +287,56 @@ exports[`Members API - member attribution Returns sign up attributions in activi Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "9204", + "content-length": "9249", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API - member attribution Returns sign up attributions of all types in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 5, + }, + }, +} +`; + +exports[`Members API - member attribution Returns sign up attributions of all types in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "9295", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1307,9 +1362,11 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", "slug": "default-product", @@ -1318,6 +1375,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, "yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, ], @@ -1332,7 +1390,7 @@ exports[`Members API Can add complimentary subscription (out of date) 4: [header Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2923", + "content-length": "3037", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1529,7 +1587,7 @@ exports[`Members API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "13859", + "content-length": "14087", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1699,9 +1757,11 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", "slug": "default-product", @@ -1710,6 +1770,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, "yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, ], @@ -1724,7 +1785,7 @@ exports[`Members API Can create a member with an existing complimentary subscrip Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2974", + "content-length": "3088", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -1803,7 +1864,7 @@ exports[`Members API Can create a member with an existing paid subscription 2: [ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2960", + "content-length": "3074", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -1874,9 +1935,11 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", "slug": "default-product", @@ -1885,6 +1948,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, "yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, ], @@ -1899,7 +1963,7 @@ exports[`Members API Can create a new member with a product (complimentary) 2: [ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2621", + "content-length": "2735", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -2324,7 +2388,7 @@ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", "content-disposition": Any, - "content-length": "215", + "content-length": "212", "content-type": "text/csv; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2485,7 +2549,7 @@ exports[`Members API Can filter by paid status 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "10103", + "content-length": "10331", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2615,7 +2679,7 @@ exports[`Members API Can filter by signup attribution 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4417", + "content-length": "4474", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2790,7 +2854,7 @@ exports[`Members API Can filter on newsletter slug 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "8826", + "content-length": "8940", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2995,7 +3059,7 @@ exports[`Members API Can filter on tier slug 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "21167", + "content-length": "22079", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -3179,7 +3243,7 @@ exports[`Members API Can ignore any unknown includes 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "10103", + "content-length": "10331", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -3993,7 +4057,7 @@ exports[`Members API Can subscribe to a newsletter 5: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "5053", + "content-length": "5144", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -4650,7 +4714,7 @@ exports[`Members API Search for paid members retrieves member with email paid@te Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2528", + "content-length": "2585", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap index 2e2c460fb73..f07b2ea5a61 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap @@ -21,10 +21,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -50,6 +49,7 @@ Object { "primary_author": Any, "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "sentiment": 0, "slug": "scheduled-post", "status": "scheduled", "tags": Any, @@ -71,10 +71,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -102,6 +101,7 @@ Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac tu "primary_author": Any, "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "sentiment": 0, "slug": "unfinished", "status": "draft", "tags": Any, @@ -123,7 +123,7 @@ exports[`Posts API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "9998", + "content-length": "10236", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -152,10 +152,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -185,6 +184,7 @@ Object { "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "reading_time": 0, + "sentiment": 0, "slug": "scheduled-post", "status": "scheduled", "tags": Any, @@ -206,10 +206,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -259,6 +258,7 @@ Header Level 3 "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "reading_time": 1, + "sentiment": 0, "slug": "unfinished", "status": "draft", "tags": Any, @@ -280,7 +280,7 @@ exports[`Posts API Can browse with formats 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "12864", + "content-length": "13102", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -299,10 +299,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -331,6 +330,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "lexical-test", "status": "draft", "tags": Any, @@ -352,7 +352,7 @@ exports[`Posts API Create Can create a post with lexical 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3795", + "content-length": "3914", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -372,10 +372,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -404,6 +403,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "mobiledoc-test", "status": "draft", "tags": Any, @@ -425,7 +425,7 @@ exports[`Posts API Create Can create a post with mobiledoc 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3611", + "content-length": "3730", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -546,10 +546,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -578,6 +577,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "lexical-update-test", "status": "draft", "tags": Any, @@ -599,7 +599,7 @@ exports[`Posts API Update Can update a post with lexical 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3746", + "content-length": "3865", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -619,10 +619,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -651,6 +650,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "lexical-update-test", "status": "draft", "tags": Any, @@ -672,7 +672,7 @@ exports[`Posts API Update Can update a post with lexical 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3743", + "content-length": "3862", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -692,10 +692,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -724,6 +723,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "mobiledoc-update-test", "status": "draft", "tags": Any, @@ -745,7 +745,7 @@ exports[`Posts API Update Can update a post with mobiledoc 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3556", + "content-length": "3675", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -765,10 +765,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -797,6 +796,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "mobiledoc-update-test", "status": "draft", "tags": Any, @@ -818,7 +818,7 @@ exports[`Posts API Update Can update a post with mobiledoc 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3553", + "content-length": "3672", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index 65ba2ea2fa1..e1393b18984 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -627,7 +627,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3450", + "content-length": "3478", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap index 6c22af9e006..d447c70c8ac 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap @@ -96,8 +96,8 @@ Object { "count": 1, "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, "negative_delta": 0, - "positive_delta": 1, - "signups": 1, + "positive_delta": 4, + "signups": 4, "tier": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, Object { diff --git a/ghost/core/test/e2e-api/admin/activity-feed.test.js b/ghost/core/test/e2e-api/admin/activity-feed.test.js new file mode 100644 index 00000000000..282b97686a2 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/activity-feed.test.js @@ -0,0 +1,243 @@ +const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework'); +const {anyEtag, anyObjectId, anyUuid, anyISODate, anyString, anyObject, anyNumber} = matchers; +const models = require('../../../core/server/models'); + +const assert = require('assert'); + +let agent; +describe('Activity Feed API', function () { + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments', 'redirects', 'clicks', 'feedback', 'members:emails'); + await agent.loginAsOwner(); + }); + + beforeEach(function () { + mockManager.mockStripe(); + mockManager.mockMail(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + // Activity feed + it('Returns comments in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:comment_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(2).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'comment_event'), 'Expected a comment event'); + assert(!body.events.find(e => e.type !== 'comment_event'), 'Expected only comment events'); + }); + }); + + it('Returns click events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:click_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(8).fill({ + type: anyString, + data: { + created_at: anyISODate, + member: { + id: anyObjectId, + uuid: anyUuid + }, + post: { + id: anyObjectId, + uuid: anyUuid, + url: anyString + } + } + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'click_event'), 'Expected a click event'); + assert(!body.events.find(e => e.type !== 'click_event'), 'Expected only click events'); + }); + }); + + it('Returns feedback events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:feedback_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(8).fill({ + type: anyString, + data: { + created_at: anyISODate, + id: anyObjectId, + member: { + id: anyObjectId, + uuid: anyUuid + }, + post: { + id: anyObjectId, + uuid: anyUuid, + url: anyString + }, + score: anyNumber + } + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'feedback_event'), 'Expected a feedback event'); + assert(!body.events.find(e => e.type !== 'feedback_event'), 'Expected only feedback events'); + }); + }); + + it('Returns signup events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:signup_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(8).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'signup_event'), 'Expected a signup event'); + assert(!body.events.find(e => e.type !== 'signup_event'), 'Expected only signup events'); + }); + }); + + it('Returns email sent events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:email_sent_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(5).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'email_sent_event'), 'Expected an email sent event'); + assert(!body.events.find(e => e.type !== 'email_sent_event'), 'Expected only email sent events'); + }); + }); + + it('Returns email delivered events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:email_delivered_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(1).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'email_delivered_event'), 'Expected an email delivered event'); + assert(!body.events.find(e => e.type !== 'email_delivered_event'), 'Expected only email delivered events'); + }); + }); + + it('Returns email opened events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:email_opened_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(1).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'email_opened_event'), 'Expected an email opened event'); + assert(!body.events.find(e => e.type !== 'email_opened_event'), 'Expected only email opened events'); + }); + }); + + it('Can filter events by post id', async function () { + const postId = fixtureManager.get('posts', 0).id; + + await agent + .get(`/members/events?filter=data.post_id:${postId}&limit=20`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(15).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post'); + + // Check all post_id event types are covered by this test + assert(body.events.find(e => e.type === 'click_event'), 'Expected a click event'); + assert(body.events.find(e => e.type === 'comment_event'), 'Expected a comment event'); + assert(body.events.find(e => e.type === 'feedback_event'), 'Expected a feedback event'); + assert(body.events.find(e => e.type === 'signup_event'), 'Expected a signup event'); + assert(body.events.find(e => e.type === 'subscription_event'), 'Expected a subscription event'); + assert(body.events.find(e => e.type === 'email_delivered_event'), 'Expected an email delivered event'); + assert(body.events.find(e => e.type === 'email_sent_event'), 'Expected an email sent event'); + assert(body.events.find(e => e.type === 'email_opened_event'), 'Expected an email opened event'); + + // Assert total is correct + assert.equal(body.meta.pagination.total, 15); + }); + }); + + it('Can limit events', async function () { + const postId = fixtureManager.get('posts', 0).id; + await agent + .get(`/members/events?filter=data.post_id:${postId}&limit=2`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(2).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post'); + + // Assert total is correct + assert.equal(body.meta.pagination.total, 15); + }); + }); +}); diff --git a/ghost/core/test/e2e-api/admin/links.test.js b/ghost/core/test/e2e-api/admin/links.test.js new file mode 100644 index 00000000000..6f4f325c099 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/links.test.js @@ -0,0 +1,228 @@ +const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework'); +const {anyObjectId, anyString, anyEtag, anyNumber} = matchers; + +const matchLink = { + post_id: anyObjectId, + link: { + link_id: anyObjectId, + from: anyString, + to: anyString, + edited: false + }, + count: { + clicks: anyNumber + } +}; + +async function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +describe('Links API', function () { + let agent; + beforeEach(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts', 'links'); + await agent.loginAsOwner(); + }); + + it('Can browse all links', async function () { + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: new Array(3).fill(matchLink) + }); + }); + + it('Can bulk update multiple links with same site redirect', async function () { + const req = await agent.get('links'); + const siteLink = req.body.links.find((link) => { + return link.link.to.includes('/email/'); + }); + const postId = siteLink.post_id; + const originalTo = siteLink.link.to; + const filter = `post_id:${postId}+to:'${originalTo}'`; + // Sleep ensures the updated time of the link is different than created + await sleep(1000); + await agent + .put(`links/bulk/?filter=${encodeURIComponent(filter)}`) + .body({ + bulk: { + action: 'updateLink', + meta: { + link: { + to: 'http://127.0.0.1:2369/blog/emails/test?example=1' + } + } + } + }) + .expectStatus(200) + .matchBodySnapshot({ + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: 2, + unsuccessful: 0 + }, + errors: [], + unsuccessfulData: [] + } + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: [ + matchLink, + { + ...matchLink, + link: { + ...matchLink.link, + to: 'http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df', + edited: true + } + }, + { + ...matchLink, + link: { + ...matchLink.link, + to: 'http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df', + edited: true + } + } + ] + }); + }); + + it('Can bulk update links with external redirect', async function () { + const req = await agent.get('links'); + const siteLink = req.body.links.find((link) => { + return link.link.to.includes('subscripe'); + }); + const postId = siteLink.post_id; + const originalTo = siteLink.link.to; + const filter = `post_id:${postId}+to:'${originalTo}'`; + await sleep(1000); + await agent + .put(`links/bulk/?filter=${encodeURIComponent(filter)}`) + .body({ + bulk: { + action: 'updateLink', + meta: { + link: { + to: 'https://example.com/subscribe?ref=Test-newsletter' + } + } + } + }) + .expectStatus(200) + .matchBodySnapshot({ + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: 1, + unsuccessful: 0 + }, + errors: [], + unsuccessfulData: [] + } + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: [ + { + ...matchLink, + link: { + ...matchLink.link, + to: 'https://example.com/subscribe?ref=Test-newsletter', + edited: true + } + }, + matchLink, + matchLink + ] + }); + }); + + it('Can call bulk update link with 0 matches', async function () { + const req = await agent.get('links'); + const siteLink = req.body.links.find((link) => { + return link.link.to.includes('subscripe'); + }); + const postId = siteLink.post_id; + const originalTo = 'https://empty.example.com'; + const filter = `post_id:${postId}+to:'${originalTo}'`; + await agent + .put(`links/bulk/?filter=${encodeURIComponent(filter)}`) + .body({ + bulk: { + action: 'updateLink', + meta: { + link: { + to: 'https://example.com/subscribe?ref=Test-newsletter' + } + } + } + }) + .expectStatus(200) + .matchBodySnapshot({ + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: 0, + unsuccessful: 0 + }, + errors: [], + unsuccessfulData: [] + } + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: [ + { + ...matchLink, + link: { + ...matchLink.link, + to: 'https://example.com/subscripe?ref=Test-newsletter' + } + }, + matchLink, + matchLink + ] + }); + }); +}); diff --git a/ghost/core/test/e2e-api/admin/members-exporter.test.js b/ghost/core/test/e2e-api/admin/members-exporter.test.js index ecd112d04c5..7902a54aa60 100644 --- a/ghost/core/test/e2e-api/admin/members-exporter.test.js +++ b/ghost/core/test/e2e-api/admin/members-exporter.test.js @@ -51,7 +51,7 @@ async function testOutput(member, asserts, filters = []) { 'content-disposition': anyString }); - res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,products/); + res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/); let csv = Papa.parse(res.text, {header: true}); let row = csv.data.find(r => r.id === member.id); @@ -72,8 +72,8 @@ describe('Members API — exportCSV', function () { await agent.loginAsOwner(); await models.Product.add({ - name: 'Extra Paid Product', - slug: 'extra-product', + name: 'Extra Paid Tier', + slug: 'extra-tier', type: 'paid', active: true, visibility: 'public' @@ -106,8 +106,8 @@ describe('Members API — exportCSV', function () { mockManager.restore(); }); - it('Can export products', async function () { - // Create a new member with a product + it('Can export tiers', async function () { + // Create a new member with a product (to be renamed to "tiers" once the changes is done on model layer) const member = await createMember({ name: 'Test member', products: tiers @@ -119,11 +119,11 @@ describe('Members API — exportCSV', function () { basicAsserts(member, row); should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); - should(row.products.split(',').sort().join(',')).eql(tiersList); - }, [`filter=products:${tiers[0].get('slug')}`, 'filter=subscribed:false']); + should(row.tiers.split(',').sort().join(',')).eql(tiersList); + }, [`filter=tier:[${tiers[0].get('slug')}]`, 'filter=subscribed:false']); }); - it('Can export a member without products', async function () { + it('Can export a member without tiers', async function () { // Create a new member with a product const member = await createMember({ name: 'Test member 2', @@ -134,7 +134,7 @@ describe('Members API — exportCSV', function () { basicAsserts(member, row); should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); }, ['filter=subscribed:false']); }); @@ -157,7 +157,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); should(row.labels).eql(labelsList); - should(row.products).eql(''); + should(row.tiers).eql(''); }, [`filter=label:${labels[0].get('slug')}`, 'filter=subscribed:false']); }); @@ -174,7 +174,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql('true'); should(row.labels).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); }, ['filter=status:comped', 'filter=subscribed:false']); }); @@ -193,7 +193,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('true'); should(row.complimentary_plan).eql(''); should(row.labels).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); }, ['filter=subscribed:true']); }); @@ -232,7 +232,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); should(row.labels).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); should(row.stripe_customer_id).eql('cus_12345'); }, ['filter=subscribed:false', 'filter=subscriptions.subscription_id:sub_123']); }); diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index 4a6cae97664..0d2ae0c082e 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -390,7 +390,7 @@ describe('Members API - member attribution', function () { }); // Activity feed - it('Returns sign up attributions in activity feed', async function () { + it('Returns sign up attributions of all types in activity feed', async function () { // Check activity feed await agent .get(`/members/events/?filter=type:signup_event`) @@ -431,56 +431,6 @@ describe('Members API', function () { mockManager.restore(); }); - // Activity feed - it('Returns comments in activity feed', async function () { - // Check activity feed - await agent - .get(`/members/events?filter=type:comment_event`) - .expectStatus(200) - .matchHeaderSnapshot({ - etag: anyEtag - }) - .matchBodySnapshot({ - events: new Array(2).fill({ - type: anyString, - data: anyObject - }) - }) - .expect(({body}) => { - should(body.events.find(e => e.type === 'comment_event')).not.be.undefined(); - }); - }); - - it('Returns click events in activity feed', async function () { - // Check activity feed - await agent - .get(`/members/events?filter=type:click_event`) - .expectStatus(200) - .matchHeaderSnapshot({ - etag: anyEtag - }) - .matchBodySnapshot({ - events: new Array(8).fill({ - type: anyString, - data: { - created_at: anyISODate, - member: { - id: anyObjectId, - uuid: anyUuid - }, - post: { - id: anyObjectId, - uuid: anyUuid, - url: anyString - } - } - }) - }) - .expect(({body}) => { - should(body.events.find(e => e.type === 'click_event')).not.be.undefined(); - }); - }); - // List Members it('Can browse', async function () { @@ -1519,7 +1469,7 @@ describe('Members API', function () { .expectStatus(200); const beforeMember = body2.members[0]; - assert.equal(beforeMember.tiers.length, 2, 'The member should have two products now'); + assert.equal(beforeMember.tiers.length, 2, 'The member should have two tiers now'); // Now try to remove only the complimentary one const compedPayload = { @@ -1801,6 +1751,9 @@ describe('Members API', function () { } } }, + { + type: 'signup_event' + }, { type: 'newsletter_event', data: { @@ -1811,9 +1764,6 @@ describe('Members API', function () { id: newsletters[0].id } } - }, - { - type: 'signup_event' } ]); @@ -2150,14 +2100,14 @@ describe('Members API', function () { 'content-disposition': anyString }); - res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,products/); + res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/); const csv = Papa.parse(res.text, {header: true}); should.exist(csv.data.find(row => row.name === 'Mr Egg')); should.exist(csv.data.find(row => row.name === 'Winston Zeddemore')); should.exist(csv.data.find(row => row.name === 'Ray Stantz')); should.exist(csv.data.find(row => row.email === 'member2@test.com')); - should.exist(csv.data.find(row => row.products.length > 0)); + should.exist(csv.data.find(row => row.tiers.length > 0)); should.exist(csv.data.find(row => row.labels.length > 0)); }); @@ -2171,14 +2121,14 @@ describe('Members API', function () { 'content-disposition': anyString }); - res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,products/); + res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/); const csv = Papa.parse(res.text, {header: true}); should.exist(csv.data.find(row => row.name === 'Mr Egg')); should.not.exist(csv.data.find(row => row.name === 'Egon Spengler')); should.not.exist(csv.data.find(row => row.name === 'Ray Stantz')); should.not.exist(csv.data.find(row => row.email === 'member2@test.com')); - // note that this member doesn't have products + // note that this member doesn't have tiers should.exist(csv.data.find(row => row.labels.length > 0)); }); diff --git a/ghost/core/test/e2e-api/admin/posts-legacy.test.js b/ghost/core/test/e2e-api/admin/posts-legacy.test.js index 345c7c50fee..24ae3eef2fa 100644 --- a/ghost/core/test/e2e-api/admin/posts-legacy.test.js +++ b/ghost/core/test/e2e-api/admin/posts-legacy.test.js @@ -112,7 +112,7 @@ describe('Posts API', function () { jsonResponse.posts[0], 'post', null, - ['authors', 'primary_author', 'email', 'tiers', 'newsletter', 'count'] + ['authors', 'primary_author', 'email', 'tiers', 'newsletter', 'count', 'sentiment'] ); localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); @@ -233,7 +233,7 @@ describe('Posts API', function () { should.exist(jsonResponse); should.exist(jsonResponse.posts); - localUtils.API.checkResponse(jsonResponse.posts[0], 'post', null, ['count']); + localUtils.API.checkResponse(jsonResponse.posts[0], 'post', null, ['count', 'sentiment']); jsonResponse.posts[0].authors[0].should.be.an.Object(); localUtils.API.checkResponse(jsonResponse.posts[0].authors[0], 'user'); diff --git a/ghost/core/test/e2e-api/admin/utils.js b/ghost/core/test/e2e-api/admin/utils.js index ff0841d9c54..a97b7ba0cdf 100644 --- a/ghost/core/test/e2e-api/admin/utils.js +++ b/ghost/core/test/e2e-api/admin/utils.js @@ -86,7 +86,8 @@ const expectedProperties = { 'email_only', 'tiers', 'newsletter', - 'count' + 'count', + 'sentiment' ], page: [ diff --git a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap index 8cff23befba..79df7855ee7 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap @@ -34,7 +34,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2952", + "content-length": "3066", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -76,7 +76,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2966", + "content-length": "3080", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -118,7 +118,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2856", + "content-length": "2970", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -160,7 +160,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3014", + "content-length": "3128", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -202,7 +202,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2980", + "content-length": "3094", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -244,7 +244,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2994", + "content-length": "3108", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -286,7 +286,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2908", + "content-length": "3022", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -328,7 +328,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent witho Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2856", + "content-length": "2970", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -372,6 +372,16 @@ Object { "type": Any, }, ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, } `; @@ -391,7 +401,7 @@ exports[`Members API Member attribution Returns subscription created attribution Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "14722", + "content-length": "14813", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -402,6 +412,16 @@ Object { exports[`Members API Member attribution empty initial activity feed 1: [body] 1`] = ` Object { "events": Array [], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 0, + "prev": null, + "total": 0, + }, + }, } `; @@ -409,7 +429,7 @@ exports[`Members API Member attribution empty initial activity feed 2: [headers] Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "13", + "content-length": "104", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-frontend/default_routes.test.js b/ghost/core/test/e2e-frontend/default_routes.test.js index c23e2bb1df7..0475ba16a35 100644 --- a/ghost/core/test/e2e-frontend/default_routes.test.js +++ b/ghost/core/test/e2e-frontend/default_routes.test.js @@ -326,7 +326,8 @@ describe('Default Frontend routing', function () { 'User-agent: *\n' + 'Sitemap: http://127.0.0.1:2369/sitemap.xml\nDisallow: /ghost/\n' + 'Disallow: /p/\n' + - 'Disallow: /email/\n' + 'Disallow: /email/\n' + + 'Disallow: /r/\n' ); }); diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap index ae13205bae0..796dff84693 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap @@ -130,3 +130,73 @@ Object { }, } `; + +exports[`member.* events member.edited event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`member.* events member.edited event is triggered 2: [body] 1`] = ` +Object { + "member": Object { + "current": Object { + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "testemail3@example.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Array [], + "last_seen_at": null, + "name": "Ghost", + "newsletters": Array [ + Object { + "body_font_category": "sans_serif", + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Default Newsletter", + "sender_email": null, + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "sans_serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], + "note": "test note3", + "status": "free", + "subscribed": true, + "subscriptions": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "previous": Object { + "name": "Test Member3", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap index 5d97d971833..4fb172eeb96 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap @@ -38,7 +38,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, ], @@ -87,7 +87,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, "primary_tag": null, @@ -99,8 +99,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -109,13 +111,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -124,6 +129,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -132,7 +138,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -141,6 +147,218 @@ Object { } `; +exports[`page.* events page.edited event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`page.* events page.edited event is triggered 2: [body] 1`] = ` +Object { + "page": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": null, + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + "primary_tag": null, + "published_at": null, + "slug": "updated-test-page", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "updated test page", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "slug": "testing-page-edited-webhook", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "testing page.edited webhook", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + exports[`page.* events page.scheduled event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", @@ -188,7 +406,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, ], @@ -197,9 +415,9 @@ Object { "codeinjection_head": null, "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { + "negative_feedback": 0, "paid_conversions": 0, "positive_feedback": 0, - "sentiment": 0, "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -251,7 +469,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, "primary_tag": null, @@ -263,8 +481,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -273,13 +493,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -288,6 +511,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -296,7 +520,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -307,8 +531,224 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + +exports[`page.* events page.unpublished event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`page.* events page.unpublished event is triggered 2: [body] 1`] = ` +Object { + "page": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": Object { + "opened_count": null, + }, + "excerpt": null, + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + "primary_tag": null, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "slug": "testing-page-unpublished-webhook", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "testing page.unpublished webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "status": "published", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -317,13 +757,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -332,6 +775,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap index b21281c08b6..7def1d301f6 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap @@ -102,8 +102,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -112,13 +114,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -127,6 +132,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -135,7 +141,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -220,7 +226,7 @@ Object { } `; -exports[`post.* events post.published event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.edited event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -230,7 +236,7 @@ Object { } `; -exports[`post.* events post.published event is triggered 2: [body] 1`] = ` +exports[`post.* events post.edited event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -277,10 +283,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -288,41 +294,20 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "excerpt": null, "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "html": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", "og_description": null, "og_image": null, "og_title": null, - "plaintext": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. - - -Header Level 2 - - 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - 2. Aliquam tincidunt mauris eu risus. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. - - -Header Level 3 - - * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - * Aliquam tincidunt mauris eu risus. - -#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -359,17 +344,18 @@ Header Level 3 "website": null, }, "primary_tag": null, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "webhookz", - "status": "published", + "published_at": null, + "slug": "testing-post-edited-webhook", + "status": "draft", "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -378,13 +364,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -393,27 +382,28 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "webhookz", + "title": "testing post.edited webhook - Updated", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, "previous": Object { - "published_at": null, - "status": "draft", "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -422,13 +412,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -437,16 +430,18 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], + "title": "testing post.edited webhook", "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, }, }, } `; -exports[`post.* events post.scheduled event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.published event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -456,7 +451,7 @@ Object { } `; -exports[`post.* events post.scheduled event is triggered 2: [body] 1`] = ` +exports[`post.* events post.published event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -503,10 +498,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -514,20 +509,41 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", "og_description": null, "og_image": null, "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -565,15 +581,18 @@ Object { }, "primary_tag": null, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "slug": "testing-post-scheduled-webhook", - "status": "scheduled", + "reading_time": 1, + "slug": "webhookz", + "status": "published", "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -582,13 +601,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -597,15 +619,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "Testing post.scheduled webhook", + "title": "webhookz", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -616,8 +639,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -626,13 +651,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -641,6 +669,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -650,7 +679,7 @@ Object { } `; -exports[`post.* events post.tag.attached event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.published.edited event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -660,7 +689,7 @@ Object { } `; -exports[`post.* events post.tag.attached event is triggered 2: [body] 1`] = ` +exports[`post.* events post.published.edited event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -707,10 +736,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -718,41 +747,20 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "excerpt": null, "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "html": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", "og_description": null, "og_image": null, "og_title": null, - "plaintext": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. - - -Header Level 2 - - 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - 2. Aliquam tincidunt mauris eu risus. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. - - -Header Level 3 - - * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - * Aliquam tincidunt mauris eu risus. - -#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -788,64 +796,19 @@ Header Level 3 "url": "http://127.0.0.1:2369/author/joe-bloggs/", "website": null, }, - "primary_tag": Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": Any, - "og_description": null, - "og_image": null, - "og_title": null, - "slug": Any, - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, - "visibility": Any, - }, - "published_at": null, - "reading_time": 1, - "slug": "test-post-tag-attached-webhook", - "status": "draft", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": Any, - "og_description": null, - "og_image": null, - "og_title": null, - "slug": Any, - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, - "visibility": Any, - }, - ], + "primary_tag": null, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "slug": "testing-post-published-edited-webhook", + "status": "published", + "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -854,13 +817,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -869,26 +835,28 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "test post tag attached webhook", + "title": "testing post published edited webhook - updated", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, "previous": Object { - "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -897,13 +865,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -912,15 +883,18 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], + "title": "testing post published edited webhook", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, }, }, } `; -exports[`post.* events post.tag.detached event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.scheduled event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -930,7 +904,7 @@ Object { } `; -exports[`post.* events post.tag.detached event is triggered 2: [body] 1`] = ` +exports[`post.* events post.scheduled event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -977,10 +951,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -988,41 +962,20 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "excerpt": null, "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "html": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", "og_description": null, "og_image": null, "og_title": null, - "plaintext": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. - - -Header Level 2 - - 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - 2. Aliquam tincidunt mauris eu risus. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. - - -Header Level 3 - - * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - * Aliquam tincidunt mauris eu risus. - -#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -1059,17 +1012,18 @@ Header Level 3 "website": null, }, "primary_tag": null, - "published_at": null, - "reading_time": 1, - "slug": "test-post-tag-detached-webhook", - "status": "draft", + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "slug": "testing-post-scheduled-webhook", + "status": "scheduled", "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -1078,13 +1032,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -1093,44 +1050,1024 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "test post tag detached webhook", + "title": "Testing post.scheduled webhook", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, "previous": Object { - "tags": Array [ + "published_at": null, + "status": "draft", + "tiers": Array [ Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, + "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, - "feature_image": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": Any, - "og_description": null, - "og_image": null, - "og_title": null, - "slug": Any, - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, - "visibility": Any, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, }, ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + +exports[`post.* events post.tag.attached event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.tag.attached event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": Object { + "accent_color": null, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "name": Any, + "og_description": null, + "og_image": null, + "og_title": null, + "slug": Any, + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "visibility": Any, + }, + "published_at": null, + "reading_time": 1, + "slug": "test-post-tag-attached-webhook", + "status": "draft", + "tags": Array [ + Object { + "accent_color": null, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "name": Any, + "og_description": null, + "og_image": null, + "og_title": null, + "slug": Any, + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "visibility": Any, + }, + ], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "test post tag attached webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + }, + }, +} +`; + +exports[`post.* events post.tag.detached event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.tag.detached event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": null, + "published_at": null, + "reading_time": 1, + "slug": "test-post-tag-detached-webhook", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "test post tag detached webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "tags": Array [ + Object { + "accent_color": null, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "name": Any, + "og_description": null, + "og_image": null, + "og_title": null, + "slug": Any, + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "visibility": Any, + }, + ], + }, + }, +} +`; + +exports[`post.* events post.unpublished event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.unpublished event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": Object { + "opened_count": null, + }, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": null, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 1, + "slug": "webhookz-2", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "webhookz", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "status": "published", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + +exports[`post.* events post.unscheduled event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.unscheduled event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": Object { + "opened_count": null, + }, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": null, + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": null, + "published_at": null, + "slug": "testing-post-unscheduled-webhook", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "Testing post.unscheduled webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "status": "scheduled", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, }, }, } diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap index 5f322ef0faa..192c0100520 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap @@ -33,7 +33,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "visibility": "public", }, "previous": Object {}, @@ -114,7 +114,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "visibility": "public", }, "previous": Object { diff --git a/ghost/core/test/e2e-webhooks/members.test.js b/ghost/core/test/e2e-webhooks/members.test.js index 9e17abf4ca8..6218b857b25 100644 --- a/ghost/core/test/e2e-webhooks/members.test.js +++ b/ghost/core/test/e2e-webhooks/members.test.js @@ -121,4 +121,50 @@ describe('member.* events', function () { } }); }); + + it('member.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/member-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'member.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('members/') + .body({ + members: [{ + name: 'Test Member3', + email: 'testemail3@example.com', + note: 'test note3' + }] + }) + .expectStatus(201); + + const id = res.body.members[0].id; + + await adminAPIAgent + .put('members/' + id) + .body({ + members: [{name: 'Ghost'}] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + member: { + current: buildMemberSnapshot(), + previous: { + updated_at: anyISODateTime + } + } + }); + }); }); \ No newline at end of file diff --git a/ghost/core/test/e2e-webhooks/pages.test.js b/ghost/core/test/e2e-webhooks/pages.test.js index cb64bd13ea2..644826eaac5 100644 --- a/ghost/core/test/e2e-webhooks/pages.test.js +++ b/ghost/core/test/e2e-webhooks/pages.test.js @@ -27,7 +27,8 @@ const buildAuthorSnapshot = (roles = false) => { const authorSnapshot = { last_seen: anyISODateTime, created_at: anyISODateTime, - updated_at: anyISODateTime + updated_at: anyISODateTime, + url: anyLocalURL }; if (roles) { @@ -120,6 +121,59 @@ describe('page.* events', function () { }); }); + it('page.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/page-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'page.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('pages/') + .body({ + pages: [ + { + title: 'testing page.edited webhook', + status: 'draft', + slug: 'testing-page-edited-webhook' + } + ] + }) + .expectStatus(201); + + const id = res.body.pages[0].id; + const updatedPage = res.body.pages[0]; + updatedPage.title = 'updated test page'; + updatedPage.slug = 'updated-test-page'; + + await adminAPIAgent + .put('pages/' + id) + .body({ + pages: [updatedPage] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + page: { + current: buildPageSnapshotWithTiers({ + published: false, + tiersCount: 2, + roles: true + }), + previous: buildPreviousPageSnapshotWithTiers(2) + } + }); + }); + it('page.scheduled event is triggered', async function () { const webhookURL = 'https://test-webhook-receiver.com/page-scheduled/'; await webhookMockReceiver.mock(webhookURL); @@ -171,4 +225,55 @@ describe('page.* events', function () { } }); }); + + it('page.unpublished event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/page-unpublished/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'page.unpublished', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('pages/') + .body({ + pages: [ + { + title: 'testing page.unpublished webhook', + status: 'published' + } + ] + }) + .expectStatus(201); + + const id = res.body.pages[0].id; + const previouslyPublishedPage = res.body.pages[0]; + previouslyPublishedPage.status = 'draft'; + + await adminAPIAgent + .put('pages/' + id) + .body({ + pages: [previouslyPublishedPage] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + page: { + current: buildPageSnapshotWithTiers({ + published: true, + tiersCount: 2, + roles: true + }), + previous: buildPreviousPageSnapshotWithTiers(2) + } + }); + }); }); diff --git a/ghost/core/test/e2e-webhooks/posts.test.js b/ghost/core/test/e2e-webhooks/posts.test.js index 6b98198284e..07b2c37a595 100644 --- a/ghost/core/test/e2e-webhooks/posts.test.js +++ b/ghost/core/test/e2e-webhooks/posts.test.js @@ -168,6 +168,59 @@ describe('post.* events', function () { }); }); + it('post.unpublished event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-unpublished/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.unpublished', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [ + { + title: 'webhookz', + status: 'published', + mobiledoc: fixtureManager.get('posts', 1).mobiledoc + } + ] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const updatedPost = res.body.posts[0]; + updatedPost.status = 'draft'; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [updatedPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: true, + tiersCount: 2 + }), + previous: buildPreviousPostSnapshotWithTiers({ + tiersCount: 2 + }) + } + }); + }); + it('post.added event is triggered', async function () { const webhookURL = 'https://test-webhook-receiver.com/post-added/'; await webhookMockReceiver.mock(webhookURL); @@ -298,6 +351,61 @@ describe('post.* events', function () { }); }); + it('post.unscheduled event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-unscheduled/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.unscheduled', + url: webhookURL + }); + + const published_at = moment().add(1, 'days').toISOString(); + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'Testing post.unscheduled webhook', + status: 'scheduled', + published_at: published_at + }] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const unrescheduledPost = res.body.posts[0]; + unrescheduledPost.status = 'draft'; + unrescheduledPost.published_at = null; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [unrescheduledPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: false, + tiersCount: 2 + }), + previous: { + published_at: anyISODateTime, + updated_at: anyISODateTime, + tiers: new Array(2).fill(tierSnapshot) + } + } + }); + }); + it('post.tag.attached event is triggered', async function () { const webhookURL = 'https://test-webhook-receiver.com/post-tag-attached/'; await webhookMockReceiver.mock(webhookURL); @@ -412,4 +520,103 @@ describe('post.* events', function () { } }); }); + + it('post.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'testing post.edited webhook', + status: 'draft' + }] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const updatedPost = res.body.posts[0]; + updatedPost.title = 'testing post.edited webhook - Updated'; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [updatedPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: false, + tiersCount: 2, + roles: true + }), + previous: buildPreviousPostSnapshotWithTiers({tiersCount: 2}) + } + }); + }); + + it('post.published.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-published-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.published.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'testing post published edited webhook', + status: 'published' + }] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const updatedPost = res.body.posts[0]; + updatedPost.title = 'testing post published edited webhook - updated'; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [updatedPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: true, + tiersCount: 2 + }), + previous: buildPreviousPostSnapshotWithTiers({ + tiersCount: 2 + }) + } + }); + }); }); diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js index 4865f31f0e3..8dfbd66464c 100644 --- a/ghost/core/test/integration/migrations/migration.test.js +++ b/ghost/core/test/integration/migrations/migration.test.js @@ -45,7 +45,7 @@ describe('Database Migration (special functions)', function () { const permissions = this.obj; // If you have to change this number, please add the relevant `havePermission` checks below - permissions.length.should.eql(106); + permissions.length.should.eql(108); permissions.should.havePermission('Export database', ['Administrator', 'DB Backup Integration']); permissions.should.havePermission('Import database', ['Administrator', 'DB Backup Integration']); @@ -179,6 +179,7 @@ describe('Database Migration (special functions)', function () { permissions.should.havePermission('Like comments', ['Administrator', 'Admin Integration']); permissions.should.havePermission('Unlike comments', ['Administrator', 'Admin Integration']); permissions.should.havePermission('Report comments', ['Administrator', 'Admin Integration']); + permissions.should.havePermission('Browse links', ['Administrator', 'Admin Integration']); }); describe('Populate', function () { diff --git a/ghost/core/test/integration/services/mega.test.js b/ghost/core/test/integration/services/mega.test.js index 395ff634cd6..7a46b500953 100644 --- a/ghost/core/test/integration/services/mega.test.js +++ b/ghost/core/test/integration/services/mega.test.js @@ -158,13 +158,13 @@ describe('MEGA', function () { // Do the actual replacements for the first member, so we don't have to worry about them anymore replacements.forEach((replacement) => { emailData[replacement.format] = emailData[replacement.format].replace( - replacement.match, + replacement.regexp, recipient[replacement.id] ); // Also force Mailgun format emailData[replacement.format] = emailData[replacement.format].replace( - `%recipient.${replacement.id}%`, + new RegExp(`%recipient.${replacement.id}%`, 'g'), recipient[replacement.id] ); }); @@ -190,6 +190,9 @@ describe('MEGA', function () { // Check if the link is a tracked link assert(href.includes('?m=' + memberUuid), href + ' is not tracked'); + // Check if this link is also present in the plaintext version (with the right replacements) + assert(emailData.plaintext.includes(href), href + ' is not present in the plaintext version'); + if (!firstLink) { firstLink = new URL(href); } diff --git a/ghost/core/test/regression/api/admin/members-importer.test.js b/ghost/core/test/regression/api/admin/members-importer.test.js index a6ea865bf44..cbeff1b0255 100644 --- a/ghost/core/test/regression/api/admin/members-importer.test.js +++ b/ghost/core/test/regression/api/admin/members-importer.test.js @@ -97,6 +97,7 @@ describe('Members Importer API', function () { .post(localUtils.API.getApiQuery(`members/upload/`)) .field('mapping[correo_electrpnico]', 'email') .field('mapping[nombre]', 'name') + .field('mapping[note]', 'note') .attach('membersfile', path.join(__dirname, '/../../../utils/fixtures/csv/members-with-mappings.csv')) .set('Origin', config.get('url')) .expect('Content-Type', /json/) @@ -135,7 +136,7 @@ describe('Members Importer API', function () { const importedMember1 = jsonResponse.members[0]; should(importedMember1.email).equal('member+mapped_1@example.com'); should(importedMember1.name).equal('Hannah'); - should(importedMember1.note).equal('no need to map me'); + should(importedMember1.note).equal('do map me'); importedMember1.subscribed.should.equal(true); importedMember1.comped.should.equal(false); importedMember1.subscriptions.should.not.be.undefined(); diff --git a/ghost/core/test/regression/api/admin/utils.js b/ghost/core/test/regression/api/admin/utils.js index 0c214b37efc..21c4e1c0cb3 100644 --- a/ghost/core/test/regression/api/admin/utils.js +++ b/ghost/core/test/regression/api/admin/utils.js @@ -66,7 +66,8 @@ const expectedProperties = { 'email_only', 'tiers', 'newsletter', - 'count' + 'count', + 'sentiment' ], user: [ 'id', diff --git a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap index 5e348005c4a..eb07d560ebb 100644 --- a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap +++ b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap @@ -70,7 +70,7 @@ Object { -