Ghost/ghost/core/test/e2e-frontend/helpers/get.test.js
Fabien 'egg' O'Carroll 6a35f6e4cc
Fixed get helper cache optimizations (#19865)
ref [ENG-747](https://linear.app/tryghost/issue/ENG-747/)
ref https://linear.app/tryghost/issue/ENG-747

H'okay - so what we're trying to do here is make get helper queries more
cacheable. The way we're doing that is by modifying the filter used when
we're trying to remove a single post from the query.

The idea is that we can remove that restriction on the filter, increase
the number of posts fetched by 1 and then filter the fetched posts back
down, this means that the same query, but filtering different posts,
will be updated to make _exactly_ the same query, and so share a cache!

We've been purposefully restrictive in the types of filters we
manipulate, so that we only deal with the simplest cases and the code is
easier to understand.
2024-03-15 00:18:15 +07:00

527 lines
18 KiB
JavaScript

const assert = require('assert/strict');
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../utils');
const models = require('../../../core/server/models/index');
const DEFAULT_POST_FIXTURE_COUNT = 7;
const get = require('../../../core/frontend/helpers/get');
async function createPost(data) {
const post = testUtils.DataGenerator.forKnex.createPost(data);
await models.Post.add(post, {context: {internal: true}});
return post;
}
function buildMember(status, products = []) {
return {
uuid: '1234',
email: 'test@example.com',
name: 'John Doe',
firstname: 'John',
avatar_image: null,
subscriptions: [],
paid: status !== 'free',
status: status,
products
};
}
function testPosts(posts, map) {
posts.should.be.an.Array();
posts.length.should.eql(DEFAULT_POST_FIXTURE_COUNT + Object.keys(map).length);
// Free post
for (const postID in map) {
const expectData = map[postID];
const post = posts.find(p => p.id === postID);
should.exist(post);
post.should.match(expectData);
}
}
describe('e2e {{#get}} helper', function () {
let fn;
let inverse;
let locals = {};
let publicPost, membersPost, paidPost, basicTierPost;
before(async function () {
await testUtils.startGhost({
backend: true,
frontend: false
});
publicPost = await createPost({
slug: 'free-to-see',
visibility: 'public',
published_at: new Date() // here to ensure sorting is not modified
});
membersPost = await createPost({
slug: 'members-post',
visibility: 'members',
published_at: new Date() // here to ensure sorting is not modified
});
paidPost = await createPost({
slug: 'paid-to-see',
visibility: 'paid',
published_at: new Date() // here to ensure sorting is not modified
});
const defaultTier = await models.Product.findOne({slug: 'default-product'});
basicTierPost = await createPost({
slug: 'tiers-post',
visibility: 'tiers',
tiers: [{
id: defaultTier.get('id')
}],
published_at: new Date() // here to ensure sorting is not modified
});
});
// Assert fixtures are correct
it('has valid fixtures', function () {
publicPost.visibility.should.eql('public');
membersPost.visibility.should.eql('members');
paidPost.visibility.should.eql('paid');
basicTierPost.visibility.should.eql('tiers');
});
beforeEach(function () {
fn = sinon.spy();
inverse = sinon.spy();
locals = {root: {_locals: {}}};
});
describe('Filter optimisation', function () {
it('Does not do filter optimisation on OR queries', async function () {
await get.call({}, 'posts', {
hash: {
filter: `tag:-hash-hidden,id:-${publicPost.id}`,
limit: '5'
},
data: {},
locals,
fn,
inverse
});
assert.notEqual(fn.firstCall.args[0].meta.cacheabilityOptimisation, true);
});
it('Does not do filter optimisation on nested id queries', async function () {
await get.call({}, 'posts', {
hash: {
filter: `tag:-hash-hidden+(id:-${publicPost.id},id:-${membersPost.id})`,
limit: '5'
},
data: {},
locals,
fn,
inverse
});
assert.notEqual(fn.firstCall.args[0].meta.cacheabilityOptimisation, true);
});
it('Does not do filter optimisation on multiple negated id queries', async function () {
await get.call({}, 'posts', {
hash: {
filter: `id:-${publicPost.id}+id:-${membersPost.id}`,
limit: '5'
},
data: {},
locals,
fn,
inverse
});
assert.notEqual(fn.firstCall.args[0].meta.cacheabilityOptimisation, true);
});
it('Returns the correct posts with limit as a string', async function () {
await get.call({}, 'posts', {
hash: {
limit: '5'
},
data: {},
locals,
fn,
inverse
});
const firstPostUsually = fn.firstCall.args[0].posts[0];
const firstLimit = fn.firstCall.args[0].meta.pagination.limit;
assert.equal(firstLimit, 5);
await get.call({}, 'posts', {
hash: {
filter: `id:-${firstPostUsually.id}`,
limit: '5'
},
data: {},
locals,
fn,
inverse
});
assert.equal(fn.secondCall.args[0].meta.cacheabilityOptimisation, true);
const foundFilteredPost = fn.secondCall.args[0].posts.find(post => post.id === firstPostUsually.id);
assert.equal(foundFilteredPost, undefined);
});
it('Returns the correct posts with default limit', async function () {
await get.call({}, 'posts', {
hash: {},
data: {},
locals,
fn,
inverse
});
const firstPostUsually = fn.firstCall.args[0].posts[0];
const defaultLimit = fn.firstCall.args[0].meta.pagination.limit;
await get.call({}, 'posts', {
hash: {
filter: `id:-${firstPostUsually.id}`
},
data: {},
locals,
fn,
inverse
});
assert.equal(fn.secondCall.args[0].meta.pagination.limit, defaultLimit);
assert.equal(fn.secondCall.args[0].meta.cacheabilityOptimisation, true);
const foundFilteredPost = fn.secondCall.args[0].posts.find(post => post.id === firstPostUsually.id);
assert.equal(foundFilteredPost, undefined);
});
it('Returns the correct posts with a limit all', async function () {
await get.call({}, 'posts', {
hash: {
limit: 'all'
},
data: {},
locals,
fn,
inverse
});
const firstPostUsually = fn.firstCall.args[0].posts[0];
const initialCount = fn.firstCall.args[0].posts.length;
await get.call({}, 'posts', {
hash: {
filter: `id:-${firstPostUsually.id}`,
limit: 'all'
},
data: {},
locals,
fn,
inverse
});
assert.equal(fn.secondCall.args[0].posts.length, initialCount - 1);
assert.equal(fn.secondCall.args[0].meta.pagination.limit, 'all');
assert.equal(fn.secondCall.args[0].meta.cacheabilityOptimisation, true);
const foundFilteredPost = fn.secondCall.args[0].posts.find(post => post.id === firstPostUsually.id);
assert.equal(foundFilteredPost, undefined);
});
it('Returns the correct posts with a solo negative filter', async function () {
await get.call({}, 'posts', {
hash: {
limit: 1
},
data: {},
locals,
fn,
inverse
});
const firstPostUsually = fn.firstCall.args[0].posts[0];
await get.call({}, 'posts', {
hash: {
filter: `id:-${firstPostUsually.id}`,
limit: 5
},
data: {},
locals,
fn,
inverse
});
assert.equal(fn.secondCall.args[0].posts.length, 5);
assert.equal(fn.secondCall.args[0].meta.pagination.limit, 5);
assert.equal(fn.secondCall.args[0].meta.cacheabilityOptimisation, true);
const foundFilteredPost = fn.secondCall.args[0].posts.find(post => post.id === firstPostUsually.id);
assert.equal(foundFilteredPost, undefined);
});
it('Returns the correct posts with a sandwiched negative filter', async function () {
await get.call({}, 'posts', {
hash: {
filter: `tag:-hash-hidden+visibility:public`,
limit: 1
},
data: {},
locals,
fn,
inverse
});
const firstPostUsually = fn.firstCall.args[0].posts[0];
await get.call({}, 'posts', {
hash: {
filter: `visibility:public+id:-${firstPostUsually.id}+tag:-hash-hidden`,
limit: 5
},
data: {},
locals,
fn,
inverse
});
assert.equal(fn.secondCall.args[0].posts.length, 5);
assert.equal(fn.secondCall.args[0].meta.pagination.limit, 5);
assert.equal(fn.secondCall.args[0].meta.cacheabilityOptimisation, true);
const foundFilteredPost = fn.secondCall.args[0].posts.find(post => post.id === firstPostUsually.id);
assert.equal(foundFilteredPost, undefined);
});
it('Returns the correct posts with a prefix negative filter', async function () {
await get.call({}, 'posts', {
hash: {
filter: `tag:-hash-hidden`,
limit: 1
},
data: {},
locals,
fn,
inverse
});
const firstPostUsually = fn.firstCall.args[0].posts[0];
await get.call({}, 'posts', {
hash: {
filter: `id:-${firstPostUsually.id}+tag:-hash-hidden`,
limit: 5
},
data: {},
locals,
fn,
inverse
});
assert.equal(fn.secondCall.args[0].posts.length, 5);
assert.equal(fn.secondCall.args[0].meta.pagination.limit, 5);
assert.equal(fn.secondCall.args[0].meta.cacheabilityOptimisation, true);
const foundFilteredPost = fn.secondCall.args[0].posts.find(post => post.id === firstPostUsually.id);
assert.equal(foundFilteredPost, undefined);
});
it('Returns the correct posts with a suffix negative filter', async function () {
await get.call({}, 'posts', {
hash: {
filter: `tag:-hash-hidden`,
limit: 1
},
data: {},
locals,
fn,
inverse
});
const firstPostUsually = fn.firstCall.args[0].posts[0];
await get.call({}, 'posts', {
hash: {
filter: `tag:-hash-hidden+id:-${firstPostUsually.id}`,
limit: 5
},
data: {},
locals,
fn,
inverse
});
assert.equal(fn.secondCall.args[0].posts.length, 5);
assert.equal(fn.secondCall.args[0].meta.pagination.limit, 5);
assert.equal(fn.secondCall.args[0].meta.cacheabilityOptimisation, true);
const foundFilteredPost = fn.secondCall.args[0].posts.find(post => post.id === firstPostUsually.id);
assert.equal(foundFilteredPost, undefined);
});
});
describe('{{access}} property', function () {
let member;
it('not authenticated member', async function () {
member = null;
locals = {root: {_locals: {}}, member};
await get.call(
{},
'posts',
{hash: {}, data: locals, fn: fn, inverse: inverse}
);
testPosts(fn.firstCall.args[0].posts, {
[publicPost.id]: {
access: true
},
[membersPost.id]: {
access: false
},
[paidPost.id]: {
access: false
},
[basicTierPost.id]: {
access: false
}
});
});
it('free member', async function () {
member = buildMember('free');
locals = {root: {_locals: {}}, member};
await get.call(
{},
'posts',
{hash: {}, data: locals, fn: fn, inverse: inverse}
);
testPosts(fn.firstCall.args[0].posts, {
[publicPost.id]: {
access: true
},
[membersPost.id]: {
access: true
},
[paidPost.id]: {
access: false
},
[basicTierPost.id]: {
access: false
}
});
});
it('paid member', async function () {
member = buildMember('paid');
locals = {root: {_locals: {}}, member};
await get.call(
{},
'posts',
{hash: {}, data: locals, fn: fn, inverse: inverse}
);
testPosts(fn.firstCall.args[0].posts, {
[publicPost.id]: {
access: true
},
[membersPost.id]: {
access: true
},
[paidPost.id]: {
access: true
},
[basicTierPost.id]: {
access: false
}
});
});
it('comped member', async function () {
member = buildMember('comped');
locals = {root: {_locals: {}}, member};
await get.call(
{},
'posts',
{hash: {}, data: locals, fn: fn, inverse: inverse}
);
testPosts(fn.firstCall.args[0].posts, {
[publicPost.id]: {
access: true
},
[membersPost.id]: {
access: true
},
[paidPost.id]: {
access: true
},
[basicTierPost.id]: {
access: false
}
});
});
/**
* When using the get helper, you need to include tiers to properly determine {{access}} for posts with specific tiers
*/
it('tiers member not including tiers', async function () {
member = buildMember('paid', [{
name: 'Default Product',
slug: 'default-product',
type: 'paid',
active: true
}]);
locals = {root: {_locals: {}}, member};
await get.call(
{},
'posts',
{hash: {}, data: locals, fn: fn, inverse: inverse}
);
testPosts(fn.firstCall.args[0].posts, {
[publicPost.id]: {
access: true
},
[membersPost.id]: {
access: true
},
[paidPost.id]: {
access: true
},
[basicTierPost.id]: {
access: false
}
});
});
it('tiers member including tiers', async function () {
member = buildMember('paid', [{
name: 'Default Product',
slug: 'default-product',
type: 'paid',
active: true
}]);
locals = {root: {_locals: {}}, member};
await get.call(
{},
'posts',
{hash: {include: 'tiers'}, data: locals, fn: fn, inverse: inverse}
);
testPosts(fn.firstCall.args[0].posts, {
[publicPost.id]: {
access: true
},
[membersPost.id]: {
access: true
},
[paidPost.id]: {
access: true
},
[basicTierPost.id]: {
access: true
}
});
});
it('tiers member with different product', async function () {
member = buildMember('paid', [{
name: 'Default Product',
slug: 'pro-product',
type: 'paid',
active: true
}]);
locals = {root: {_locals: {}}, member};
await get.call(
{},
'posts',
{hash: {include: 'tiers'}, data: locals, fn: fn, inverse: inverse}
);
testPosts(fn.firstCall.args[0].posts, {
[publicPost.id]: {
access: true
},
[membersPost.id]: {
access: true
},
[paidPost.id]: {
access: true
},
[basicTierPost.id]: {
access: false
}
});
});
});
});