✨ 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 }}
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
{{else}}
|
||||
@ -35,6 +35,13 @@
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#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>
|
||||
<button class="mr2" type="button" {{on "click" this.deletePosts}}>
|
||||
<span class="red">{{svg-jar "trash"}}Delete</span>
|
||||
|
@ -39,11 +39,16 @@ const messages = {
|
||||
tagAdded: {
|
||||
single: 'Tag added successfully',
|
||||
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 {
|
||||
@service ajax;
|
||||
@service feature;
|
||||
@service ghostPaths;
|
||||
@service session;
|
||||
@service infinity;
|
||||
@ -116,6 +121,11 @@ export default class PostsContextMenu extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async copyPosts() {
|
||||
this.menu.performTask(this.copyPostsTask);
|
||||
}
|
||||
|
||||
@task
|
||||
*addTagToPostsTask(tags) {
|
||||
const updatedModels = this.selectionList.availableModels;
|
||||
@ -366,6 +376,29 @@ export default class PostsContextMenu extends Component {
|
||||
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() {
|
||||
const filter = this.selectionList.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() {
|
||||
let featuredCount = 0;
|
||||
for (const m of this.selectionList.availableModels) {
|
||||
@ -412,4 +451,12 @@ export default class PostsContextMenu extends Component {
|
||||
}
|
||||
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 {
|
||||
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 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) {
|
||||
const protocol = (frame.original.url.secure === false) ? 'http://' : 'https://';
|
||||
@ -132,8 +133,13 @@ module.exports = {
|
||||
if (!locationURL.endsWith('/')) {
|
||||
locationURL += '/';
|
||||
}
|
||||
|
||||
locationURL += `${resourceId}/`;
|
||||
|
||||
if (hasLocationResolver) {
|
||||
locationURL = apiConfigHeaders.location.resolve(locationURL);
|
||||
}
|
||||
|
||||
const locationHeader = {
|
||||
Location: locationURL
|
||||
};
|
||||
|
@ -102,7 +102,7 @@ describe('Headers', 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 = {
|
||||
posts: [{
|
||||
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 () {
|
||||
const apiResult = {
|
||||
posts: [{
|
||||
|
@ -238,5 +238,30 @@ module.exports = {
|
||||
query(frame) {
|
||||
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) {
|
||||
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) {
|
||||
forcePageFilter(frame);
|
||||
},
|
||||
|
||||
copy(apiConfig, frame) {
|
||||
debug('copy');
|
||||
|
||||
defaultFormat(frame);
|
||||
defaultRelations(frame);
|
||||
}
|
||||
};
|
||||
|
@ -220,6 +220,13 @@ module.exports = {
|
||||
type: 'post'
|
||||
};
|
||||
|
||||
defaultFormat(frame);
|
||||
defaultRelations(frame);
|
||||
},
|
||||
|
||||
copy(apiConfig, frame) {
|
||||
debug('copy');
|
||||
|
||||
defaultFormat(frame);
|
||||
defaultRelations(frame);
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ module.exports = function apiRoutes() {
|
||||
router.get('/posts/slug/:slug', mw.authAdminApi, http(api.posts.read));
|
||||
router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit));
|
||||
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));
|
||||
|
||||
@ -49,6 +50,7 @@ module.exports = function apiRoutes() {
|
||||
router.get('/pages/slug/:slug', mw.authAdminApi, http(api.pages.read));
|
||||
router.put('/pages/:id', mw.authAdminApi, http(api.pages.edit));
|
||||
router.del('/pages/:id', mw.authAdminApi, http(api.pages.destroy));
|
||||
router.post('/pages/:id/copy', mw.authAdminApi, http(api.pages.copy));
|
||||
|
||||
// # 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 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`] = `
|
||||
exports[`Posts API Copy Can copy a post 1: [body] 1`] = `
|
||||
Object {
|
||||
"posts": Array [
|
||||
Object {
|
||||
@ -576,27 +455,26 @@ Object {
|
||||
"email_only": false,
|
||||
"email_segment": "all",
|
||||
"email_subject": null,
|
||||
"excerpt": "Testing post creation with lexical",
|
||||
"excerpt": null,
|
||||
"feature_image": null,
|
||||
"feature_image_alt": null,
|
||||
"feature_image_caption": null,
|
||||
"featured": false,
|
||||
"frontmatter": null,
|
||||
"html": "<p>Testing post creation with lexical</p>",
|
||||
"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_title": null,
|
||||
"mobiledoc": null,
|
||||
"mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}",
|
||||
"newsletter": null,
|
||||
"og_description": null,
|
||||
"og_image": null,
|
||||
"og_title": null,
|
||||
"post_revisions": Any<Array>,
|
||||
"primary_author": Any<Object>,
|
||||
"primary_tag": Any<Object>,
|
||||
"published_at": null,
|
||||
"reading_time": 0,
|
||||
"slug": "post-revisions-test",
|
||||
"slug": "test-post-copy",
|
||||
"status": "draft",
|
||||
"tags": Any<Array>,
|
||||
"tiers": Array [
|
||||
@ -637,7 +515,7 @@ Object {
|
||||
"yearly_price_id": null,
|
||||
},
|
||||
],
|
||||
"title": "Post Revisions Test",
|
||||
"title": "Test Post (Copy)",
|
||||
"twitter_description": null,
|
||||
"twitter_image": 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 {
|
||||
"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": "4018",
|
||||
"content-length": "3702",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"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`] = `
|
||||
Object {
|
||||
"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 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');
|
||||
const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {anyArray, anyContentVersion, anyEtag, anyLocationFor, anyObject, anyObjectId, anyISODateTime, anyString, anyUuid} = matchers;
|
||||
|
||||
const tierSnapshot = {
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime
|
||||
};
|
||||
|
||||
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 () {
|
||||
let request;
|
||||
let agent;
|
||||
|
||||
before(async function () {
|
||||
await localUtils.startGhost();
|
||||
request = supertest.agent(config.get('url'));
|
||||
await localUtils.doAuth(request, 'users:extra', 'posts');
|
||||
agent = await agentProvider.getAdminAPIAgent();
|
||||
await fixtureManager.init('posts');
|
||||
await agent.loginAsOwner();
|
||||
});
|
||||
|
||||
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/`);
|
||||
afterEach(function () {
|
||||
mockManager.restore();
|
||||
});
|
||||
|
||||
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);
|
||||
describe('Copy', function () {
|
||||
it('Can copy a page', async function () {
|
||||
const page = {
|
||||
title: 'Test Page',
|
||||
status: 'published'
|
||||
};
|
||||
|
||||
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
|
||||
const {body: pageBody} = await agent
|
||||
.post('/pages/?formats=mobiledoc,lexical,html', {
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
},
|
||||
lastSaved: 1663081361393,
|
||||
source: 'Playground',
|
||||
version: '0.4.1'
|
||||
})
|
||||
};
|
||||
})
|
||||
.body({pages: [page]})
|
||||
.expectStatus(201);
|
||||
|
||||
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 [pageResponse] = pageBody.pages;
|
||||
|
||||
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 agent
|
||||
.post(`/pages/${pageResponse.id}/copy?formats=mobiledoc,lexical`)
|
||||
.expectStatus(201)
|
||||
.matchBodySnapshot({
|
||||
pages: [Object.assign(matchPageShallowIncludes, {published_at: null})]
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag,
|
||||
location: anyLocationFor('pages')
|
||||
});
|
||||
});
|
||||
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'}]);
|
||||
});
|
||||
});
|
||||
|
||||
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 errors = require('@tryghost/errors');
|
||||
const ObjectId = require('bson-objectid').default;
|
||||
const omit = require('lodash/omit');
|
||||
|
||||
const messages = {
|
||||
invalidVisibilityFilter: 'Invalid visibility filter.',
|
||||
@ -365,6 +366,75 @@ class PostsService {
|
||||
|
||||
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;
|
||||
|
@ -1,5 +1,6 @@
|
||||
const {PostsService} = require('../index');
|
||||
const assert = require('assert');
|
||||
const sinon = require('sinon');
|
||||
|
||||
describe('Posts Service', 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