mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Added experimental headers to allow caching members content (#20200)
ref https://linear.app/tryghost/issue/KTLO-45/deploy-members-caching-solution-to-a-single-site-to-validate-and-test Currently we only cache publicly available content. Any content that is accessed by a logged in member is only cached for that specific member based on their cookie. As a result, almost all requests from logged in members bypass our caching layer and reach Ghost, which adds unnecessary load to Ghost and its database. This change adds experimental headers that allow our CDN to understand which tier to cache the content against, and securely tell the CDN which tier a logged in member has access to. With these changes, we can cache the member content against the tier, rather than the individual member, which should result in a higher cache HIT ratio and reduce the load on Ghost. For requests to the frontend of the site, Ghost will set a custom `X-Member-Cache-Tier` header to the ID of the tier of the member who is accessing the content. This tells the CDN which tier to cache the content against. For requests to either `/members/?token=...` endpoint (the magic link endpoint) or `/members/api/member`, Ghost will set a `ghost-access` and `ghost-access-hmac` cookie with the ID of the tier of the logged in member. With these two pieces of information, our CDN can serve cached content to logged in members. These headers are experimental, and can only be enabled via Ghost's config. To enable these headers, set `cacheMembersContent:enabled` to `true` and provide an HMAC key in `cacheMembersContent:hmacSecret`.
This commit is contained in:
parent
d302d4049c
commit
98d49f57d5
8 changed files with 382 additions and 9 deletions
77
ghost/core/core/frontend/web/middleware/frontend-caching.js
Normal file
77
ghost/core/core/frontend/web/middleware/frontend-caching.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* @file Middleware to set the appropriate cache headers on the frontend
|
||||
*/
|
||||
const config = require('../../../shared/config');
|
||||
const shared = require('../../../server/web/shared');
|
||||
const {api} = require('../../services/proxy');
|
||||
|
||||
/**
|
||||
* Calculate the member's active tier.
|
||||
* @param {object} member - The member object.
|
||||
* @param {object} freeTier - The free tier object.
|
||||
* @returns {string|null} - The member's active tier, or null if the member has more than one active subscription.
|
||||
*/
|
||||
function calculateMemberTier(member, freeTier) {
|
||||
const activeSubscriptions = member.subscriptions.filter(sub => sub.status === 'active');
|
||||
if (activeSubscriptions.length === 0) {
|
||||
return freeTier;
|
||||
}
|
||||
if (activeSubscriptions.length === 1) {
|
||||
return activeSubscriptions[0].tier;
|
||||
}
|
||||
return null; // More than one active subscription
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {function(): Promise<object>} GetFreeTier
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the frontend caching middleware.
|
||||
* @param {GetFreeTier} [getFreeTier] - Async function that takes no arguments and resolves to the free tier object.
|
||||
* @returns {function} Middleware function.
|
||||
*/
|
||||
const getMiddleware = async (getFreeTier = async () => {
|
||||
const {tiers} = await api.tiers.browse();
|
||||
return tiers.find(tier => tier.type === 'free');
|
||||
}) => {
|
||||
const freeTier = await getFreeTier();
|
||||
/**
|
||||
* Middleware to set cache headers based on site configuration and request properties.
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
function setFrontendCacheHeadersMiddleware(req, res, next) {
|
||||
// Caching member's content is an experimental feature, enabled via config
|
||||
const shouldCacheMembersContent = config.get('cacheMembersContent:enabled');
|
||||
// CASE: Never cache if the blog is set to private
|
||||
// CASE: Never cache if the request is made by a member and the site is not configured to cache members content
|
||||
if (res.isPrivateBlog || (req.member && !shouldCacheMembersContent)) {
|
||||
return shared.middleware.cacheControl('private')(req, res, next);
|
||||
}
|
||||
|
||||
// CASE: Cache member's content if this feature is enabled
|
||||
if (req.member && shouldCacheMembersContent) {
|
||||
// Set the 'cache-control' header to 'public'
|
||||
const memberTier = calculateMemberTier(req.member, freeTier);
|
||||
if (!memberTier) {
|
||||
// Member has more than one active subscription, don't cache the content
|
||||
return shared.middleware.cacheControl('private')(req, res, next);
|
||||
}
|
||||
// The member is either on the free tier or has a single active subscription
|
||||
// Cache the content based on the member's tier
|
||||
res.set({'X-Member-Cache-Tier': memberTier.id});
|
||||
return shared.middleware.cacheControl('public', {maxAge: config.get('caching:frontend:maxAge')})(req, res, next);
|
||||
}
|
||||
// CASE: Site is not private and the request is not made by a member — cache the content
|
||||
return shared.middleware.cacheControl('public', {maxAge: config.get('caching:frontend:maxAge')})(req, res, next);
|
||||
}
|
||||
|
||||
return setFrontendCacheHeadersMiddleware;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMiddleware,
|
||||
calculateMemberTier // exported for testing
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
module.exports = {
|
||||
cors: require('./cors'),
|
||||
errorHandler: require('./error-handler'),
|
||||
frontendCaching: require('./frontend-caching'),
|
||||
handleImageSizes: require('./handle-image-sizes'),
|
||||
redirectGhostToAdmin: require('./redirect-ghost-to-admin'),
|
||||
serveFavicon: require('./serve-favicon'),
|
||||
|
|
|
@ -169,12 +169,12 @@ module.exports = function setupSiteApp(routerConfig) {
|
|||
siteApp.use(shared.middleware.prettyUrls);
|
||||
|
||||
// ### Caching
|
||||
siteApp.use(function frontendCaching(req, res, next) {
|
||||
// Site frontend is cacheable UNLESS request made by a member or site is in private mode
|
||||
if (req.member || res.isPrivateBlog) {
|
||||
return shared.middleware.cacheControl('private')(req, res, next);
|
||||
} else {
|
||||
return shared.middleware.cacheControl('public', {maxAge: config.get('caching:frontend:maxAge')})(req, res, next);
|
||||
siteApp.use(async function frontendCaching(req, res, next) {
|
||||
try {
|
||||
const middleware = await mw.frontendCaching.getMiddleware();
|
||||
return middleware(req, res, next);
|
||||
} catch {
|
||||
return next();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
const crypto = require('crypto');
|
||||
const _ = require('lodash');
|
||||
const logging = require('@tryghost/logging');
|
||||
const membersService = require('./service');
|
||||
|
@ -11,12 +12,65 @@ const {
|
|||
} = require('./utils');
|
||||
const errors = require('@tryghost/errors');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
const onHeaders = require('on-headers');
|
||||
const tiersService = require('../tiers/service');
|
||||
const config = require('../../../shared/config');
|
||||
|
||||
const messages = {
|
||||
missingUuid: 'Missing uuid.',
|
||||
invalidUuid: 'Invalid uuid.'
|
||||
};
|
||||
|
||||
const getFreeTier = async function getFreeTier() {
|
||||
const response = await tiersService.api.browse();
|
||||
const freeTier = response.data.find(tier => tier.type === 'free');
|
||||
return freeTier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the ghost-access and ghost-access-hmac cookies on the response object
|
||||
* @param {object} member - The member object
|
||||
* @param {import('express').Response} res - The express response object to set the cookies on
|
||||
* @returns
|
||||
*/
|
||||
const setAccessCookies = function setAccessCookies(member = undefined, res, freeTier) {
|
||||
if (!member) {
|
||||
const accessCookie = `ghost-access=null; Max-Age=0; Path=/; HttpOnly; SameSite=Strict;`;
|
||||
const hmacCookie = `ghost-access-hmac=null; Max-Age=0; Path=/; HttpOnly; SameSite=Strict;`;
|
||||
const existingCookies = res.getHeader('Set-Cookie') || [];
|
||||
const cookiesToSet = [accessCookie, hmacCookie].concat(existingCookies);
|
||||
|
||||
res.setHeader('Set-Cookie', cookiesToSet);
|
||||
return;
|
||||
}
|
||||
const hmacSecret = config.get('cacheMembersContent:hmacSecret');
|
||||
if (!hmacSecret) {
|
||||
return;
|
||||
}
|
||||
const activeSubscription = member.subscriptions?.find(sub => sub.status === 'active');
|
||||
|
||||
const cookieTimestamp = Math.floor(Date.now() / 1000); // to mitigate a cookie replay attack
|
||||
const memberTier = activeSubscription && activeSubscription.tier.id || freeTier.id;
|
||||
const memberTierAndTimestamp = `${memberTier}:${cookieTimestamp}`;
|
||||
const memberTierHmac = crypto.createHmac('sha256', hmacSecret).update(memberTierAndTimestamp).digest('hex');
|
||||
|
||||
const maxAge = 3600;
|
||||
const accessCookie = `ghost-access=${memberTierAndTimestamp}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict;`;
|
||||
const hmacCookie = `ghost-access-hmac=${memberTierHmac}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict;`;
|
||||
|
||||
const existingCookies = res.getHeader('Set-Cookie') || [];
|
||||
const cookiesToSet = [accessCookie, hmacCookie].concat(existingCookies);
|
||||
res.setHeader('Set-Cookie', cookiesToSet);
|
||||
};
|
||||
|
||||
const accessInfoSession = async function accessInfoSession(req, res, next) {
|
||||
const freeTier = await getFreeTier();
|
||||
onHeaders(res, function () {
|
||||
setAccessCookies(req.member, res, freeTier);
|
||||
});
|
||||
next();
|
||||
};
|
||||
|
||||
// @TODO: This piece of middleware actually belongs to the frontend, not to the member app
|
||||
// Need to figure a way to separate these things (e.g. frontend actually talks to members API)
|
||||
const loadMemberSession = async function loadMemberSession(req, res, next) {
|
||||
|
@ -242,6 +296,16 @@ const createSessionFromMagicLink = async function createSessionFromMagicLink(req
|
|||
// Note: don't reset 'member_login', or that would give an easy way around user enumeration by logging in to a manually created account
|
||||
const subscriptions = member && member.subscriptions || [];
|
||||
|
||||
if (config.get('cacheMembersContent:enabled')) {
|
||||
// Set the ghost-access cookies to enable tier-based caching
|
||||
try {
|
||||
const freeTier = await getFreeTier();
|
||||
setAccessCookies(member, res, freeTier);
|
||||
} catch {
|
||||
// This is a non-critical operation, so we can safely ignore any errors
|
||||
}
|
||||
}
|
||||
|
||||
const action = req.query.action;
|
||||
|
||||
if (action === 'signup' || action === 'signup-paid' || action === 'subscribe') {
|
||||
|
@ -322,5 +386,6 @@ module.exports = {
|
|||
updateMemberData,
|
||||
updateMemberNewsletters,
|
||||
deleteSession,
|
||||
accessInfoSession,
|
||||
deleteSuppression
|
||||
};
|
||||
|
|
|
@ -45,7 +45,14 @@ module.exports = function setupMembersApp() {
|
|||
membersApp.put('/api/member/newsletters', bodyParser.json({limit: '50mb'}), middleware.updateMemberNewsletters);
|
||||
|
||||
// Get and update member data
|
||||
membersApp.get('/api/member', middleware.getMemberData);
|
||||
// Caching members content is an experimental feature
|
||||
const shouldCacheMembersContent = config.get('cacheMembersContent:enabled');
|
||||
if (shouldCacheMembersContent) {
|
||||
membersApp.get('/api/member', middleware.loadMemberSession, middleware.accessInfoSession, middleware.getMemberData);
|
||||
} else {
|
||||
membersApp.get('/api/member', middleware.getMemberData);
|
||||
}
|
||||
|
||||
membersApp.put('/api/member', bodyParser.json({limit: '50mb'}), middleware.updateMemberData);
|
||||
membersApp.post('/api/member/email', bodyParser.json({limit: '50mb'}), (req, res) => membersService.api.middleware.updateEmailAddress(req, res));
|
||||
|
||||
|
|
|
@ -357,6 +357,110 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Comments API when caching members content is enabled can get member data 1: [body] 1`] = `
|
||||
Object {
|
||||
"avatar_image": null,
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"email": "member@example.com",
|
||||
"email_suppression": Object {
|
||||
"info": null,
|
||||
"suppressed": false,
|
||||
},
|
||||
"enable_comment_notifications": true,
|
||||
"expertise": null,
|
||||
"firstname": null,
|
||||
"name": null,
|
||||
"newsletters": Array [
|
||||
Object {
|
||||
"description": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"name": "Default Newsletter",
|
||||
"sort_order": 0,
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
},
|
||||
Object {
|
||||
"description": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"name": "Weekly newsletter",
|
||||
"sort_order": 2,
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
},
|
||||
],
|
||||
"paid": false,
|
||||
"subscribed": false,
|
||||
"subscriptions": Array [],
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Comments API when caching members content is enabled can get member data 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "621",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"set-cookie": Array [
|
||||
StringMatching /\\^ghost-access=free:/,
|
||||
StringMatching /\\^ghost-access-hmac=/,
|
||||
],
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Comments API when caching members content is enabled sets ghost-access and ghost-access-hmac cookies 1: [body] 1`] = `
|
||||
Object {
|
||||
"avatar_image": null,
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"email": "member@example.com",
|
||||
"email_suppression": Object {
|
||||
"info": null,
|
||||
"suppressed": false,
|
||||
},
|
||||
"enable_comment_notifications": true,
|
||||
"expertise": null,
|
||||
"firstname": null,
|
||||
"name": null,
|
||||
"newsletters": Array [
|
||||
Object {
|
||||
"description": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"name": "Default Newsletter",
|
||||
"sort_order": 0,
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
},
|
||||
Object {
|
||||
"description": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"name": "Weekly newsletter",
|
||||
"sort_order": 2,
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
},
|
||||
],
|
||||
"paid": false,
|
||||
"subscribed": false,
|
||||
"subscriptions": Array [],
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Comments API when caching members content is enabled sets ghost-access and ghost-access-hmac cookies 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "621",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"set-cookie": Array [
|
||||
StringMatching /\\^ghost-access=\\[0-9a-fA-F\\]\\{24\\}:\\\\d\\{10\\}/,
|
||||
StringMatching /\\^ghost-access-hmac=\\[a-fA-F0-9\\]\\{64\\}/,
|
||||
],
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Comments API when not authenticated but enabled can update comment notifications 1: [body] 1`] = `
|
||||
Object {
|
||||
"email": "member1@test.com",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {anyEtag, anyObjectId, anyUuid, anyISODateTime} = matchers;
|
||||
const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = require('../../utils/e2e-framework');
|
||||
const {anyEtag, anyObjectId, anyUuid, anyISODateTime, stringMatching} = matchers;
|
||||
const models = require('../../../core/server/models');
|
||||
const should = require('should');
|
||||
|
||||
|
@ -224,4 +224,29 @@ describe('Comments API', function () {
|
|||
should(member.get('email_disabled')).be.false();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when caching members content is enabled', function () {
|
||||
it('sets ghost-access and ghost-access-hmac cookies', async function () {
|
||||
configUtils.set('cacheMembersContent:enabled', true);
|
||||
configUtils.set('cacheMembersContent:hmacSecret', 'testsecret');
|
||||
membersAgent = await agentProvider.getMembersAPIAgent();
|
||||
await fixtureManager.init('newsletters', 'members:newsletters');
|
||||
await membersAgent.loginAs('member@example.com');
|
||||
const member = await models.Member.findOne({email: 'member@example.com'}, {require: true});
|
||||
await membersAgent
|
||||
.get(`/api/member/`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
'set-cookie': [
|
||||
stringMatching(/^ghost-access=[0-9a-fA-F]{24}:\d{10}/),
|
||||
stringMatching(/^ghost-access-hmac=[a-fA-F0-9]{64}/)
|
||||
]
|
||||
})
|
||||
.matchBodySnapshot(memberMatcher(2))
|
||||
.expect(({body}) => {
|
||||
body.email.should.eql(member.get('email'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
const assert = require('node:assert').strict;
|
||||
const sinon = require('sinon');
|
||||
const testUtils = require('../../../../utils');
|
||||
const configUtils = require('../../../../utils/configUtils');
|
||||
|
||||
const frontendCaching = require('../../../../../core/frontend/web/middleware/frontend-caching');
|
||||
|
||||
const cacheMembersContentConfigKey = 'cacheMembersContent:enabled';
|
||||
|
||||
describe('frontendCaching', function () {
|
||||
let res;
|
||||
let req;
|
||||
let next;
|
||||
let middleware;
|
||||
let freeTier;
|
||||
let premiumTier;
|
||||
|
||||
this.beforeEach(async function () {
|
||||
res = {
|
||||
set: sinon.spy(),
|
||||
get: sinon.stub().returns(undefined)
|
||||
};
|
||||
req = sinon.spy();
|
||||
next = sinon.spy();
|
||||
freeTier = {id: 'freeTierId'};
|
||||
premiumTier = {id: 'premiumTierId'};
|
||||
middleware = await frontendCaching.getMiddleware(async () => Promise.resolve(freeTier));
|
||||
});
|
||||
|
||||
this.afterEach(async function () {
|
||||
sinon.restore();
|
||||
await configUtils.restore();
|
||||
});
|
||||
|
||||
it('should set cache control to private if the blog is private', function () {
|
||||
res.isPrivateBlog = true;
|
||||
middleware(req, res, next);
|
||||
assert(res.set.calledOnce);
|
||||
assert.ok(res.set.calledWith({'Cache-Control': testUtils.cacheRules.private}));
|
||||
});
|
||||
|
||||
it('should set cache control to private if the request is made by a member', function () {
|
||||
req.member = true;
|
||||
middleware(req, res, next);
|
||||
assert.ok(res.set.calledOnce);
|
||||
assert.ok(res.set.calledWith({'Cache-Control': testUtils.cacheRules.private}));
|
||||
});
|
||||
|
||||
it('should set cache control to public if the site is public and the request is not made by a member', function () {
|
||||
req.member = undefined;
|
||||
res.isPrivateBlog = undefined;
|
||||
middleware(req, res, next);
|
||||
assert.ok(res.set.calledOnce);
|
||||
assert.ok(res.set.calledWith({'Cache-Control': testUtils.cacheRules.public}));
|
||||
});
|
||||
|
||||
it('should set cache control to public if the request is made by a member and caching members content is enabled', function () {
|
||||
configUtils.set(cacheMembersContentConfigKey, true);
|
||||
req.member = {
|
||||
subscriptions: []
|
||||
};
|
||||
res.isPrivateBlog = undefined;
|
||||
middleware(req, res, next);
|
||||
assert.equal(res.set.callCount, 2);
|
||||
assert.ok(res.set.calledWith({'Cache-Control': testUtils.cacheRules.public}));
|
||||
assert.ok(res.set.calledWith({'X-Member-Cache-Tier': 'freeTierId'}));
|
||||
});
|
||||
|
||||
describe('calculateMemberTier', function () {
|
||||
it('should return null if the member has more than one active subscription', function () {
|
||||
const member = {
|
||||
subscriptions: [{status: 'active'}, {status: 'active'}]
|
||||
};
|
||||
const memberTier = frontendCaching.calculateMemberTier(member, freeTier);
|
||||
assert.equal(memberTier, null);
|
||||
});
|
||||
|
||||
it('should return the tier if the member has one active subscription', function () {
|
||||
const member = {
|
||||
subscriptions: [{status: 'active', tier: premiumTier}]
|
||||
};
|
||||
const memberTier = frontendCaching.calculateMemberTier(member, freeTier);
|
||||
assert.deepEqual(memberTier, premiumTier);
|
||||
});
|
||||
|
||||
it('should return free if the member has no active subscriptions', function () {
|
||||
const member = {
|
||||
subscriptions: []
|
||||
};
|
||||
const memberTier = frontendCaching.calculateMemberTier(member, freeTier);
|
||||
assert.equal(memberTier, freeTier);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue