Ghost/ghost/core/test/e2e-api/content/posts.test.js

409 lines
15 KiB
JavaScript
Raw Normal View History

const assert = require('assert/strict');
const cheerio = require('cheerio');
const moment = require('moment');
const testUtils = require('../../utils');
const models = require('../../../core/server/models');
const {agentProvider, fixtureManager, matchers, mockManager} = require('../../utils/e2e-framework');
const {anyArray, anyContentVersion, anyEtag, anyUuid, anyISODateTimeWithTZ} = matchers;
const postMatcher = {
published_at: anyISODateTimeWithTZ,
created_at: anyISODateTimeWithTZ,
updated_at: anyISODateTimeWithTZ,
uuid: anyUuid
};
2023-02-17 12:00:27 +03:00
const postMatcherShallowIncludes = Object.assign(
{},
postMatcher, {
tags: anyArray,
authors: anyArray
}
);
describe('Posts Content API', function () {
let agent;
before(async function () {
// NOTE: can be removed after collections -> GA
mockManager.mockLabsEnabled('collections');
agent = await agentProvider.getContentAPIAgent();
await fixtureManager.init('owner:post', 'users', 'user:inactive', 'posts', 'tags:extra', 'api_keys', 'newsletters', 'members:newsletters');
await agent.authenticate();
Renamed newsletter_id and email_recipient_filter options (#14798) refs https://github.com/TryGhost/Team/issues/1596 - Renamed `newsletter_id` to `newsletter` option, the `newsletter` option expects a slug instead of an id - Renamed `email_recipient_filter` to `email_segment` option - Default `email_segment` to `all`. Ignored if no newsletter is set - `email_segment` is ignored if no newsletter is set - When reverting a post to a draft, both `newsletter` and `email_segment` are reset to their default values (null, all) - Removed legacy mapping from old email_recipient_filter values 'paid' and 'free' (already a migration in place) - Dropped legacy throwing errors when email_recipient_filter is paid or free in transformEmailRecipientFilter - Reorganized transformEmailRecipientFilter parameters for the now required newsletter parameter - Fixed an issue where the newsletter filter wasn't working because it wasn't in permittedoptions - Fixed an issue where you could send to an archived newsletter - Added an extra protection when scheduling to an active, and later archiving the newsletter - Dropped support for `send_email_when_published` in API - When importing posts we currently don't have a system in place to set the newsletter_id to map the `send_email_when_published` behaviour. Since this was already the case, I won't include a fix in this PR. - Stripped `email_recipient_filter`/`email_segment` from Content API (https://ghost.slack.com/archives/C02G9E68C/p1652363211841359?thread_ts=1650623650.233229&cid=C02G9E68C) - Updated `admin-api-schema` to 3.2.0, which includes the new email_segment property - Contains a temporary fix for https://github.com/TryGhost/Team/issues/1626, where the `.related('newsletter').fetch` call fails when the newsletter relation is already loaded, because of the overridden `formatOnWrite` method. Since the `email_recipient_filter` is no longer used without a newsletter, the `none` value is no longer used. A migration transforms all those values to `all`. This should be safe, because we only send an email now when newsletter_id is not null (scheduled posts should already have a newsletter_id, even if at the time of scheduling they didn't add the newsletter_id option, because at that time, we defaulted to the default newsletter). Admin changes to make this work: https://github.com/TryGhost/Admin/pull/2380
2022-05-16 11:18:04 +03:00
// Assign a newsletter to one of the posts
const newsletterId = testUtils.DataGenerator.Content.newsletters[0].id;
const postId = testUtils.DataGenerator.Content.posts[0].id;
await models.Post.edit({newsletter_id: newsletterId}, {id: postId});
});
it('Can request posts', async function () {
const res = await agent.get('posts/')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(11)
.fill(postMatcher)
});
assert.equal(res.body.posts[0].slug, 'welcome', 'Default order "published_at desc" check');
assert.equal(res.body.posts[6].slug, 'integrations', 'Default order "published_at desc" check');
// kitchen sink
assert.equal(res.body.posts[9].slug, fixtureManager.get('posts', 1).slug);
let urlParts = new URL(res.body.posts[9].feature_image);
assert.equal(urlParts.protocol, 'http:');
assert.equal(urlParts.host, '127.0.0.1:2369');
urlParts = new URL(res.body.posts[9].url);
assert.equal(urlParts.protocol, 'http:');
assert.equal(urlParts.host, '127.0.0.1:2369');
const $ = cheerio.load(res.body.posts[9].html);
urlParts = new URL($('img').attr('src'));
assert.equal(urlParts.protocol, 'http:');
assert.equal(urlParts.host, '127.0.0.1:2369');
assert.equal(res.body.posts[7].slug, 'not-so-short-bit-complex');
assert.match(res.body.posts[7].html, /<a href="http:\/\/127.0.0.1:2369\/about#nowhere" title="Relative URL/);
assert.equal(res.body.posts[9].slug, 'ghostly-kitchen-sink');
assert.match(res.body.posts[9].html, /<img src="http:\/\/127.0.0.1:2369\/content\/images\/lol.jpg"/);
});
it('Cannot request mobiledoc or lexical formats', async function () {
await agent
.get(`posts/?formats=mobiledoc,lexical`)
.expectStatus(200)
.matchBodySnapshot({
posts: new Array(11).fill(postMatcher)
});
});
it('Cannot request mobiledoc or lexical fields', async function () {
await agent
.get(`posts/?fields=mobiledoc,lexical,published_at,created_at,updated_at,uuid`)
.expectStatus(200)
.matchBodySnapshot({
posts: new Array(11).fill(postMatcher)
});
});
it('Can filter posts by tag', async function () {
const res = await agent.get('posts/?filter=tag:kitchen-sink,featured:true&include=tags')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(4)
.fill(postMatcher)
});
const jsonResponse = res.body;
const ids = jsonResponse.posts.map(p => p.id);
assert.equal(jsonResponse.posts.length, 4);
assert.deepEqual(ids, [
fixtureManager.get('posts', 4).id,
fixtureManager.get('posts', 2).id,
fixtureManager.get('posts', 1).id,
fixtureManager.get('posts', 0).id
], 'Should have content filtered and ordered');
jsonResponse.posts.forEach((post) => {
if (post.featured) {
assert.equal(post.featured, true, `Each post must either be featured or have the tag 'kitchen-sink'`);
} else {
const tag = post.tags
.map(t => t.slug)
.filter(s => s === 'kitchen-sink')
.pop();
assert.equal(tag, 'kitchen-sink', `Each post must either be featured or have the tag 'kitchen-sink'`);
}
});
});
it('Can filter posts by authors', async function () {
const res = await agent
.get('posts/?filter=authors:[joe-bloggs,pat,ghost,slimer-mcectoplasm]&include=authors')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(11)
.fill(postMatcher)
});
const jsonResponse = res.body;
assert.equal(jsonResponse.posts[0].slug, 'not-so-short-bit-complex', 'The API orders by number of matched authors');
const primaryAuthors = jsonResponse.posts.map((post) => {
return post.primary_author.slug;
});
const ghostPrimaryAuthors = primaryAuthors.filter((value) => {
return value === 'ghost';
});
const joePrimaryAuthors = primaryAuthors.filter((value) => {
return value === 'joe-bloggs';
});
assert.equal(ghostPrimaryAuthors.length, 7, `Each post must either have the author 'joe-bloggs' or 'ghost', 'pat' is non existing author`);
assert.equal(joePrimaryAuthors.length, 4, `Each post must either have the author 'joe-bloggs' or 'ghost', 'pat' is non existing author`);
});
it('Can browse filtering by collection', async function () {
await agent
.get(`posts/?collection=latest`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: Array(11).fill(postMatcher)
});
});
it('Can browse filtering by collection and using paging parameters', async function () {
await agent
.get(`posts/?collection=latest&limit=1&page=2`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: Array(1).fill(postMatcher)
})
.expect((res) => {
// there are total of 11 published posts
assert.equal(res.body.meta.pagination.total, 11);
});
});
it('Can request fields of posts', async function () {
await agent
.get('posts/?&fields=url')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot();
Fixed backward compatibility for `send_email_when_published` (#12357) no-issue * Handled send_email_when_published in Posts API This restores backwards compatibility of the Posts API allowing existing clients to continue to use the `send_email_when_published` flag. This change uses two edits, which is unfortunate. The reason being is that this is an API compatibility issue, not a model issue, so we shouldn't introduce code to the model layer to handle it. The visibility property of the model is used to determine how to fall back, and because it can be left out of the API request, and relies on a default in the settings, we require that the model decide on the `visibility` before we run our fallback logic (or we duplicate the `visibility` default at the cost of maintenance in the future) * Dropped send_email_when_published column from posts Since this column is not used any more, we can drop it from the table. We include an extra migration to repopulate the column in the event of a rollback * Updated importer to handle send_email_when_published Because we currently export this value from Ghost, we should correctly import it. This follows the same logic as the migrations for this value. * Included send_email_when_published in API response As our v3 API documentation includes `send_email_when_published` we must retain backward compatibility by calculating the property. * Fixed fields filter with send_email_when_published * Added safety checks to frame properties Some parts of the code pass a manually created "frame" which is missing lots of properties, so we check for the existence of all of them before using them. * Fixed 3.1 migration to include columnDefinition We require that migrations have all the information they need contained within them as they run in an unknown state of the codebase, which could be from the commit they are introduced, to any future commit. In this case the column definition is removed from the schema in 3.38 and the migration would fail when run in this version or later.
2020-11-11 16:03:41 +03:00
});
it('Can include relations', async function () {
await agent
.get('posts/?include=tags,authors')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(11)
2023-02-17 12:00:27 +03:00
.fill(postMatcherShallowIncludes)
});
});
it('Can request posts from different origin', async function () {
await agent
.get('posts/')
.header('Origin', 'https://example.com')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(11)
.fill(postMatcher)
});
});
it('Can filter by published date', async function () {
function createFilter(publishedAt, op) {
// This line deliberately uses double quotes because GQL cannot handle either double quotes
// or escaped singles, see TryGhost/GQL#34
return encodeURIComponent('published_at:' + op + '\'' + publishedAt + '\'');
}
const res = await agent
.get('posts/?limit=1')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(1)
.fill(postMatcher)
});
const post = res.body.posts[0];
const publishedAt = moment(post.published_at).format('YYYY-MM-DD HH:mm:ss');
const res2 = await agent
.get(`posts/?limit=1&filter=${createFilter(publishedAt, `<`)}`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(1)
.fill(postMatcher)
});
const post2 = res2.body.posts[0];
const publishedAt2 = moment(post2.published_at).format('YYYY-MM-DD HH:mm:ss');
assert.equal(post2.title, 'Customizing your brand and design settings');
const res3 = await agent
.get(`posts/?limit=1&filter=${createFilter(publishedAt2, `>`)}`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(1)
.fill(postMatcher)
});
const post3 = res3.body.posts[0];
assert.equal(post3.title, 'Start here for a quick overview of everything you need to know');
});
it('Can request a single post', async function () {
await agent
.get(`posts/${fixtureManager.get('posts', 0).id}/`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
posts: new Array(1)
.fill(postMatcher)
});
});
it('Can include free and paid tiers for public post', async function () {
const publicPost = testUtils.DataGenerator.forKnex.createPost({
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 agent
.get(`posts/${publicPost.id}/?include=tiers`)
.expectStatus(200);
const publicPostData = publicPostRes.body.posts[0];
publicPostData.tiers.length.should.eql(2);
});
it('Can include free and paid tiers for members only post', async function () {
const membersPost = testUtils.DataGenerator.forKnex.createPost({
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 agent
.get(`posts/${membersPost.id}/?include=tiers`)
.expectStatus(200);
const membersPostData = membersPostRes.body.posts[0];
membersPostData.tiers.length.should.eql(2);
});
it('Can include only paid tier for paid post', async function () {
const paidPost = testUtils.DataGenerator.forKnex.createPost({
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 agent
.get(`posts/${paidPost.id}/?include=tiers`)
.expectStatus(200);
const paidPostData = paidPostRes.body.posts[0];
paidPostData.tiers.length.should.eql(1);
});
it('Can include specific tier for post with tiers visibility', async function () {
const res = await agent
.get(`tiers/`)
.expectStatus(200);
const jsonResponse = res.body;
const paidTier = jsonResponse.tiers.find(p => p.type === 'paid');
const tiersPost = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-shalt-be-for-specific-tiers',
visibility: 'tiers',
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
});
tiersPost.tiers = [paidTier];
await models.Post.add(tiersPost, {context: {internal: true}});
const tiersPostRes = await agent
.get(`posts/${tiersPost.id}/?include=tiers`)
.expectStatus(200);
const tiersPostData = tiersPostRes.body.posts[0];
tiersPostData.tiers.length.should.eql(1);
});
it('Can use post excerpt as field', async function () {
await agent
.get(`posts/?fields=excerpt`)
.expectStatus(200)
.matchBodySnapshot();
});
it('Can use post plaintext as field', async function () {
await agent
.get(`posts/?fields=plaintext`)
.expectStatus(200)
.matchBodySnapshot();
});
it('Adds ?ref tags', async function () {
const post = await models.Post.add({
title: 'title',
status: 'published',
slug: 'add-ref-tags',
mobiledoc: JSON.stringify({version: '0.3.1',atoms: [],cards: [['html',{html: '<a href="https://example.com">Link</a><a href="invalid">Test</a>'}]],markups: [],sections: [[10,0],[1,'p',[]]],ghostVersion: '4.0'})
}, {context: {internal: true}});
let response = await agent
.get(`posts/${post.id}/`)
.expectStatus(200);
assert(response.body.posts[0].html.includes('<a href="https://example.com/?ref=127.0.0.1">Link</a><a href="invalid">Test</a>'), 'Html not expected (should contain ?ref): ' + response.body.posts[0].html);
// Disable outbound link tracking
mockManager.mockSetting('outbound_link_tagging', false);
response = await agent
.get(`posts/${post.id}/`)
.expectStatus(200);
assert(response.body.posts[0].html.includes('<a href="https://example.com">Link</a><a href="invalid">Test</a>'), 'Html not expected: ' + response.body.posts[0].html);
});
});