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:
Michael Barrett 2023-05-15 09:30:32 +01:00 committed by GitHub
parent 77d7b590bc
commit 59fe794b0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1131 additions and 619 deletions

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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;
}

View 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

View File

@ -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
};

View File

@ -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: [{

View File

@ -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);
}
}
};

View File

@ -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);
}
}
};

View File

@ -197,5 +197,12 @@ module.exports = {
bulkDestroy(apiConfig, frame) {
forcePageFilter(frame);
},
copy(apiConfig, frame) {
debug('copy');
defaultFormat(frame);
defaultRelations(frame);
}
};

View File

@ -220,6 +220,13 @@ module.exports = {
type: 'post'
};
defaultFormat(frame);
defaultRelations(frame);
},
copy(apiConfig, frame) {
debug('copy');
defaultFormat(frame);
defaultRelations(frame);
}

View File

@ -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

View 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",
}
`;

View File

@ -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 [

View 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('/*');
});
});

View File

@ -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('/*');
});
});

View File

@ -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')
});
});
});
});

View File

@ -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']);
});
});
});

View File

@ -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']);
});
});
});

View File

@ -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;

View File

@ -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);
});
});
});