mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-23 19:02:29 +03:00
✨ Implemented duplicate post functionality (#16767)
refs: https://github.com/TryGhost/Team/issues/3139 https://github.com/TryGhost/Team/issues/3140 - Added duplicate post functionality to post list context menu - Currently only a single post can be duplicated at a time - Currently only enabled via the `Making it rain` flag - Added admin API endpoint to copy a post - `POST ghost/api/admin/posts/<post_id>/copy/` - Added admin API endpoint to copy a page - `POST ghost/api/admin/pages/<page_id>/copy/`
This commit is contained in:
parent
77d7b590bc
commit
59fe794b0c
@ -11,7 +11,7 @@
|
|||||||
{{#if this.shouldFeatureSelection }}
|
{{#if this.shouldFeatureSelection }}
|
||||||
<li>
|
<li>
|
||||||
<button class="mr2" type="button" {{on "click" this.featurePosts}}>
|
<button class="mr2" type="button" {{on "click" this.featurePosts}}>
|
||||||
<span>{{svg-jar "star" class="mb1"}}Feature</span>
|
<span>{{svg-jar "star" class="mb1 star"}}Feature</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
@ -35,6 +35,13 @@
|
|||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if this.session.user.isAdmin}}
|
{{#if this.session.user.isAdmin}}
|
||||||
|
{{#if this.canCopySelection}}
|
||||||
|
<li>
|
||||||
|
<button class="mr2" type="button" {{on "click" this.copyPosts}}>
|
||||||
|
<span>{{svg-jar "duplicate"}}Duplicate</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
<li>
|
<li>
|
||||||
<button class="mr2" type="button" {{on "click" this.deletePosts}}>
|
<button class="mr2" type="button" {{on "click" this.deletePosts}}>
|
||||||
<span class="red">{{svg-jar "trash"}}Delete</span>
|
<span class="red">{{svg-jar "trash"}}Delete</span>
|
||||||
|
@ -39,11 +39,16 @@ const messages = {
|
|||||||
tagAdded: {
|
tagAdded: {
|
||||||
single: 'Tag added successfully',
|
single: 'Tag added successfully',
|
||||||
multiple: 'Tag added successfully to {count} {type}s'
|
multiple: 'Tag added successfully to {count} {type}s'
|
||||||
|
},
|
||||||
|
duplicated: {
|
||||||
|
single: '{Type} duplicated successfully',
|
||||||
|
multiple: '{count} {type}s duplicated successfully'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class PostsContextMenu extends Component {
|
export default class PostsContextMenu extends Component {
|
||||||
@service ajax;
|
@service ajax;
|
||||||
|
@service feature;
|
||||||
@service ghostPaths;
|
@service ghostPaths;
|
||||||
@service session;
|
@service session;
|
||||||
@service infinity;
|
@service infinity;
|
||||||
@ -116,6 +121,11 @@ export default class PostsContextMenu extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async copyPosts() {
|
||||||
|
this.menu.performTask(this.copyPostsTask);
|
||||||
|
}
|
||||||
|
|
||||||
@task
|
@task
|
||||||
*addTagToPostsTask(tags) {
|
*addTagToPostsTask(tags) {
|
||||||
const updatedModels = this.selectionList.availableModels;
|
const updatedModels = this.selectionList.availableModels;
|
||||||
@ -366,6 +376,29 @@ export default class PostsContextMenu extends Component {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@task
|
||||||
|
*copyPostsTask() {
|
||||||
|
try {
|
||||||
|
const result = yield this.performCopy();
|
||||||
|
|
||||||
|
// Add to the store and retrieve model
|
||||||
|
this.store.pushPayload(result);
|
||||||
|
|
||||||
|
const data = result[this.type === 'post' ? 'posts' : 'pages'][0];
|
||||||
|
const model = this.store.peekRecord(this.type, data.id);
|
||||||
|
|
||||||
|
// Update infinity list
|
||||||
|
this.selectionList.infinityModel.content.unshiftObject(model);
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
this.notifications.showNotification(this.#getToastMessage('duplicated'), {type: 'success'});
|
||||||
|
} catch (error) {
|
||||||
|
this.notifications.showAPIError(error, {key: `${this.type}.copy.failed`});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async performBulkDestroy() {
|
async performBulkDestroy() {
|
||||||
const filter = this.selectionList.filter;
|
const filter = this.selectionList.filter;
|
||||||
let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts' : 'pages') + `?filter=${encodeURIComponent(filter)}`;
|
let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts' : 'pages') + `?filter=${encodeURIComponent(filter)}`;
|
||||||
@ -385,6 +418,12 @@ export default class PostsContextMenu extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async performCopy() {
|
||||||
|
const id = this.selectionList.availableModels[0].id;
|
||||||
|
const copyUrl = this.ghostPaths.url.api(`${this.type === 'post' ? 'posts' : 'pages'}/${id}/copy`) + '?formats=mobiledoc,lexical';
|
||||||
|
return await this.ajax.post(copyUrl);
|
||||||
|
}
|
||||||
|
|
||||||
get shouldFeatureSelection() {
|
get shouldFeatureSelection() {
|
||||||
let featuredCount = 0;
|
let featuredCount = 0;
|
||||||
for (const m of this.selectionList.availableModels) {
|
for (const m of this.selectionList.availableModels) {
|
||||||
@ -412,4 +451,12 @@ export default class PostsContextMenu extends Component {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canCopySelection() {
|
||||||
|
if (this.feature.makingItRain === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.selectionList.availableModels.length === 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -388,3 +388,17 @@ Post context menu
|
|||||||
.gh-posts-context-menu li > button span svg path {
|
.gh-posts-context-menu li > button span svg path {
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gh-posts-context-menu li > button span svg.star path {
|
||||||
|
stroke-width: 1.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gh-posts-context-menu li:last-child::before {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
content: "";
|
||||||
|
margin: 5px 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: #dfe1e3;
|
||||||
|
}
|
||||||
|
3
ghost/admin/public/assets/icons/duplicate.svg
Normal file
3
ghost/admin/public/assets/icons/duplicate.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 2500 2500" version="1.1">
|
||||||
|
<path d="M 155.778 1.549 C 116.272 7.712, 81.591 25.300, 53.446 53.446 C 27.289 79.602, 10.519 110.957, 2.874 148 L 0.500 159.500 0.500 911.500 L 0.500 1663.500 2.874 1675 C 17.471 1745.727, 69.628 1800.639, 138.547 1817.839 C 157.843 1822.654, 151.739 1822.500, 323 1822.500 L 484.500 1822.500 492.500 1819.775 C 516.605 1811.564, 536.082 1792.024, 543.610 1768.500 C 547.115 1757.549, 547.950 1740.858, 545.587 1728.977 C 542.964 1715.786, 535.897 1702.183, 526.451 1692.141 C 514.724 1679.676, 503.635 1672.946, 487 1668.200 C 482.152 1666.817, 463.312 1666.564, 328 1666.066 L 174.500 1665.500 170.357 1663.284 C 165.179 1660.515, 159.304 1653.754, 158.002 1649.065 C 156.548 1643.834, 156.641 178.832, 158.095 173.595 C 159.575 168.265, 168.391 159.510, 173.725 158.074 C 176.581 157.305, 389.313 157.067, 913.617 157.247 C 1638.960 157.496, 1649.543 157.528, 1652.500 159.432 C 1657.511 162.659, 1661.073 166.296, 1663.342 170.500 L 1665.500 174.500 1666.066 328 C 1666.564 463.312, 1666.817 482.152, 1668.200 487 C 1672.946 503.635, 1679.676 514.724, 1692.141 526.451 C 1702.183 535.897, 1715.786 542.964, 1728.977 545.587 C 1740.858 547.950, 1757.549 547.115, 1768.500 543.610 C 1792.024 536.082, 1811.564 516.605, 1819.775 492.500 L 1822.500 484.500 1822.500 323 C 1822.500 192.519, 1822.243 160.060, 1821.162 154 C 1814.034 114.055, 1797.050 80.982, 1769.534 53.466 C 1743.918 27.850, 1714.253 11.706, 1677.149 3.190 L 1665.500 0.516 915 0.352 C 286.385 0.215, 163.084 0.409, 155.778 1.549 M 837 678.090 C 805.439 682.153, 774.219 694.967, 748.513 714.410 C 738.278 722.151, 722.151 738.278, 714.410 748.513 C 694.613 774.688, 682.086 805.510, 678.053 837.974 C 677.324 843.838, 677 1075.250, 677 1589.345 C 677 2257.337, 677.157 2333.315, 678.561 2342.872 C 684.456 2383.021, 702.010 2418.078, 730.466 2446.534 C 756.621 2472.689, 788.023 2489.495, 825 2497.126 L 836.500 2499.500 1588.500 2499.500 L 2340.500 2499.500 2352 2497.126 C 2389.043 2489.481, 2420.398 2472.711, 2446.554 2446.554 C 2472.711 2420.398, 2489.481 2389.043, 2497.126 2352 L 2499.500 2340.500 2499.500 1588.500 L 2499.500 836.500 2497.126 825 C 2489.495 788.023, 2472.689 756.621, 2446.534 730.466 C 2418.078 702.010, 2383.021 684.456, 2342.872 678.561 C 2333.309 677.156, 2257.628 677.013, 1587.872 677.127 C 1178.467 677.197, 840.575 677.630, 837 678.090 M 848.500 836.383 C 842.491 839.224, 838.816 842.957, 836.081 849 L 834.044 853.500 834.044 1588.500 L 834.044 2323.500 836.084 2328 C 838.517 2333.366, 841.118 2336.331, 846.500 2339.870 L 850.500 2342.500 1586.383 2342.753 C 2110.687 2342.933, 2323.419 2342.695, 2326.275 2341.926 C 2331.702 2340.465, 2340.465 2331.702, 2341.926 2326.275 C 2342.695 2323.419, 2342.933 2110.687, 2342.753 1586.383 L 2342.500 850.500 2339.870 846.500 C 2336.331 841.118, 2333.366 838.517, 2328 836.084 L 2323.500 834.044 1588.500 834.032 L 853.500 834.020 848.500 836.383" stroke="none" fill="currentColor" fill-rule="evenodd"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.0 KiB |
@ -122,7 +122,8 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const locationHeaderDisabled = apiConfigHeaders?.location === false;
|
const locationHeaderDisabled = apiConfigHeaders?.location === false;
|
||||||
const hasFrameData = frame?.method === 'add' && result[frame.docName]?.[0]?.id;
|
const hasLocationResolver = apiConfigHeaders?.location?.resolve;
|
||||||
|
const hasFrameData = (frame?.method === 'add' || hasLocationResolver) && result[frame.docName]?.[0]?.id;
|
||||||
|
|
||||||
if (!locationHeaderDisabled && hasFrameData) {
|
if (!locationHeaderDisabled && hasFrameData) {
|
||||||
const protocol = (frame.original.url.secure === false) ? 'http://' : 'https://';
|
const protocol = (frame.original.url.secure === false) ? 'http://' : 'https://';
|
||||||
@ -132,8 +133,13 @@ module.exports = {
|
|||||||
if (!locationURL.endsWith('/')) {
|
if (!locationURL.endsWith('/')) {
|
||||||
locationURL += '/';
|
locationURL += '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
locationURL += `${resourceId}/`;
|
locationURL += `${resourceId}/`;
|
||||||
|
|
||||||
|
if (hasLocationResolver) {
|
||||||
|
locationURL = apiConfigHeaders.location.resolve(locationURL);
|
||||||
|
}
|
||||||
|
|
||||||
const locationHeader = {
|
const locationHeader = {
|
||||||
Location: locationURL
|
Location: locationURL
|
||||||
};
|
};
|
||||||
|
@ -102,7 +102,7 @@ describe('Headers', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('location header', function () {
|
describe('location header', function () {
|
||||||
it('adds header when all needed data is present', function () {
|
it('adds header when all needed data is present and method is add', function () {
|
||||||
const apiResult = {
|
const apiResult = {
|
||||||
posts: [{
|
posts: [{
|
||||||
id: 'id_value'
|
id: 'id_value'
|
||||||
@ -130,6 +130,41 @@ describe('Headers', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds header when a location resolver is provided', function () {
|
||||||
|
const apiResult = {
|
||||||
|
posts: [{
|
||||||
|
id: 'id_value'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedLocationUrl = 'resolved location';
|
||||||
|
|
||||||
|
const apiConfigHeaders = {
|
||||||
|
location: {
|
||||||
|
resolve() {
|
||||||
|
return resolvedLocationUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const frame = {
|
||||||
|
docName: 'posts',
|
||||||
|
method: 'copy',
|
||||||
|
original: {
|
||||||
|
url: {
|
||||||
|
host: 'example.com',
|
||||||
|
pathname: `/api/content/posts/existing_post_id_value/copy`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return shared.headers.get(apiResult, apiConfigHeaders, frame)
|
||||||
|
.then((result) => {
|
||||||
|
result.should.eql({
|
||||||
|
Location: resolvedLocationUrl
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('respects HTTP redirects', async function () {
|
it('respects HTTP redirects', async function () {
|
||||||
const apiResult = {
|
const apiResult = {
|
||||||
posts: [{
|
posts: [{
|
||||||
|
@ -238,5 +238,30 @@ module.exports = {
|
|||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Post.destroy({...frame.options, require: true});
|
return models.Post.destroy({...frame.options, require: true});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copy: {
|
||||||
|
statusCode: 201,
|
||||||
|
headers: {
|
||||||
|
location: {
|
||||||
|
resolve: postsService.generateCopiedPostLocationFromUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
'id',
|
||||||
|
'formats'
|
||||||
|
],
|
||||||
|
validation: {
|
||||||
|
id: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
docName: 'posts',
|
||||||
|
method: 'add'
|
||||||
|
},
|
||||||
|
async query(frame) {
|
||||||
|
return postsService.copyPost(frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -279,5 +279,29 @@ module.exports = {
|
|||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Post.destroy({...frame.options, require: true});
|
return models.Post.destroy({...frame.options, require: true});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copy: {
|
||||||
|
statusCode: 201,
|
||||||
|
headers: {
|
||||||
|
location: {
|
||||||
|
resolve: postsService.generateCopiedPostLocationFromUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
'id',
|
||||||
|
'formats'
|
||||||
|
],
|
||||||
|
validation: {
|
||||||
|
id: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
method: 'add'
|
||||||
|
},
|
||||||
|
async query(frame) {
|
||||||
|
return postsService.copyPost(frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -197,5 +197,12 @@ module.exports = {
|
|||||||
|
|
||||||
bulkDestroy(apiConfig, frame) {
|
bulkDestroy(apiConfig, frame) {
|
||||||
forcePageFilter(frame);
|
forcePageFilter(frame);
|
||||||
|
},
|
||||||
|
|
||||||
|
copy(apiConfig, frame) {
|
||||||
|
debug('copy');
|
||||||
|
|
||||||
|
defaultFormat(frame);
|
||||||
|
defaultRelations(frame);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -220,6 +220,13 @@ module.exports = {
|
|||||||
type: 'post'
|
type: 'post'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defaultFormat(frame);
|
||||||
|
defaultRelations(frame);
|
||||||
|
},
|
||||||
|
|
||||||
|
copy(apiConfig, frame) {
|
||||||
|
debug('copy');
|
||||||
|
|
||||||
defaultFormat(frame);
|
defaultFormat(frame);
|
||||||
defaultRelations(frame);
|
defaultRelations(frame);
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,7 @@ module.exports = function apiRoutes() {
|
|||||||
router.get('/posts/slug/:slug', mw.authAdminApi, http(api.posts.read));
|
router.get('/posts/slug/:slug', mw.authAdminApi, http(api.posts.read));
|
||||||
router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit));
|
router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit));
|
||||||
router.del('/posts/:id', mw.authAdminApi, http(api.posts.destroy));
|
router.del('/posts/:id', mw.authAdminApi, http(api.posts.destroy));
|
||||||
|
router.post('/posts/:id/copy', mw.authAdminApi, http(api.posts.copy));
|
||||||
|
|
||||||
router.get('/mentions', labs.enabledMiddleware('webmentions'), mw.authAdminApi, http(api.mentions.browse));
|
router.get('/mentions', labs.enabledMiddleware('webmentions'), mw.authAdminApi, http(api.mentions.browse));
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ module.exports = function apiRoutes() {
|
|||||||
router.get('/pages/slug/:slug', mw.authAdminApi, http(api.pages.read));
|
router.get('/pages/slug/:slug', mw.authAdminApi, http(api.pages.read));
|
||||||
router.put('/pages/:id', mw.authAdminApi, http(api.pages.edit));
|
router.put('/pages/:id', mw.authAdminApi, http(api.pages.edit));
|
||||||
router.del('/pages/:id', mw.authAdminApi, http(api.pages.destroy));
|
router.del('/pages/:id', mw.authAdminApi, http(api.pages.destroy));
|
||||||
|
router.post('/pages/:id/copy', mw.authAdminApi, http(api.pages.copy));
|
||||||
|
|
||||||
// # Integrations
|
// # Integrations
|
||||||
|
|
||||||
|
104
ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap
Normal file
104
ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Pages API Copy Can copy a page 1: [body] 1`] = `
|
||||||
|
Object {
|
||||||
|
"pages": Array [
|
||||||
|
Object {
|
||||||
|
"authors": Any<Array>,
|
||||||
|
"canonical_url": null,
|
||||||
|
"codeinjection_foot": null,
|
||||||
|
"codeinjection_head": null,
|
||||||
|
"comment_id": Any<String>,
|
||||||
|
"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,
|
||||||
|
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||||
|
"lexical": null,
|
||||||
|
"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": Any<Object>,
|
||||||
|
"primary_tag": Any<Object>,
|
||||||
|
"published_at": null,
|
||||||
|
"slug": "test-page-copy",
|
||||||
|
"status": "draft",
|
||||||
|
"tags": Any<Array>,
|
||||||
|
"tiers": Array [
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"title": "Test Page (Copy)",
|
||||||
|
"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": Any<String>,
|
||||||
|
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||||
|
"visibility": "public",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Pages API Copy Can copy a page 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-type": "application/json; charset=utf-8",
|
||||||
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
|
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/pages\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||||
|
"vary": "Accept-Version, Origin, Accept-Encoding",
|
||||||
|
"x-powered-by": "Express",
|
||||||
|
}
|
||||||
|
`;
|
@ -434,128 +434,7 @@ Object {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Posts API Can export 1: [body] 1`] = `Object {}`;
|
exports[`Posts API Copy Can copy a post 1: [body] 1`] = `
|
||||||
|
|
||||||
exports[`Posts API Can export 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",
|
|
||||||
"content-disposition": "Attachment; filename=\\"posts.2023-03-27.csv\\"",
|
|
||||||
"content-length": "2511",
|
|
||||||
"content-type": "text/csv; charset=utf-8",
|
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
|
||||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
|
||||||
"x-powered-by": "Express",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export 2 1`] = `
|
|
||||||
Object {
|
|
||||||
"text": "title,url,author,status,created_at,updated_at,published_at,featured,tags,post_access,email_recipients,sends,opens,clicks,free_signups,paid_signups
|
|
||||||
Start here for a quick overview of everything you need to know,http://127.0.0.1:2369/welcome/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Customizing your brand and design settings,http://127.0.0.1:2369/design/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
\\"Writing and managing content in Ghost, an advanced guide\\",http://127.0.0.1:2369/write/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Building your audience with subscriber signups,http://127.0.0.1:2369/portal/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Selling premium memberships with recurring revenue,http://127.0.0.1:2369/sell/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Paid members,,,,,0,0
|
|
||||||
How to grow your business around an audience,http://127.0.0.1:2369/grow/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Setting up apps and custom integrations,http://127.0.0.1:2369/integrations/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
\\"Not so short, bit complex\\",http://127.0.0.1:2369/not-so-short-bit-complex/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,true,,Public,,,,,0,0
|
|
||||||
Short and Sweet,http://127.0.0.1:2369/short-and-sweet/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,true,chorizo,Public,,,,,0,0
|
|
||||||
Ghostly Kitchen Sink,http://127.0.0.1:2369/ghostly-kitchen-sink/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,\\"kitchen sink, bacon\\",Public,,,,,0,0
|
|
||||||
HTML Ipsum,http://127.0.0.1:2369/html-ipsum/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,\\"kitchen sink, bacon\\",Public,,,,,0,0",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export 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-disposition": "Attachment; filename=\\"posts.2023-03-27.csv\\"",
|
|
||||||
"content-length": "2511",
|
|
||||||
"content-type": "text/csv; charset=utf-8",
|
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
|
||||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
|
||||||
"x-powered-by": "Express",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export with filter 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",
|
|
||||||
"content-disposition": "Attachment; filename=\\"posts.2023-03-27.csv\\"",
|
|
||||||
"content-length": "544",
|
|
||||||
"content-type": "text/csv; charset=utf-8",
|
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
|
||||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
|
||||||
"x-powered-by": "Express",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export with filter 2 1`] = `
|
|
||||||
Object {
|
|
||||||
"text": "title,url,author,status,created_at,updated_at,published_at,featured,tags,post_access,email_recipients,sends,opens,clicks,free_signups,paid_signups
|
|
||||||
\\"Not so short, bit complex\\",http://127.0.0.1:2369/not-so-short-bit-complex/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,true,,Public,,,,,0,0
|
|
||||||
Short and Sweet,http://127.0.0.1:2369/short-and-sweet/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,true,chorizo,Public,,,,,0,0",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export with limit 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",
|
|
||||||
"content-disposition": "Attachment; filename=\\"posts.2023-03-27.csv\\"",
|
|
||||||
"content-length": "381",
|
|
||||||
"content-type": "text/csv; charset=utf-8",
|
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
|
||||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
|
||||||
"x-powered-by": "Express",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export with limit 2 1`] = `
|
|
||||||
Object {
|
|
||||||
"text": "title,url,author,status,created_at,updated_at,published_at,featured,tags,post_access,email_recipients,sends,opens,clicks,free_signups,paid_signups
|
|
||||||
Start here for a quick overview of everything you need to know,http://127.0.0.1:2369/welcome/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export with order 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",
|
|
||||||
"content-disposition": "Attachment; filename=\\"posts.2023-03-27.csv\\"",
|
|
||||||
"content-length": "2511",
|
|
||||||
"content-type": "text/csv; charset=utf-8",
|
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
|
||||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
|
||||||
"x-powered-by": "Express",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can export with order 2 1`] = `
|
|
||||||
Object {
|
|
||||||
"text": "title,url,author,status,created_at,updated_at,published_at,featured,tags,post_access,email_recipients,sends,opens,clicks,free_signups,paid_signups
|
|
||||||
\\"Writing and managing content in Ghost, an advanced guide\\",http://127.0.0.1:2369/write/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Start here for a quick overview of everything you need to know,http://127.0.0.1:2369/welcome/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Short and Sweet,http://127.0.0.1:2369/short-and-sweet/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,true,chorizo,Public,,,,,0,0
|
|
||||||
Setting up apps and custom integrations,http://127.0.0.1:2369/integrations/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Selling premium memberships with recurring revenue,http://127.0.0.1:2369/sell/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Paid members,,,,,0,0
|
|
||||||
\\"Not so short, bit complex\\",http://127.0.0.1:2369/not-so-short-bit-complex/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,true,,Public,,,,,0,0
|
|
||||||
HTML Ipsum,http://127.0.0.1:2369/html-ipsum/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,\\"kitchen sink, bacon\\",Public,,,,,0,0
|
|
||||||
How to grow your business around an audience,http://127.0.0.1:2369/grow/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Ghostly Kitchen Sink,http://127.0.0.1:2369/ghostly-kitchen-sink/,Joe Bloggs,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,\\"kitchen sink, bacon\\",Public,,,,,0,0
|
|
||||||
Customizing your brand and design settings,http://127.0.0.1:2369/design/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0
|
|
||||||
Building your audience with subscriber signups,http://127.0.0.1:2369/portal/,Ghost,published and emailed,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,2050-01-01T00:00:00.000Z,false,Getting Started,Public,,,,,0,0",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can read with post_revisions included 1: [body] 1`] = `
|
|
||||||
Object {
|
Object {
|
||||||
"posts": Array [
|
"posts": Array [
|
||||||
Object {
|
Object {
|
||||||
@ -576,27 +455,26 @@ Object {
|
|||||||
"email_only": false,
|
"email_only": false,
|
||||||
"email_segment": "all",
|
"email_segment": "all",
|
||||||
"email_subject": null,
|
"email_subject": null,
|
||||||
"excerpt": "Testing post creation with lexical",
|
"excerpt": null,
|
||||||
"feature_image": null,
|
"feature_image": null,
|
||||||
"feature_image_alt": null,
|
"feature_image_alt": null,
|
||||||
"feature_image_caption": null,
|
"feature_image_caption": null,
|
||||||
"featured": false,
|
"featured": false,
|
||||||
"frontmatter": null,
|
"frontmatter": null,
|
||||||
"html": "<p>Testing post creation with lexical</p>",
|
|
||||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||||
"lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Testing post creation with lexical\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}",
|
"lexical": null,
|
||||||
"meta_description": null,
|
"meta_description": null,
|
||||||
"meta_title": null,
|
"meta_title": null,
|
||||||
"mobiledoc": null,
|
"mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}",
|
||||||
"newsletter": null,
|
"newsletter": null,
|
||||||
"og_description": null,
|
"og_description": null,
|
||||||
"og_image": null,
|
"og_image": null,
|
||||||
"og_title": null,
|
"og_title": null,
|
||||||
|
"post_revisions": Any<Array>,
|
||||||
"primary_author": Any<Object>,
|
"primary_author": Any<Object>,
|
||||||
"primary_tag": Any<Object>,
|
"primary_tag": Any<Object>,
|
||||||
"published_at": null,
|
"published_at": null,
|
||||||
"reading_time": 0,
|
"slug": "test-post-copy",
|
||||||
"slug": "post-revisions-test",
|
|
||||||
"status": "draft",
|
"status": "draft",
|
||||||
"tags": Any<Array>,
|
"tags": Any<Array>,
|
||||||
"tiers": Array [
|
"tiers": Array [
|
||||||
@ -637,7 +515,7 @@ Object {
|
|||||||
"yearly_price_id": null,
|
"yearly_price_id": null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"title": "Post Revisions Test",
|
"title": "Test Post (Copy)",
|
||||||
"twitter_description": null,
|
"twitter_description": null,
|
||||||
"twitter_image": null,
|
"twitter_image": null,
|
||||||
"twitter_title": null,
|
"twitter_title": null,
|
||||||
@ -650,11 +528,11 @@ Object {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Posts API Can read with post_revisions included 2: [headers] 1`] = `
|
exports[`Posts API Copy Can copy a post 2: [headers] 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"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",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "4018",
|
"content-length": "3702",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
@ -664,73 +542,6 @@ Object {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Posts API Can read with post_revisions included 3: [body] 1`] = `
|
|
||||||
Object {
|
|
||||||
"posts": Array [
|
|
||||||
Object {
|
|
||||||
"canonical_url": null,
|
|
||||||
"codeinjection_foot": null,
|
|
||||||
"codeinjection_head": null,
|
|
||||||
"comment_id": Any<String>,
|
|
||||||
"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": "Testing post creation with lexical",
|
|
||||||
"feature_image": null,
|
|
||||||
"feature_image_alt": null,
|
|
||||||
"feature_image_caption": null,
|
|
||||||
"featured": false,
|
|
||||||
"frontmatter": null,
|
|
||||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
|
||||||
"meta_description": null,
|
|
||||||
"meta_title": null,
|
|
||||||
"mobiledoc": null,
|
|
||||||
"og_description": null,
|
|
||||||
"og_image": null,
|
|
||||||
"og_title": null,
|
|
||||||
"post_revisions": Array [
|
|
||||||
Object {
|
|
||||||
"author_id": "1",
|
|
||||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
|
||||||
"created_at_ts": Any<Number>,
|
|
||||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
|
||||||
"lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Testing post creation with lexical\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}",
|
|
||||||
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
|
||||||
"title": "Post Revisions Test",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"published_at": null,
|
|
||||||
"slug": "post-revisions-test",
|
|
||||||
"status": "draft",
|
|
||||||
"title": "Post Revisions Test",
|
|
||||||
"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": Any<String>,
|
|
||||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
|
||||||
"visibility": "public",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Can read with post_revisions included 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": "1496",
|
|
||||||
"content-type": "application/json; charset=utf-8",
|
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
|
||||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
|
||||||
"x-powered-by": "Express",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Posts API Create Can create a post with lexical 1: [body] 1`] = `
|
exports[`Posts API Create Can create a post with lexical 1: [body] 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"posts": Array [
|
"posts": Array [
|
||||||
|
433
ghost/core/test/e2e-api/admin/pages-legacy.test.js
Normal file
433
ghost/core/test/e2e-api/admin/pages-legacy.test.js
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
const should = require('should');
|
||||||
|
const supertest = require('supertest');
|
||||||
|
const moment = require('moment');
|
||||||
|
const _ = require('lodash');
|
||||||
|
const testUtils = require('../../utils');
|
||||||
|
const config = require('../../../core/shared/config');
|
||||||
|
const models = require('../../../core/server/models');
|
||||||
|
const localUtils = require('./utils');
|
||||||
|
|
||||||
|
describe('Pages API', function () {
|
||||||
|
let request;
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
await localUtils.startGhost();
|
||||||
|
request = supertest.agent(config.get('url'));
|
||||||
|
await localUtils.doAuth(request, 'users:extra', 'posts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can retrieve all pages', async function () {
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery('pages/'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
should.not.exist(res.headers['x-cache-invalidate']);
|
||||||
|
const jsonResponse = res.body;
|
||||||
|
should.exist(jsonResponse.pages);
|
||||||
|
localUtils.API.checkResponse(jsonResponse, 'pages');
|
||||||
|
jsonResponse.pages.should.have.length(6);
|
||||||
|
|
||||||
|
localUtils.API.checkResponse(jsonResponse.pages[0], 'page');
|
||||||
|
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
|
||||||
|
_.isBoolean(jsonResponse.pages[0].featured).should.eql(true);
|
||||||
|
|
||||||
|
// Absolute urls by default
|
||||||
|
jsonResponse.pages[0].url.should.match(new RegExp(`${config.get('url')}/p/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}`));
|
||||||
|
jsonResponse.pages[1].url.should.eql(`${config.get('url')}/contribute/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can retrieve pages with lexical format', async function () {
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery('pages/?formats=lexical'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
should.not.exist(res.headers['x-cache-invalidate']);
|
||||||
|
const jsonResponse = res.body;
|
||||||
|
should.exist(jsonResponse.pages);
|
||||||
|
localUtils.API.checkResponse(jsonResponse, 'pages');
|
||||||
|
jsonResponse.pages.should.have.length(6);
|
||||||
|
|
||||||
|
const additionalProperties = ['lexical'];
|
||||||
|
const missingProperties = ['mobiledoc'];
|
||||||
|
localUtils.API.checkResponse(jsonResponse.pages[0], 'page', additionalProperties, missingProperties);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can add a page', async function () {
|
||||||
|
const page = {
|
||||||
|
title: 'My Page',
|
||||||
|
page: false,
|
||||||
|
status: 'published',
|
||||||
|
feature_image_alt: 'Testing feature image alt',
|
||||||
|
feature_image_caption: 'Testing <b>feature image caption</b>'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request.post(localUtils.API.getApiQuery('pages/'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({pages: [page]})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
res.body.pages.length.should.eql(1);
|
||||||
|
|
||||||
|
localUtils.API.checkResponse(res.body.pages[0], 'page');
|
||||||
|
should.exist(res.headers['x-cache-invalidate']);
|
||||||
|
|
||||||
|
should.exist(res.headers.location);
|
||||||
|
res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('pages/')}${res.body.pages[0].id}/`);
|
||||||
|
|
||||||
|
const model = await models.Post.findOne({
|
||||||
|
id: res.body.pages[0].id
|
||||||
|
}, testUtils.context.internal);
|
||||||
|
|
||||||
|
const modelJson = model.toJSON();
|
||||||
|
|
||||||
|
modelJson.title.should.eql(page.title);
|
||||||
|
modelJson.status.should.eql(page.status);
|
||||||
|
modelJson.type.should.eql('page');
|
||||||
|
|
||||||
|
modelJson.posts_meta.feature_image_alt.should.eql(page.feature_image_alt);
|
||||||
|
modelJson.posts_meta.feature_image_caption.should.eql(page.feature_image_caption);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can add a page with mobiledoc', async function () {
|
||||||
|
const page = {
|
||||||
|
title: 'Mobiledoc test',
|
||||||
|
mobiledoc: JSON.stringify({
|
||||||
|
version: '0.3.1',
|
||||||
|
ghostVersion: '4.0',
|
||||||
|
markups: [],
|
||||||
|
atoms: [],
|
||||||
|
cards: [],
|
||||||
|
sections: [
|
||||||
|
[1, 'p', [
|
||||||
|
[0, [], 0, 'Testing post creation with mobiledoc']
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request.post(localUtils.API.getApiQuery('pages/?formats=mobiledoc,lexical'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({pages: [page]})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
res.body.pages.length.should.eql(1);
|
||||||
|
const [returnedPage] = res.body.pages;
|
||||||
|
|
||||||
|
const additionalProperties = ['lexical'];
|
||||||
|
localUtils.API.checkResponse(returnedPage, 'page', additionalProperties);
|
||||||
|
|
||||||
|
should.equal(returnedPage.mobiledoc, page.mobiledoc);
|
||||||
|
should.equal(returnedPage.lexical, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can add a page with lexical', async function () {
|
||||||
|
const page = {
|
||||||
|
title: 'Lexical test',
|
||||||
|
lexical: JSON.stringify({
|
||||||
|
root: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Testing page creation with lexical',
|
||||||
|
type: 'text',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'paragraph',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'root',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request.post(localUtils.API.getApiQuery('pages/?formats=mobiledoc,lexical,html'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({pages: [page]})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
res.body.pages.length.should.eql(1);
|
||||||
|
const [returnedPage] = res.body.pages;
|
||||||
|
|
||||||
|
const additionalProperties = ['lexical', 'html', 'reading_time'];
|
||||||
|
localUtils.API.checkResponse(returnedPage, 'page', additionalProperties);
|
||||||
|
|
||||||
|
should.equal(returnedPage.mobiledoc, null);
|
||||||
|
should.equal(returnedPage.lexical, page.lexical);
|
||||||
|
should.equal(returnedPage.html, '<p>Testing page creation with lexical</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can\'t add a page with both mobiledoc and lexical', async function () {
|
||||||
|
const page = {
|
||||||
|
title: 'Mobiledoc test',
|
||||||
|
mobiledoc: JSON.stringify({
|
||||||
|
version: '0.3.1',
|
||||||
|
ghostVersion: '4.0',
|
||||||
|
markups: [],
|
||||||
|
atoms: [],
|
||||||
|
cards: [],
|
||||||
|
sections: [
|
||||||
|
[1, 'p', [
|
||||||
|
[0, [], 0, 'Testing post creation with mobiledoc']
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
lexical: JSON.stringify({
|
||||||
|
editorState: {
|
||||||
|
root: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Testing post creation with lexical',
|
||||||
|
type: 'text',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'paragraph',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'root',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lastSaved: 1663081361393,
|
||||||
|
source: 'Playground',
|
||||||
|
version: '0.4.1'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request.post(localUtils.API.getApiQuery('pages/?formats=mobiledoc,lexical'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({pages: [page]})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(422);
|
||||||
|
|
||||||
|
const [error] = res.body.errors;
|
||||||
|
error.type.should.equal('ValidationError');
|
||||||
|
error.property.should.equal('lexical');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can include free and paid tiers for public page', async function () {
|
||||||
|
const publicPost = testUtils.DataGenerator.forKnex.createPost({
|
||||||
|
type: 'page',
|
||||||
|
slug: 'free-to-see',
|
||||||
|
visibility: 'public',
|
||||||
|
published_at: moment().add(15, 'seconds').toDate() // here to ensure sorting is not modified
|
||||||
|
});
|
||||||
|
await models.Post.add(publicPost, {context: {internal: true}});
|
||||||
|
|
||||||
|
const publicPostRes = await request
|
||||||
|
.get(localUtils.API.getApiQuery(`pages/${publicPost.id}/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect(200);
|
||||||
|
const publicPostData = publicPostRes.body.pages[0];
|
||||||
|
publicPostData.tiers.length.should.eql(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can include free and paid tiers for members only page', async function () {
|
||||||
|
const membersPost = testUtils.DataGenerator.forKnex.createPost({
|
||||||
|
type: 'page',
|
||||||
|
slug: 'thou-shalt-not-be-seen',
|
||||||
|
visibility: 'members',
|
||||||
|
published_at: moment().add(45, 'seconds').toDate() // here to ensure sorting is not modified
|
||||||
|
});
|
||||||
|
await models.Post.add(membersPost, {context: {internal: true}});
|
||||||
|
|
||||||
|
const membersPostRes = await request
|
||||||
|
.get(localUtils.API.getApiQuery(`pages/${membersPost.id}/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect(200);
|
||||||
|
const membersPostData = membersPostRes.body.pages[0];
|
||||||
|
membersPostData.tiers.length.should.eql(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can include only paid tier for paid page', async function () {
|
||||||
|
const paidPost = testUtils.DataGenerator.forKnex.createPost({
|
||||||
|
type: 'page',
|
||||||
|
slug: 'thou-shalt-be-paid-for',
|
||||||
|
visibility: 'paid',
|
||||||
|
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
|
||||||
|
});
|
||||||
|
await models.Post.add(paidPost, {context: {internal: true}});
|
||||||
|
|
||||||
|
const paidPostRes = await request
|
||||||
|
.get(localUtils.API.getApiQuery(`pages/${paidPost.id}/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect(200);
|
||||||
|
const paidPostData = paidPostRes.body.pages[0];
|
||||||
|
paidPostData.tiers.length.should.eql(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can include specific tier for page with tiers visibility', async function () {
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery('tiers/'))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const jsonResponse = res.body;
|
||||||
|
|
||||||
|
const paidTier = jsonResponse.tiers.find(p => p.type === 'paid');
|
||||||
|
|
||||||
|
const tiersPage = testUtils.DataGenerator.forKnex.createPost({
|
||||||
|
type: 'page',
|
||||||
|
slug: 'thou-shalt-be-for-specific-tiers',
|
||||||
|
visibility: 'tiers',
|
||||||
|
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
|
||||||
|
});
|
||||||
|
|
||||||
|
tiersPage.tiers = [paidTier];
|
||||||
|
|
||||||
|
await models.Post.add(tiersPage, {context: {internal: true}});
|
||||||
|
|
||||||
|
const tiersPageRes = await request
|
||||||
|
.get(localUtils.API.getApiQuery(`pages/${tiersPage.id}/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect(200);
|
||||||
|
const tiersPageData = tiersPageRes.body.pages[0];
|
||||||
|
|
||||||
|
tiersPageData.tiers.length.should.eql(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can update a page', async function () {
|
||||||
|
const page = {
|
||||||
|
title: 'updated page',
|
||||||
|
page: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request
|
||||||
|
.get(localUtils.API.getApiQuery(`pages/${testUtils.DataGenerator.Content.posts[5].id}/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
page.updated_at = res.body.pages[0].updated_at;
|
||||||
|
|
||||||
|
const res2 = await request.put(localUtils.API.getApiQuery('pages/' + testUtils.DataGenerator.Content.posts[5].id))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({pages: [page]})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
should.exist(res2.headers['x-cache-invalidate']);
|
||||||
|
localUtils.API.checkResponse(res2.body.pages[0], 'page');
|
||||||
|
|
||||||
|
const model = await models.Post.findOne({
|
||||||
|
id: res2.body.pages[0].id
|
||||||
|
}, testUtils.context.internal);
|
||||||
|
|
||||||
|
model.get('type').should.eql('page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can update a page with restricted access to specific tier', async function () {
|
||||||
|
const page = {
|
||||||
|
title: 'updated page',
|
||||||
|
page: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request
|
||||||
|
.get(localUtils.API.getApiQuery(`pages/${testUtils.DataGenerator.Content.posts[5].id}/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const resTiers = await request
|
||||||
|
.get(localUtils.API.getApiQuery(`tiers/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const tiers = resTiers.body.tiers;
|
||||||
|
page.updated_at = res.body.pages[0].updated_at;
|
||||||
|
page.visibility = 'tiers';
|
||||||
|
const paidTiers = tiers.filter((p) => {
|
||||||
|
return p.type === 'paid';
|
||||||
|
}).map((product) => {
|
||||||
|
return product;
|
||||||
|
});
|
||||||
|
page.tiers = paidTiers;
|
||||||
|
|
||||||
|
const res2 = await request.put(localUtils.API.getApiQuery('pages/' + testUtils.DataGenerator.Content.posts[5].id))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({pages: [page]})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
should.exist(res2.headers['x-cache-invalidate']);
|
||||||
|
localUtils.API.checkResponse(res2.body.pages[0], 'page');
|
||||||
|
res2.body.pages[0].tiers.length.should.eql(paidTiers.length);
|
||||||
|
|
||||||
|
const model = await models.Post.findOne({
|
||||||
|
id: res2.body.pages[0].id
|
||||||
|
}, testUtils.context.internal);
|
||||||
|
|
||||||
|
model.get('type').should.eql('page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cannot get page via posts endpoint', async function () {
|
||||||
|
await request.get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[5].id}/`))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cannot update page via posts endpoint', async function () {
|
||||||
|
const page = {
|
||||||
|
title: 'fails',
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await request.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[5].id))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.send({posts: [page]})
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can delete a page', async function () {
|
||||||
|
const res = await request.del(localUtils.API.getApiQuery('pages/' + testUtils.DataGenerator.Content.posts[5].id))
|
||||||
|
.set('Origin', config.get('url'))
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
res.body.should.be.empty();
|
||||||
|
res.headers['x-cache-invalidate'].should.eql('/*');
|
||||||
|
});
|
||||||
|
});
|
@ -1,433 +1,69 @@
|
|||||||
const should = require('should');
|
const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework');
|
||||||
const supertest = require('supertest');
|
const {anyArray, anyContentVersion, anyEtag, anyLocationFor, anyObject, anyObjectId, anyISODateTime, anyString, anyUuid} = matchers;
|
||||||
const moment = require('moment');
|
|
||||||
const _ = require('lodash');
|
const tierSnapshot = {
|
||||||
const testUtils = require('../../utils');
|
id: anyObjectId,
|
||||||
const config = require('../../../core/shared/config');
|
created_at: anyISODateTime,
|
||||||
const models = require('../../../core/server/models');
|
updated_at: anyISODateTime
|
||||||
const localUtils = require('./utils');
|
};
|
||||||
|
|
||||||
|
const matchPageShallowIncludes = {
|
||||||
|
id: anyObjectId,
|
||||||
|
uuid: anyUuid,
|
||||||
|
comment_id: anyString,
|
||||||
|
url: anyString,
|
||||||
|
authors: anyArray,
|
||||||
|
primary_author: anyObject,
|
||||||
|
tags: anyArray,
|
||||||
|
primary_tag: anyObject,
|
||||||
|
tiers: Array(2).fill(tierSnapshot),
|
||||||
|
created_at: anyISODateTime,
|
||||||
|
updated_at: anyISODateTime,
|
||||||
|
published_at: anyISODateTime
|
||||||
|
};
|
||||||
|
|
||||||
describe('Pages API', function () {
|
describe('Pages API', function () {
|
||||||
let request;
|
let agent;
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
await localUtils.startGhost();
|
agent = await agentProvider.getAdminAPIAgent();
|
||||||
request = supertest.agent(config.get('url'));
|
await fixtureManager.init('posts');
|
||||||
await localUtils.doAuth(request, 'users:extra', 'posts');
|
await agent.loginAsOwner();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Can retrieve all pages', async function () {
|
afterEach(function () {
|
||||||
const res = await request.get(localUtils.API.getApiQuery('pages/'))
|
mockManager.restore();
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
should.not.exist(res.headers['x-cache-invalidate']);
|
|
||||||
const jsonResponse = res.body;
|
|
||||||
should.exist(jsonResponse.pages);
|
|
||||||
localUtils.API.checkResponse(jsonResponse, 'pages');
|
|
||||||
jsonResponse.pages.should.have.length(6);
|
|
||||||
|
|
||||||
localUtils.API.checkResponse(jsonResponse.pages[0], 'page');
|
|
||||||
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
|
|
||||||
_.isBoolean(jsonResponse.pages[0].featured).should.eql(true);
|
|
||||||
|
|
||||||
// Absolute urls by default
|
|
||||||
jsonResponse.pages[0].url.should.match(new RegExp(`${config.get('url')}/p/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}`));
|
|
||||||
jsonResponse.pages[1].url.should.eql(`${config.get('url')}/contribute/`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Can retrieve pages with lexical format', async function () {
|
describe('Copy', function () {
|
||||||
const res = await request.get(localUtils.API.getApiQuery('pages/?formats=lexical'))
|
it('Can copy a page', async function () {
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
should.not.exist(res.headers['x-cache-invalidate']);
|
|
||||||
const jsonResponse = res.body;
|
|
||||||
should.exist(jsonResponse.pages);
|
|
||||||
localUtils.API.checkResponse(jsonResponse, 'pages');
|
|
||||||
jsonResponse.pages.should.have.length(6);
|
|
||||||
|
|
||||||
const additionalProperties = ['lexical'];
|
|
||||||
const missingProperties = ['mobiledoc'];
|
|
||||||
localUtils.API.checkResponse(jsonResponse.pages[0], 'page', additionalProperties, missingProperties);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can add a page', async function () {
|
|
||||||
const page = {
|
const page = {
|
||||||
title: 'My Page',
|
title: 'Test Page',
|
||||||
page: false,
|
status: 'published'
|
||||||
status: 'published',
|
|
||||||
feature_image_alt: 'Testing feature image alt',
|
|
||||||
feature_image_caption: 'Testing <b>feature image caption</b>'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await request.post(localUtils.API.getApiQuery('pages/'))
|
const {body: pageBody} = await agent
|
||||||
.set('Origin', config.get('url'))
|
.post('/pages/?formats=mobiledoc,lexical,html', {
|
||||||
.send({pages: [page]})
|
headers: {
|
||||||
.expect('Content-Type', /json/)
|
'content-type': 'application/json'
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
res.body.pages.length.should.eql(1);
|
|
||||||
|
|
||||||
localUtils.API.checkResponse(res.body.pages[0], 'page');
|
|
||||||
should.exist(res.headers['x-cache-invalidate']);
|
|
||||||
|
|
||||||
should.exist(res.headers.location);
|
|
||||||
res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('pages/')}${res.body.pages[0].id}/`);
|
|
||||||
|
|
||||||
const model = await models.Post.findOne({
|
|
||||||
id: res.body.pages[0].id
|
|
||||||
}, testUtils.context.internal);
|
|
||||||
|
|
||||||
const modelJson = model.toJSON();
|
|
||||||
|
|
||||||
modelJson.title.should.eql(page.title);
|
|
||||||
modelJson.status.should.eql(page.status);
|
|
||||||
modelJson.type.should.eql('page');
|
|
||||||
|
|
||||||
modelJson.posts_meta.feature_image_alt.should.eql(page.feature_image_alt);
|
|
||||||
modelJson.posts_meta.feature_image_caption.should.eql(page.feature_image_caption);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can add a page with mobiledoc', async function () {
|
|
||||||
const page = {
|
|
||||||
title: 'Mobiledoc test',
|
|
||||||
mobiledoc: JSON.stringify({
|
|
||||||
version: '0.3.1',
|
|
||||||
ghostVersion: '4.0',
|
|
||||||
markups: [],
|
|
||||||
atoms: [],
|
|
||||||
cards: [],
|
|
||||||
sections: [
|
|
||||||
[1, 'p', [
|
|
||||||
[0, [], 0, 'Testing post creation with mobiledoc']
|
|
||||||
]]
|
|
||||||
]
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await request.post(localUtils.API.getApiQuery('pages/?formats=mobiledoc,lexical'))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.send({pages: [page]})
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
res.body.pages.length.should.eql(1);
|
|
||||||
const [returnedPage] = res.body.pages;
|
|
||||||
|
|
||||||
const additionalProperties = ['lexical'];
|
|
||||||
localUtils.API.checkResponse(returnedPage, 'page', additionalProperties);
|
|
||||||
|
|
||||||
should.equal(returnedPage.mobiledoc, page.mobiledoc);
|
|
||||||
should.equal(returnedPage.lexical, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can add a page with lexical', async function () {
|
|
||||||
const page = {
|
|
||||||
title: 'Lexical test',
|
|
||||||
lexical: JSON.stringify({
|
|
||||||
root: {
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
detail: 0,
|
|
||||||
format: 0,
|
|
||||||
mode: 'normal',
|
|
||||||
style: '',
|
|
||||||
text: 'Testing page creation with lexical',
|
|
||||||
type: 'text',
|
|
||||||
version: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
type: 'paragraph',
|
|
||||||
version: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
type: 'root',
|
|
||||||
version: 1
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
.body({pages: [page]})
|
||||||
|
.expectStatus(201);
|
||||||
|
|
||||||
const res = await request.post(localUtils.API.getApiQuery('pages/?formats=mobiledoc,lexical,html'))
|
const [pageResponse] = pageBody.pages;
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.send({pages: [page]})
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
res.body.pages.length.should.eql(1);
|
await agent
|
||||||
const [returnedPage] = res.body.pages;
|
.post(`/pages/${pageResponse.id}/copy?formats=mobiledoc,lexical`)
|
||||||
|
.expectStatus(201)
|
||||||
const additionalProperties = ['lexical', 'html', 'reading_time'];
|
.matchBodySnapshot({
|
||||||
localUtils.API.checkResponse(returnedPage, 'page', additionalProperties);
|
pages: [Object.assign(matchPageShallowIncludes, {published_at: null})]
|
||||||
|
|
||||||
should.equal(returnedPage.mobiledoc, null);
|
|
||||||
should.equal(returnedPage.lexical, page.lexical);
|
|
||||||
should.equal(returnedPage.html, '<p>Testing page creation with lexical</p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can\'t add a page with both mobiledoc and lexical', async function () {
|
|
||||||
const page = {
|
|
||||||
title: 'Mobiledoc test',
|
|
||||||
mobiledoc: JSON.stringify({
|
|
||||||
version: '0.3.1',
|
|
||||||
ghostVersion: '4.0',
|
|
||||||
markups: [],
|
|
||||||
atoms: [],
|
|
||||||
cards: [],
|
|
||||||
sections: [
|
|
||||||
[1, 'p', [
|
|
||||||
[0, [], 0, 'Testing post creation with mobiledoc']
|
|
||||||
]]
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
lexical: JSON.stringify({
|
|
||||||
editorState: {
|
|
||||||
root: {
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
detail: 0,
|
|
||||||
format: 0,
|
|
||||||
mode: 'normal',
|
|
||||||
style: '',
|
|
||||||
text: 'Testing post creation with lexical',
|
|
||||||
type: 'text',
|
|
||||||
version: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
type: 'paragraph',
|
|
||||||
version: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
type: 'root',
|
|
||||||
version: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
lastSaved: 1663081361393,
|
|
||||||
source: 'Playground',
|
|
||||||
version: '0.4.1'
|
|
||||||
})
|
})
|
||||||
};
|
.matchHeaderSnapshot({
|
||||||
|
'content-version': anyContentVersion,
|
||||||
const res = await request.post(localUtils.API.getApiQuery('pages/?formats=mobiledoc,lexical'))
|
etag: anyEtag,
|
||||||
.set('Origin', config.get('url'))
|
location: anyLocationFor('pages')
|
||||||
.send({pages: [page]})
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(422);
|
|
||||||
|
|
||||||
const [error] = res.body.errors;
|
|
||||||
error.type.should.equal('ValidationError');
|
|
||||||
error.property.should.equal('lexical');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Can include free and paid tiers for public page', async function () {
|
|
||||||
const publicPost = testUtils.DataGenerator.forKnex.createPost({
|
|
||||||
type: 'page',
|
|
||||||
slug: 'free-to-see',
|
|
||||||
visibility: 'public',
|
|
||||||
published_at: moment().add(15, 'seconds').toDate() // here to ensure sorting is not modified
|
|
||||||
});
|
});
|
||||||
await models.Post.add(publicPost, {context: {internal: true}});
|
|
||||||
|
|
||||||
const publicPostRes = await request
|
|
||||||
.get(localUtils.API.getApiQuery(`pages/${publicPost.id}/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect(200);
|
|
||||||
const publicPostData = publicPostRes.body.pages[0];
|
|
||||||
publicPostData.tiers.length.should.eql(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can include free and paid tiers for members only page', async function () {
|
|
||||||
const membersPost = testUtils.DataGenerator.forKnex.createPost({
|
|
||||||
type: 'page',
|
|
||||||
slug: 'thou-shalt-not-be-seen',
|
|
||||||
visibility: 'members',
|
|
||||||
published_at: moment().add(45, 'seconds').toDate() // here to ensure sorting is not modified
|
|
||||||
});
|
|
||||||
await models.Post.add(membersPost, {context: {internal: true}});
|
|
||||||
|
|
||||||
const membersPostRes = await request
|
|
||||||
.get(localUtils.API.getApiQuery(`pages/${membersPost.id}/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect(200);
|
|
||||||
const membersPostData = membersPostRes.body.pages[0];
|
|
||||||
membersPostData.tiers.length.should.eql(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can include only paid tier for paid page', async function () {
|
|
||||||
const paidPost = testUtils.DataGenerator.forKnex.createPost({
|
|
||||||
type: 'page',
|
|
||||||
slug: 'thou-shalt-be-paid-for',
|
|
||||||
visibility: 'paid',
|
|
||||||
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
|
|
||||||
});
|
|
||||||
await models.Post.add(paidPost, {context: {internal: true}});
|
|
||||||
|
|
||||||
const paidPostRes = await request
|
|
||||||
.get(localUtils.API.getApiQuery(`pages/${paidPost.id}/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect(200);
|
|
||||||
const paidPostData = paidPostRes.body.pages[0];
|
|
||||||
paidPostData.tiers.length.should.eql(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can include specific tier for page with tiers visibility', async function () {
|
|
||||||
const res = await request.get(localUtils.API.getApiQuery('tiers/'))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const jsonResponse = res.body;
|
|
||||||
|
|
||||||
const paidTier = jsonResponse.tiers.find(p => p.type === 'paid');
|
|
||||||
|
|
||||||
const tiersPage = testUtils.DataGenerator.forKnex.createPost({
|
|
||||||
type: 'page',
|
|
||||||
slug: 'thou-shalt-be-for-specific-tiers',
|
|
||||||
visibility: 'tiers',
|
|
||||||
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
|
|
||||||
});
|
|
||||||
|
|
||||||
tiersPage.tiers = [paidTier];
|
|
||||||
|
|
||||||
await models.Post.add(tiersPage, {context: {internal: true}});
|
|
||||||
|
|
||||||
const tiersPageRes = await request
|
|
||||||
.get(localUtils.API.getApiQuery(`pages/${tiersPage.id}/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect(200);
|
|
||||||
const tiersPageData = tiersPageRes.body.pages[0];
|
|
||||||
|
|
||||||
tiersPageData.tiers.length.should.eql(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can update a page', async function () {
|
|
||||||
const page = {
|
|
||||||
title: 'updated page',
|
|
||||||
page: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await request
|
|
||||||
.get(localUtils.API.getApiQuery(`pages/${testUtils.DataGenerator.Content.posts[5].id}/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
page.updated_at = res.body.pages[0].updated_at;
|
|
||||||
|
|
||||||
const res2 = await request.put(localUtils.API.getApiQuery('pages/' + testUtils.DataGenerator.Content.posts[5].id))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.send({pages: [page]})
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
should.exist(res2.headers['x-cache-invalidate']);
|
|
||||||
localUtils.API.checkResponse(res2.body.pages[0], 'page');
|
|
||||||
|
|
||||||
const model = await models.Post.findOne({
|
|
||||||
id: res2.body.pages[0].id
|
|
||||||
}, testUtils.context.internal);
|
|
||||||
|
|
||||||
model.get('type').should.eql('page');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can update a page with restricted access to specific tier', async function () {
|
|
||||||
const page = {
|
|
||||||
title: 'updated page',
|
|
||||||
page: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await request
|
|
||||||
.get(localUtils.API.getApiQuery(`pages/${testUtils.DataGenerator.Content.posts[5].id}/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const resTiers = await request
|
|
||||||
.get(localUtils.API.getApiQuery(`tiers/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const tiers = resTiers.body.tiers;
|
|
||||||
page.updated_at = res.body.pages[0].updated_at;
|
|
||||||
page.visibility = 'tiers';
|
|
||||||
const paidTiers = tiers.filter((p) => {
|
|
||||||
return p.type === 'paid';
|
|
||||||
}).map((product) => {
|
|
||||||
return product;
|
|
||||||
});
|
|
||||||
page.tiers = paidTiers;
|
|
||||||
|
|
||||||
const res2 = await request.put(localUtils.API.getApiQuery('pages/' + testUtils.DataGenerator.Content.posts[5].id))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.send({pages: [page]})
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
should.exist(res2.headers['x-cache-invalidate']);
|
|
||||||
localUtils.API.checkResponse(res2.body.pages[0], 'page');
|
|
||||||
res2.body.pages[0].tiers.length.should.eql(paidTiers.length);
|
|
||||||
|
|
||||||
const model = await models.Post.findOne({
|
|
||||||
id: res2.body.pages[0].id
|
|
||||||
}, testUtils.context.internal);
|
|
||||||
|
|
||||||
model.get('type').should.eql('page');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Cannot get page via posts endpoint', async function () {
|
|
||||||
await request.get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[5].id}/`))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Cannot update page via posts endpoint', async function () {
|
|
||||||
const page = {
|
|
||||||
title: 'fails',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await request.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[5].id))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.send({posts: [page]})
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can delete a page', async function () {
|
|
||||||
const res = await request.del(localUtils.API.getApiQuery('pages/' + testUtils.DataGenerator.Content.posts[5].id))
|
|
||||||
.set('Origin', config.get('url'))
|
|
||||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
|
||||||
.expect(204);
|
|
||||||
|
|
||||||
res.body.should.be.empty();
|
|
||||||
res.headers['x-cache-invalidate'].should.eql('/*');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -450,4 +450,36 @@ describe('Posts API', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Copy', function () {
|
||||||
|
it('Can copy a post', async function () {
|
||||||
|
const post = {
|
||||||
|
title: 'Test Post',
|
||||||
|
status: 'published'
|
||||||
|
};
|
||||||
|
|
||||||
|
const {body: postBody} = await agent
|
||||||
|
.post('/posts/?formats=mobiledoc,lexical,html', {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.body({posts: [post]})
|
||||||
|
.expectStatus(201);
|
||||||
|
|
||||||
|
const [postResponse] = postBody.posts;
|
||||||
|
|
||||||
|
await agent
|
||||||
|
.post(`/posts/${postResponse.id}/copy?formats=mobiledoc,lexical`)
|
||||||
|
.expectStatus(201)
|
||||||
|
.matchBodySnapshot({
|
||||||
|
posts: [Object.assign(matchPostShallowIncludes, {published_at: null})]
|
||||||
|
})
|
||||||
|
.matchHeaderSnapshot({
|
||||||
|
'content-version': anyContentVersion,
|
||||||
|
etag: anyEtag,
|
||||||
|
location: anyLocationFor('posts')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -224,4 +224,26 @@ describe('Unit: endpoints/utils/serializers/input/pages', function () {
|
|||||||
frame.data.pages[0].tags.should.eql([{name: 'name1'}, {name: 'name2'}]);
|
frame.data.pages[0].tags.should.eql([{name: 'name1'}, {name: 'name2'}]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('copy', function () {
|
||||||
|
it('adds default formats if no formats are specified', function () {
|
||||||
|
const frame = {
|
||||||
|
options: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
serializers.input.pages.copy({}, frame);
|
||||||
|
|
||||||
|
frame.options.formats.should.eql('mobiledoc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds default relations if no relations are specified', function () {
|
||||||
|
const frame = {
|
||||||
|
options: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
serializers.input.pages.copy({}, frame);
|
||||||
|
|
||||||
|
frame.options.withRelated.should.eql(['tags', 'authors', 'authors.roles', 'tiers', 'count.signups', 'count.paid_conversions']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -339,4 +339,26 @@ describe('Unit: endpoints/utils/serializers/input/posts', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('copy', function () {
|
||||||
|
it('adds default formats if no formats are specified', function () {
|
||||||
|
const frame = {
|
||||||
|
options: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
serializers.input.posts.copy({}, frame);
|
||||||
|
|
||||||
|
frame.options.formats.should.eql('mobiledoc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds default relations if no relations are specified', function () {
|
||||||
|
const frame = {
|
||||||
|
options: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
serializers.input.posts.copy({}, frame);
|
||||||
|
|
||||||
|
frame.options.withRelated.should.eql(['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.clicks', 'post_revisions', 'post_revisions.author']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,6 +3,7 @@ const {BadRequestError} = require('@tryghost/errors');
|
|||||||
const tpl = require('@tryghost/tpl');
|
const tpl = require('@tryghost/tpl');
|
||||||
const errors = require('@tryghost/errors');
|
const errors = require('@tryghost/errors');
|
||||||
const ObjectId = require('bson-objectid').default;
|
const ObjectId = require('bson-objectid').default;
|
||||||
|
const omit = require('lodash/omit');
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
invalidVisibilityFilter: 'Invalid visibility filter.',
|
invalidVisibilityFilter: 'Invalid visibility filter.',
|
||||||
@ -365,6 +366,75 @@ class PostsService {
|
|||||||
|
|
||||||
return cacheInvalidate;
|
return cacheInvalidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async copyPost(frame) {
|
||||||
|
const existingPost = await this.models.Post.findOne({
|
||||||
|
id: frame.options.id,
|
||||||
|
status: 'all'
|
||||||
|
}, frame.options);
|
||||||
|
|
||||||
|
const newPostData = omit(
|
||||||
|
existingPost.attributes,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'uuid',
|
||||||
|
'slug',
|
||||||
|
'comment_id',
|
||||||
|
'created_at',
|
||||||
|
'created_by',
|
||||||
|
'updated_at',
|
||||||
|
'updated_by',
|
||||||
|
'published_at',
|
||||||
|
'published_by',
|
||||||
|
'canonical_url',
|
||||||
|
'count__clicks'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
newPostData.title = `${existingPost.attributes.title} (Copy)`;
|
||||||
|
newPostData.status = 'draft';
|
||||||
|
newPostData.authors = existingPost.related('authors')
|
||||||
|
.map(author => ({id: author.get('id')}));
|
||||||
|
newPostData.tags = existingPost.related('tags')
|
||||||
|
.map(tag => ({id: tag.get('id')}));
|
||||||
|
|
||||||
|
const existingPostMeta = existingPost.related('posts_meta');
|
||||||
|
|
||||||
|
if (existingPostMeta.isNew() === false) {
|
||||||
|
newPostData.posts_meta = omit(
|
||||||
|
existingPostMeta.attributes,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'post_id'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPostTiers = existingPost.related('tiers');
|
||||||
|
|
||||||
|
if (existingPostTiers.length > 0) {
|
||||||
|
newPostData.tiers = existingPostTiers.map(tier => ({id: tier.get('id')}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.models.Post.add(newPostData, frame.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a location url for a copied post based on the original url generated by the API framework
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
generateCopiedPostLocationFromUrl(url) {
|
||||||
|
const urlParts = url.split('/');
|
||||||
|
const pageId = urlParts[urlParts.length - 2];
|
||||||
|
|
||||||
|
return urlParts
|
||||||
|
.slice(0, -4)
|
||||||
|
.concat(pageId)
|
||||||
|
.concat('')
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = PostsService;
|
module.exports = PostsService;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
const {PostsService} = require('../index');
|
const {PostsService} = require('../index');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
|
||||||
describe('Posts Service', function () {
|
describe('Posts Service', function () {
|
||||||
it('Can construct class', function () {
|
it('Can construct class', function () {
|
||||||
@ -39,4 +40,208 @@ describe('Posts Service', function () {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('copyPost', function () {
|
||||||
|
const makeModelStub = (key, value) => ({
|
||||||
|
get(k) {
|
||||||
|
if (k === key) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const POST_ID = 'abc123';
|
||||||
|
|
||||||
|
let postModelStub, existingPostModel, frame;
|
||||||
|
|
||||||
|
const makePostService = () => new PostsService({
|
||||||
|
models: {
|
||||||
|
Post: postModelStub
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
postModelStub = {
|
||||||
|
add: sinon.stub(),
|
||||||
|
findOne: sinon.stub()
|
||||||
|
};
|
||||||
|
|
||||||
|
existingPostModel = {
|
||||||
|
attributes: {
|
||||||
|
id: POST_ID,
|
||||||
|
title: 'Test Post',
|
||||||
|
slug: 'test-post',
|
||||||
|
status: 'published'
|
||||||
|
},
|
||||||
|
related: sinon.stub()
|
||||||
|
};
|
||||||
|
|
||||||
|
frame = {
|
||||||
|
options: {
|
||||||
|
id: POST_ID
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
postModelStub.findOne.withArgs({
|
||||||
|
id: POST_ID,
|
||||||
|
status: 'all'
|
||||||
|
}, frame.options).resolves(existingPostModel);
|
||||||
|
|
||||||
|
postModelStub.add.resolves();
|
||||||
|
|
||||||
|
existingPostModel.related.withArgs('authors').returns([]);
|
||||||
|
existingPostModel.related.withArgs('tags').returns([]);
|
||||||
|
existingPostModel.related.withArgs('posts_meta').returns({
|
||||||
|
isNew: () => true
|
||||||
|
});
|
||||||
|
existingPostModel.related.withArgs('tiers').returns([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a post', async function () {
|
||||||
|
const copiedPost = {
|
||||||
|
attributes: {
|
||||||
|
id: 'def789'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
postModelStub.add.resolves(copiedPost);
|
||||||
|
|
||||||
|
const result = await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
// Ensure copied post is created
|
||||||
|
assert.equal(
|
||||||
|
postModelStub.add.calledOnceWithExactly(
|
||||||
|
sinon.match.object,
|
||||||
|
frame.options
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure copied post is returned
|
||||||
|
assert.deepEqual(result, copiedPost);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits unnecessary data from the copied post', async function () {
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.equal(copiedPostData.id, undefined);
|
||||||
|
assert.equal(copiedPostData.slug, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the title of the copied post', async function () {
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.equal(copiedPostData.title, 'Test Post (Copy)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the status of the copied post', async function () {
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.equal(copiedPostData.status, 'draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds authors to the copied post', async function () {
|
||||||
|
existingPostModel.related.withArgs('authors').returns([
|
||||||
|
makeModelStub('id', 'author-1'),
|
||||||
|
makeModelStub('id', 'author-2')
|
||||||
|
]);
|
||||||
|
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.deepEqual(copiedPostData.authors, [
|
||||||
|
{id: 'author-1'},
|
||||||
|
{id: 'author-2'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds tags to the copied post', async function () {
|
||||||
|
existingPostModel.related.withArgs('tags').returns([
|
||||||
|
makeModelStub('id', 'tag-1'),
|
||||||
|
makeModelStub('id', 'tag-2')
|
||||||
|
]);
|
||||||
|
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.deepEqual(copiedPostData.tags, [
|
||||||
|
{id: 'tag-1'},
|
||||||
|
{id: 'tag-2'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds meta data to the copied post', async function () {
|
||||||
|
const postMetaModel = {
|
||||||
|
attributes: {
|
||||||
|
meta_title: 'Test Post',
|
||||||
|
meta_description: 'Test Post Description'
|
||||||
|
},
|
||||||
|
isNew: () => false
|
||||||
|
};
|
||||||
|
|
||||||
|
existingPostModel.related.withArgs('posts_meta').returns(postMetaModel);
|
||||||
|
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.deepEqual(copiedPostData.posts_meta, postMetaModel.attributes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds tiers to the copied post', async function () {
|
||||||
|
existingPostModel.related.withArgs('tiers').returns([
|
||||||
|
makeModelStub('id', 'tier-1'),
|
||||||
|
makeModelStub('id', 'tier-2')
|
||||||
|
]);
|
||||||
|
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.deepEqual(copiedPostData.tiers, [
|
||||||
|
{id: 'tier-1'},
|
||||||
|
{id: 'tier-2'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits unnecessary meta data from the copied post', async function () {
|
||||||
|
const postMetaModel = {
|
||||||
|
attributes: {
|
||||||
|
post_id: POST_ID,
|
||||||
|
meta_title: 'Test Post',
|
||||||
|
meta_description: 'Test Post Description'
|
||||||
|
},
|
||||||
|
isNew: () => false
|
||||||
|
};
|
||||||
|
|
||||||
|
existingPostModel.related.withArgs('posts_meta').returns(postMetaModel);
|
||||||
|
|
||||||
|
await makePostService().copyPost(frame);
|
||||||
|
|
||||||
|
const copiedPostData = postModelStub.add.getCall(0).args[0];
|
||||||
|
|
||||||
|
assert.deepEqual(copiedPostData.posts_meta, {
|
||||||
|
meta_title: postMetaModel.attributes.meta_title,
|
||||||
|
meta_description: postMetaModel.attributes.meta_description
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCopiedPostLocationFromUrl', function () {
|
||||||
|
it('generates a location from the provided url', function () {
|
||||||
|
const postsService = new PostsService({});
|
||||||
|
const url = 'http://foo.bar/ghost/api/admin/posts/abc123/copy/def456/';
|
||||||
|
const expectedUrl = 'http://foo.bar/ghost/api/admin/posts/def456/';
|
||||||
|
|
||||||
|
assert.equal(postsService.generateCopiedPostLocationFromUrl(url), expectedUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user