apostrophe
Version:
The Apostrophe Content Management System.
1,646 lines (1,385 loc) • 77.9 kB
JavaScript
const t = require('../test-lib/test.js');
const assert = require('assert');
const _ = require('lodash');
describe('Pages', function() {
let apos;
let home;
let homeId;
const apiKey = 'this is a test api key';
this.timeout(t.timeout);
before(async function() {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/express': {
options: {
apiKeys: {
[apiKey]: {
role: 'admin'
}
}
}
},
'@apostrophecms/page': {
options: {
park: [],
types: [
{
name: '@apostrophecms/home-page',
label: 'Home'
},
{
name: 'test-page',
label: 'Test Page'
}
],
publicApiProjection: {
title: 1,
_url: 1
}
}
},
'test-page': {
extend: '@apostrophecms/page-type'
}
}
});
home = await apos.page.find(apos.task.getAnonReq(), { level: 0 }).toObject();
homeId = home._id;
const testItems = [
{
_id: 'parent:en:published',
aposLocale: 'en:published',
aposDocId: 'parent',
type: 'test-page',
slug: '/parent',
visibility: 'public',
path: `${homeId.replace(':en:published', '')}/parent`,
level: 1,
rank: 0
},
{
_id: 'child:en:published',
aposLocale: 'en:published',
aposDocId: 'child',
type: 'test-page',
slug: '/parent/child',
visibility: 'public',
path: `${homeId.replace(':en:published', '')}/parent/child`,
level: 2,
rank: 0
},
{
_id: 'grandchild:en:published',
aposLocale: 'en:published',
aposDocId: 'grandchild',
type: 'test-page',
slug: '/parent/child/grandchild',
visibility: 'public',
path: `${homeId.replace(':en:published', '')}/parent/child/grandchild`,
level: 3,
rank: 0
},
{
_id: 'sibling:en:published',
aposLocale: 'en:published',
aposDocId: 'sibling',
type: 'test-page',
slug: '/parent/sibling',
visibility: 'public',
path: `${homeId.replace(':en:published', '')}/parent/sibling`,
level: 2,
rank: 1
},
{
_id: 'cousin:en:published',
aposLocale: 'en:published',
aposDocId: 'cousin',
type: 'test-page',
slug: '/parent/sibling/cousin',
visibility: 'public',
path: `${homeId.replace(':en:published', '')}/parent/sibling/cousin`,
level: 3,
rank: 0
},
{
_id: 'another-parent:en:published',
aposLocale: 'en:published',
aposDocId: 'another-parent',
type: 'test-page',
slug: '/another-parent',
visibility: 'public',
path: `${homeId.replace(':en:published', '')}/another-parent`,
level: 1,
rank: 1
}
];
// Insert draft versions too to match the A3 data model
const lastPublishedAt = new Date();
const draftItems = await apos.doc.db.insertMany(testItems.map(item => ({
...item,
aposLocale: item.aposLocale.replace(':published', ':draft'),
lastPublishedAt,
_id: item._id.replace(':published', ':draft')
})));
assert(draftItems.result.ok === 1);
assert(draftItems.insertedCount === 6);
const items = await apos.doc.db.insertMany(testItems);
assert(items.result.ok === 1);
assert(items.insertedCount === 6);
});
after(function() {
return t.destroy(apos);
});
// EXISTENCE
it('should be a property of the apos object', async function() {
assert(apos.page.__meta.name === '@apostrophecms/page');
});
// SETUP
it('should make sure all of the expected indexes are configured', async function() {
const expectedIndexes = [ 'path' ];
const actualIndexes = [];
const info = await apos.doc.db.indexInformation();
// Extract the actual index info we care about
_.each(info, function(index) {
actualIndexes.push(index[0][0]);
});
// Now make sure everything in expectedIndexes is in actualIndexes
_.each(expectedIndexes, function(index) {
assert(_.includes(actualIndexes, index));
});
});
it('parked homepage exists', async function() {
assert(home);
assert(home.slug === '/');
assert(`${home.path}:en:published` === home._id);
assert(home.type === '@apostrophecms/home-page');
assert(home.parked);
assert(home.visibility === 'public');
});
it('parked archive page exists', async function() {
const archive = await apos.page.find(apos.task.getReq(), { slug: '/archive' }).archived(null).toObject();
assert(archive);
assert(archive.slug === '/archive');
assert(archive.path === `${homeId.replace(':en:published', '')}/${archive._id.replace(':en:published', '')}`);
assert(archive.type === '@apostrophecms/archive-page');
assert(archive.parked);
// Verify that clonePermanent did its
// job and removed properties not meant
// to be stored in mongodb
assert(!archive._children);
});
// FINDING
it('should have a find method on pages that returns a cursor', async function() {
const cursor = apos.page.find(apos.task.getAnonReq());
assert(cursor);
});
it('should be able to find the parked homepage', async function() {
const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/' });
const page = await cursor.toObject();
// There should be only 1 result.
assert(page);
assert(`${page.path}:en:published` === page._id);
assert(page.rank === 0);
});
it('should be able to find just a single page', async function() {
const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/parent/child' });
const page = await cursor.toObject();
// There should be only 1 result.
assert(page);
// It should have a path of /parent/child
assert(page.path === `${homeId.replace(':en:published', '')}/parent/child`);
});
it('should convert an uppercase URL to its lowercase version', async function() {
const response = await apos.http.get('/PArent/cHild', {
fullResponse: true
});
assert(response.body.match(/URL: \/parent\/child/));
});
it('should NOT convert an uppercase URL if redirectFailedUpperCaseUrls is false', async function() {
apos.page.options.redirectFailedUpperCaseUrls = false;
try {
await apos.http.get('/PArent/cHild', {
fullResponse: true
});
} catch (error) {
assert(error.status === 404);
}
});
it('should be able to include the ancestors of a page', async function() {
const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/parent/child' });
const page = await cursor.ancestors(true).toObject();
// There should be only 1 result.
assert(page);
// There should be 2 ancestors.
assert(page._ancestors.length === 2);
// The first ancestor should be the homepage
assert.strictEqual(`${page._ancestors[0].path}:en:published`, homeId);
// The second ancestor should be 'parent'
assert.strictEqual(page._ancestors[1].path, `${homeId.replace(':en:published', '')}/parent`);
});
it('should be able to include just one ancestor of a page, i.e. the parent', async function() {
const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/parent/child' });
const page = await cursor.ancestors({ depth: 1 }).toObject();
// There should be only 1 result.
assert(page);
// There should be 1 ancestor returned.
assert(page._ancestors.length === 1);
// The first ancestor returned should be 'parent'
assert.strictEqual(page._ancestors[0].path, `${homeId.replace(':en:published', '')}/parent`);
});
it('should be able to include the children of the ancestors of a page', async function() {
const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/parent/child' });
const page = await cursor.ancestors({ children: 1 }).toObject();
// There should be only 1 result.
assert(page);
// There should be 2 ancestors.
assert(page._ancestors.length === 2);
// The second ancestor should have children
assert(page._ancestors[1]._children);
// The first ancestor's child should have a path '/parent/child'
assert.strictEqual(page._ancestors[1]._children[0].path, `${homeId.replace(':en:published', '')}/parent/child`);
// The second ancestor's child should have a path '/parent/sibling'
assert.strictEqual(page._ancestors[1]._children[1].path, `${homeId.replace(':en:published', '')}/parent/sibling`);
});
it('should return pages from a specific type when type is provided', async function () {
const result = await apos.http.get('/api/v1/@apostrophecms/page', {
qs: {
type: 'test-page'
}
});
const expected = {
results: [
{
type: 'test-page',
slug: '/parent'
},
{
type: 'test-page',
slug: '/parent/child'
},
{
type: 'test-page',
slug: '/parent/child/grandchild'
},
{
type: 'test-page',
slug: '/parent/sibling'
},
{
type: 'test-page',
slug: '/parent/sibling/cousin'
},
{
type: 'test-page',
slug: '/another-parent'
}
],
pages: 1,
currentPage: 1
};
const mappedResult = result.results.map(({ type, slug }) => ({
type,
slug
}));
assert.deepEqual({
...result,
results: mappedResult
}, expected);
});
// INSERTING
it('is able to insert a new page', async function() {
const parentId = 'parent:en:published';
const newPage = {
slug: '/parent/new-page',
visibility: 'public',
type: 'test-page',
title: 'New Page'
};
const page = await apos.page.insert(apos.task.getReq(), parentId, 'lastChild', newPage);
// Is the path generally correct?
assert.strictEqual(page.path, `${homeId.replace(':en:published', '')}/parent/${page._id.replace(':en:published', '')}`);
});
let newPage;
it('is able to insert a new page in the correct order', async function() {
const cursor = apos.page.find(apos.task.getAnonReq(), {
slug: '/parent/new-page'
});
newPage = await cursor.toObject();
assert(newPage);
assert.strictEqual(newPage.rank, 2);
assert.strictEqual(newPage.level, 2);
});
it('is able to insert a subpage', async function() {
const subPageInfo = {
slug: '/parent/new-page/sub-page',
visibility: 'public',
type: 'test-page',
title: 'Sub Page'
};
const subPage = await apos.page.insert(apos.task.getReq(), newPage._id, 'lastChild', subPageInfo);
const homePage = await apos.doc.db.findOne({
slug: '/',
aposMode: 'published'
});
const components = subPage.path.split('/');
assert.strictEqual(components.length, 4);
assert(components[0] === homePage.aposDocId);
assert(components[1] === 'parent');
assert(components[2] === newPage.aposDocId);
assert(components[3] === subPage.aposDocId);
assert.strictEqual(subPage.slug, '/parent/new-page/sub-page');
assert(subPage.rank === 0);
assert(subPage.level === 3);
});
it('is able to insert a draft subpage from published parent id', async function() {
const subPageInfo = {
slug: '/parent/sub-draft-page',
visibility: 'public',
type: 'test-page',
title: 'Sub Draft Page'
};
const subPage = await apos.page.insert(
apos.task.getReq({ mode: 'draft' }),
// ensure it ends with ":published"
newPage._id.replace(':draft', ':published'),
'lastChild',
subPageInfo
);
// Should not throw 'notfound'.
assert(subPage);
});
// MOVING
it('is able to move root/parent/sibling/cousin after root/parent', async function() {
await apos.page.move(apos.task.getReq(), 'cousin:en:published', 'parent:en:published', 'after');
const cursor = apos.page.find(apos.task.getAnonReq(), { _id: 'cousin:en:published' });
const page = await cursor.toObject();
// Is the new path correct?
assert.strictEqual(page.path, `${homeId.replace(':en:published', '')}/cousin`);
// Is the rank correct?
assert.strictEqual(page.rank, 1);
});
it('is not able to move a page under itself', async function() {
await assert.rejects(
apos.page.move(apos.task.getReq(), 'cousin:en:published', 'cousin:en:published', 'lastChild'),
{
name: 'forbidden',
message: 'Cannot move a page under itself'
}
);
});
it('is able to move root/cousin before root/parent/child', async function() {
// 'Cousin' _id === 4312
// 'Child' _id === 2341
await apos.page.move(apos.task.getReq(), 'cousin:en:published', 'child:en:published', 'before');
const cursor = apos.page.find(apos.task.getAnonReq(), { _id: 'cousin:en:published' });
const page = await cursor.toObject();
// Is the new path correct?
assert.strictEqual(page.path, `${homeId.replace(':en:published', '')}/parent/cousin`);
// Is the rank correct?
assert.strictEqual(page.rank, 0);
});
it('is able to move root/parent/cousin inside root/parent/sibling', async function() {
await apos.page.move(apos.task.getReq(), 'cousin:en:published', 'sibling:en:published', 'firstChild');
const cursor = apos.page.find(apos.task.getAnonReq(), { _id: 'cousin:en:published' });
const page = await cursor.toObject();
// Is the new path correct?
assert.strictEqual(page.path, `${homeId.replace(':en:published', '')}/parent/sibling/cousin`);
// Is the rank correct?
assert.strictEqual(page.rank, 0);
});
it('moving /parent into /another-parent should also move /parent/sibling', async function() {
await apos.page.move(apos.task.getReq(), 'parent:en:published', 'another-parent:en:published', 'firstChild');
const cursor = apos.page.find(apos.task.getAnonReq(), { _id: 'sibling:en:published' });
const page = await cursor.toObject();
// Is the grandchild's path correct?
assert.strictEqual(page.path, `${homeId.replace(':en:published', '')}/another-parent/parent/sibling`);
});
describe('move peer pages', function () {
this.afterEach(async function() {
await apos.doc.db.deleteMany({
type: 'test-page'
});
});
it('moving /bar under /foo should wind up with /foo/bar', async function() {
const fooPage = await apos.page.insert(
apos.task.getReq(),
'_home',
'lastChild',
{
slug: '/foo',
visibility: 'public',
type: 'test-page',
title: 'Foo Page'
}
);
const barPage = await apos.page.insert(
apos.task.getReq(),
'_home',
'lastChild',
{
slug: '/bar',
visibility: 'public',
type: 'test-page',
title: 'Bar Page'
}
);
const childPage = await apos.page.insert(
apos.task.getReq(),
barPage._id,
'lastChild',
{
slug: '/bar/child',
visibility: 'public',
type: 'test-page',
title: 'Child Page'
}
);
await apos.page.move(
apos.task.getReq(),
barPage._id,
fooPage._id,
'lastChild'
);
const movedPage = await apos.page
.find(apos.task.getAnonReq(), { _id: barPage._id }).toObject();
const movedChildPage = await apos.page
.find(apos.task.getAnonReq(), { _id: childPage._id }).toObject();
const actual = {
bar: {
path: movedPage.path,
rank: movedPage.rank,
slug: movedPage.slug
},
child: {
path: movedChildPage.path,
rank: movedChildPage.rank,
slug: movedChildPage.slug
}
};
const expected = {
bar: {
path: fooPage.path.concat('/', barPage.aposDocId),
rank: 0,
slug: '/foo/bar'
},
child: {
path: movedPage.path.concat('/', childPage.aposDocId),
rank: 0,
slug: '/foo/bar/child'
}
};
assert.deepEqual(actual, expected);
});
it('moving peer /foo/bar under /foo should wind up with /foo/bar', async function() {
const fooPage = await apos.page.insert(
apos.task.getReq(),
'_home',
'lastChild',
{
slug: '/foo',
visibility: 'public',
type: 'test-page',
title: 'Foo Page'
}
);
const barPage = await apos.page.insert(
apos.task.getReq(),
'_home',
'lastChild',
{
slug: '/foo/bar',
visibility: 'public',
type: 'test-page',
title: 'Bar Page'
}
);
const childPage = await apos.page.insert(
apos.task.getReq(),
barPage._id,
'lastChild',
{
slug: '/foo/bar/child',
visibility: 'public',
type: 'test-page',
title: 'Child Page'
}
);
await apos.page.move(
apos.task.getReq(),
barPage._id,
fooPage._id,
'lastChild'
);
const movedPage = await apos.page
.find(apos.task.getAnonReq(), { _id: barPage._id }).toObject();
const movedChildPage = await apos.page
.find(apos.task.getAnonReq(), { _id: childPage._id }).toObject();
const actual = {
bar: {
path: movedPage.path,
rank: movedPage.rank,
slug: movedPage.slug
},
child: {
path: movedChildPage.path,
rank: movedChildPage.rank,
slug: movedChildPage.slug
}
};
const expected = {
bar: {
path: fooPage.path.concat('/', barPage.aposDocId),
rank: 0,
slug: '/foo/bar'
},
child: {
path: movedPage.path.concat('/', childPage.aposDocId),
rank: 0,
slug: '/foo/bar/child'
}
};
assert.deepEqual(actual, expected);
});
it('moving /foobar under /foo should wind up with /foo/foobar', async function() {
const fooPage = await apos.page.insert(
apos.task.getReq(),
'_home',
'lastChild',
{
slug: '/foo',
visibility: 'public',
type: 'test-page',
title: 'Foo Page'
}
);
const foobarPage = await apos.page.insert(
apos.task.getReq(),
'_home',
'lastChild',
{
slug: '/foobar',
visibility: 'public',
type: 'test-page',
title: 'Foobar Page'
}
);
const childPage = await apos.page.insert(
apos.task.getReq(),
foobarPage._id,
'lastChild',
{
slug: '/foobar/child',
visibility: 'public',
type: 'test-page',
title: 'Child Page'
}
);
await apos.page.move(
apos.task.getReq(),
foobarPage._id,
fooPage._id,
'lastChild'
);
const movedPage = await apos.page
.find(apos.task.getAnonReq(), { _id: foobarPage._id }).toObject();
const movedChildPage = await apos.page
.find(apos.task.getAnonReq(), { _id: childPage._id }).toObject();
const actual = {
foobar: {
path: movedPage.path,
rank: movedPage.rank,
slug: movedPage.slug
},
child: {
path: movedChildPage.path,
rank: movedChildPage.rank,
slug: movedChildPage.slug
}
};
const expected = {
foobar: {
path: fooPage.path.concat('/', foobarPage.aposDocId),
rank: 0,
slug: '/foo/foobar'
},
child: {
path: movedPage.path.concat('/', childPage.aposDocId),
rank: 0,
slug: '/foo/foobar/child'
}
};
assert.deepEqual(actual, expected);
});
});
it('inferred page relationships are correct', async function() {
const req = apos.task.getReq();
const pages = await apos.page.find(req, {}).toArray();
for (const page of pages) {
if (page.level === 0) {
continue;
}
const { lastTargetId, lastPosition } = await apos.page
.inferLastTargetIdAndPosition(page);
const parentPath = page.path.split('/').slice(0, page.path.split('/').length - 1).join('/');
assert(pages.find(p => p.path === parentPath));
const peers = pages.filter(p => p.path.match(
apos.page.matchDescendants(parentPath)) && (p.level === page.level)
);
if (peers.length === 1) {
const parent = pages.find(p => p._id === lastTargetId);
assert(parent);
assert(page.path.startsWith(parent.path));
assert([ 'firstChild', 'lastChild' ].includes(lastPosition));
} else if (page.rank === Math.max(...peers.map(peer => peer.rank))) {
assert.strictEqual(lastPosition, 'lastChild');
const parent = pages.find(p => p._id === lastTargetId);
assert(parent);
assert(page.path.startsWith(parent.path));
} else if (page.rank === Math.min(...peers.map(peer => peer.rank))) {
assert.strictEqual(lastPosition, 'firstChild');
const parent = pages.find(p => p._id === lastTargetId);
assert(parent);
assert(page.path.startsWith(parent.path));
} else if (lastPosition === 'after') {
const peer = pages.find(p => p._id === lastTargetId);
assert(peer);
assert(page.rank > peer.rank);
} else {
throw new Error(`Unexpected position for ${page.path}: ${lastPosition}`);
}
}
});
it('should be able to serve a page', async function() {
const response = await apos.http.get('/another-parent/parent/child', {
fullResponse: true
});
// Is our status code good?
assert.strictEqual(response.status, 200);
// Did we get our page back?
assert(response.body.match(/Sing to me, Oh Muse./));
// Does the response prove that data.home was available?
assert(response.body.match(/Home: \//));
// Does the response prove that data.home._children was available?
assert(response.body.match(/Tab: \/another-parent/));
});
it('should not be able to serve a nonexistent page', async function() {
try {
await apos.http.get('/nobodyschild');
assert(false);
} catch (e) {
// Is our status code good?
assert.strictEqual(e.status, 404);
// Does the response prove that data.home was available?
assert(e.body.match(/Home: \//));
// Does the response prove that data.home._children was available?
assert(e.body.match(/Tab: \/another-parent/));
}
});
it('should detect that the home page is an ancestor of any page except itself', function() {
assert(
// actual paths are made up of _ids in 3.x
apos.page.isAncestorOf({
path: 'home'
}, {
path: 'home/about'
})
);
assert(
apos.page.isAncestorOf({
path: 'home'
}, {
path: 'home/about/grandkid'
})
);
assert(!apos.page.isAncestorOf({
path: 'home'
}, {
path: 'home'
}));
});
it('should detect a tab as the ancestor of its great grandchild but not someone else\'s', function() {
assert(
apos.page.isAncestorOf({
path: 'home/about'
}, {
path: 'home/about/test/thing'
})
);
assert(
!apos.page.isAncestorOf({
path: 'home/about'
}, {
path: 'home/wiggy/test/thing'
})
);
});
it('is able to move parent to the archive', async function() {
await apos.page.archive(apos.task.getReq(), 'parent:en:published');
const cursor = apos.page.find(apos.task.getAnonReq(), { _id: 'parent' });
const page = await cursor.toObject();
assert(!page);
const req = apos.task.getReq();
const archive = await apos.page.findOneForEditing(req, { parkedId: 'archive' });
const archived = await apos.page.findOneForEditing(req, {
_id: 'parent:en:published'
});
assert.strictEqual(archived.path, `${homeId.replace(':en:published', '')}/${archive._id.replace(':en:published', '')}/${archived._id.replace(':en:published', '')}`);
assert(archived.archived);
assert.strictEqual(archived.level, 2);
});
it('should be able to find the parked homepage again', async function() {
const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/' });
const page = await cursor.toObject();
// There should be only 1 result.
assert(page);
assert(`${page.path}:en:published` === page._id);
assert(page.rank === 0);
});
it('After everything else, ranks must still be unduplicated among peers and level must be consistent with path', async function() {
const pages = await apos.doc.db.find({
slug: /^\//,
aposLocale: 'en:published'
}).sort({
path: 1
}).toArray();
for (let i = 0; (i < pages.length); i++) {
const iLevel = pages[i].path.replace(/[^/]+/g, '').length;
assert(iLevel === pages[i].level);
const ranks = [];
for (let j = i + 1; (j < pages.length); j++) {
const jLevel = pages[j].path.replace(/[^/]+/g, '').length;
assert(jLevel === pages[j].level);
if (pages[j].path.substring(0, pages[i].path.length) !== pages[i].path) {
break;
}
if (pages[j].level !== (pages[i].level + 1)) {
// Ignore grandchildren etc.
continue;
}
assert(!ranks.includes(pages[j].rank));
ranks.push(pages[j].rank);
}
}
});
it('should not set a cache-control value when retrieving pages, when cache option is not set', async function() {
const response1 = await apos.http.get('/api/v1/@apostrophecms/page', { fullResponse: true });
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
assert(response1.headers['cache-control'] === undefined);
assert(response2.headers['cache-control'] === undefined);
});
it('should not set a cache-control value when retrieving a single page, when "etags" cache option is set', async function() {
apos.page.options.cache = {
api: {
maxAge: 5555,
etags: true
}
};
const response = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
assert(response.headers['cache-control'] === undefined);
delete apos.page.options.cache;
});
it('should not set a cache-control value when retrieving pages, when "api" cache option is not set', async function() {
apos.page.options.cache = {
page: {
maxAge: 5555
}
};
const response1 = await apos.http.get('/api/v1/@apostrophecms/page', { fullResponse: true });
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
assert(response1.headers['cache-control'] === undefined);
assert(response2.headers['cache-control'] === undefined);
delete apos.page.options.cache;
});
it('should set a "max-age" cache-control value when retrieving pieces, when "api" cache option is set', async function() {
apos.page.options.cache = {
api: {
maxAge: 4444
}
};
const response1 = await apos.http.get('/api/v1/@apostrophecms/page', { fullResponse: true });
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
assert(response1.headers['cache-control'] === 'max-age=4444');
assert(response2.headers['cache-control'] === 'max-age=4444');
delete apos.page.options.cache;
});
it('should set a "no-store" cache-control value when retrieving pages, when user is connected', async function() {
const jar = apos.http.jar();
const user = apos.user.newInstance();
user.title = 'admin';
user.username = 'admin';
user.password = 'admin';
user.email = 'ad@min.com';
user.role = 'admin';
await apos.user.insert(apos.task.getReq(), user);
await apos.http.post('/api/v1/@apostrophecms/login/login', {
body: {
username: 'admin',
password: 'admin',
session: true
},
jar
});
const response1 = await apos.http.get('/api/v1/@apostrophecms/page', {
fullResponse: true,
jar
});
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
fullResponse: true,
jar
});
assert(response1.headers['cache-control'] === 'no-store');
assert(response2.headers['cache-control'] === 'no-store');
});
it('should set a "no-store" cache-control value when retrieving pages, when "api" cache option is set, when user is connected', async function() {
apos.page.options.cache = {
api: {
maxAge: 4444
}
};
const jar = apos.http.jar();
await apos.http.post('/api/v1/@apostrophecms/login/login', {
body: {
username: 'admin',
password: 'admin',
session: true
},
jar
});
const response1 = await apos.http.get('/api/v1/@apostrophecms/page', {
fullResponse: true,
jar
});
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
fullResponse: true,
jar
});
assert(response1.headers['cache-control'] === 'no-store');
assert(response2.headers['cache-control'] === 'no-store');
delete apos.page.options.cache;
});
it('should set a "no-store" cache-control value when retrieving pages, when user is connected using an api key', async function() {
const response1 = await apos.http.get(`/api/v1/@apostrophecms/page?apiKey=${apiKey}`, { fullResponse: true });
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}?apiKey=${apiKey}`, { fullResponse: true });
assert(response1.headers['cache-control'] === 'no-store');
assert(response2.headers['cache-control'] === 'no-store');
});
it('should set a "no-store" cache-control value when retrieving pages, when "api" cache option is set, when user is connected using an api key', async function() {
apos.page.options.cache = {
api: {
maxAge: 4444
}
};
const response1 = await apos.http.get(`/api/v1/@apostrophecms/page?apiKey=${apiKey}`, { fullResponse: true });
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}?apiKey=${apiKey}`, { fullResponse: true });
assert(response1.headers['cache-control'] === 'no-store');
assert(response2.headers['cache-control'] === 'no-store');
delete apos.page.options.cache;
});
it('should not set a cache-control value when serving a page, when cache option is not set', async function() {
const response = await apos.http.get('/', { fullResponse: true });
assert(response.headers['cache-control'] === undefined);
});
it('should not set a cache-control value when serving a page, when "page" cache option is not set', async function() {
apos.page.options.cache = {
api: {
maxAge: 4444
}
};
const response = await apos.http.get('/', { fullResponse: true });
assert(response.headers['cache-control'] === undefined);
delete apos.page.options.cache;
});
it('should not set a cache-control value when serving a page, when "etags" cache option is set', async function() {
apos.page.options.cache = {
page: {
maxAge: 4444,
etags: true
}
};
const response = await apos.http.get('/', { fullResponse: true });
assert(response.headers['cache-control'] === undefined);
delete apos.page.options.cache;
});
it('should set a cache-control value when serving a page, when "page" cache option is set', async function() {
apos.page.options.cache = {
page: {
maxAge: 5555
}
};
const response = await apos.http.get('/', { fullResponse: true });
assert(response.headers['cache-control'] === 'max-age=5555');
delete apos.page.options.cache;
});
it('should set a custom etag when retrieving a single page', async function() {
apos.page.options.cache = {
api: {
maxAge: 1111,
etags: true
}
};
const response = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
const eTagParts = response.headers.etag.split(':');
assert(eTagParts[0] === apos.asset.getReleaseId());
assert(eTagParts[1] === (new Date(response.body.cacheInvalidatedAt))
.getTime()
.toString());
assert(eTagParts[2]);
delete apos.page.options.cache;
});
it('should return a 304 status code when retrieving a page with a matching etag', async function() {
apos.page.options.cache = {
api: {
maxAge: 1111,
etags: true
}
};
const response1 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
fullResponse: true,
headers: {
'if-none-match': response1.headers.etag
}
});
assert(response1.status === 200);
assert(response1.body);
assert(response2.status === 304);
assert(response2.body === '');
// Same ETag should be sent again to the client
assert(response1.headers.etag === response2.headers.etag);
delete apos.page.options.cache;
});
it('should not return a 304 status code when retrieving a page that has been edited', async function() {
apos.page.options.cache = {
api: {
maxAge: 1111,
etags: true
}
};
const response1 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
const pageDoc = await apos.doc.db.findOne({
slug: '/',
aposLocale: 'en:published'
});
// Edit homepage, this should invalidate its cache,
// so requesting it again should not return a 304 status code
const pageUpdateResponse = await apos.doc.update(apos.task.getReq(), pageDoc);
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
fullResponse: true,
headers: {
'if-none-match': response1.headers.etag
}
});
const eTag1Parts = response1.headers.etag.split(':');
const eTag2Parts = response2.headers.etag.split(':');
assert(response1.status === 200);
assert(response1.body);
assert(response2.status === 200);
assert(response2.body);
// New ETag has been generated, with the new value of
// the edited homepage's `cacheInvalidatedAt` field...
assert(eTag2Parts[1] === pageUpdateResponse.cacheInvalidatedAt.getTime().toString());
// ...and a new timestamp
assert(eTag2Parts[2] !== eTag1Parts[2]);
delete apos.page.options.cache;
});
it('should not return a 304 status code when retrieving a page after the max-age period', async function() {
apos.page.options.cache = {
api: {
maxAge: 4444,
etags: true
}
};
const response1 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
const eTagParts = response1.headers.etag.split(':');
const outOfDateETagParts = [ ...eTagParts ];
outOfDateETagParts[2] = Number(outOfDateETagParts[2]) -
(4444 + 1) * 1000; // 1s outdated
const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
fullResponse: true,
headers: {
'if-none-match': outOfDateETagParts.join(':')
}
});
const eTag1Parts = response1.headers.etag.split(':');
const eTag2Parts = response2.headers.etag.split(':');
assert(response1.status === 200);
assert(response1.body);
assert(response2.status === 200);
assert(response2.body);
// New timestamp
assert(eTag1Parts[2] !== eTag2Parts[2]);
delete apos.page.options.cache;
});
it('should set a custom etag when serving a page', async function() {
apos.page.options.cache = {
page: {
maxAge: 4444,
etags: true
}
};
const response = await apos.http.get('/', { fullResponse: true });
const eTagParts = response.headers.etag.split(':');
assert(eTagParts[0] === apos.asset.getReleaseId());
assert(eTagParts[1]);
assert(eTagParts[2]);
delete apos.page.options.cache;
});
it('should return a 304 status code when requesting a page with a matching etag', async function() {
apos.page.options.cache = {
page: {
maxAge: 4444,
etags: true
}
};
const response1 = await apos.http.get('/', { fullResponse: true });
const response2 = await apos.http.get('/', {
fullResponse: true,
headers: {
'if-none-match': response1.headers.etag
}
});
assert(response1.status === 200);
assert(response1.body);
assert(response2.status === 304);
assert(response2.body === '');
// Same ETag should be sent again to the client
assert(response1.headers.etag === response2.headers.etag);
delete apos.page.options.cache;
});
it('should not return a 304 status code when requesting a page that has been edited', async function() {
apos.page.options.cache = {
page: {
maxAge: 4444,
etags: true
}
};
const response1 = await apos.http.get('/', { fullResponse: true });
const pageDoc = await apos.doc.db.findOne({
slug: '/',
aposLocale: 'en:published'
});
// Edit homepage, this should invalidate its cache,
// so requesting it again should not return a 304 status code
const pageUpdateResponse = await apos.doc.update(apos.task.getReq(), pageDoc);
const response2 = await apos.http.get('/', {
fullResponse: true,
headers: {
'if-none-match': response1.headers.etag
}
});
const eTag1Parts = response1.headers.etag.split(':');
const eTag2Parts = response2.headers.etag.split(':');
assert(response1.status === 200);
assert(response1.body);
assert(response2.status === 200);
assert(response2.body);
// New ETag has been generated, with the new value of
// the edited homepage's `cacheInvalidatedAt` field...
assert(eTag2Parts[1] === pageUpdateResponse.cacheInvalidatedAt.getTime().toString());
// ...and a new timestamp
assert(eTag2Parts[2] !== eTag1Parts[2]);
delete apos.page.options.cache;
});
it('should not return a 304 status code when requesting a page with an outdated release id', async function() {
apos.page.options.cache = {
page: {
maxAge: 4444,
etags: true
}
};
const response1 = await apos.http.get('/', { fullResponse: true });
const eTagParts = response1.headers.etag.split(':');
const outOfDateETagParts = [ ...eTagParts ];
outOfDateETagParts[0] = 'abcdefghi';
const response2 = await apos.http.get('/', {
fullResponse: true,
headers: {
'if-none-match': outOfDateETagParts.join(':')
}
});
const eTag1Parts = response1.headers.etag.split(':');
const eTag2Parts = response2.headers.etag.split(':');
assert(response1.status === 200);
assert(response1.body);
assert(response2.status === 200);
assert(response2.body);
// New timestamp
assert(eTag1Parts[2] !== eTag2Parts[2]);
delete apos.page.options.cache;
});
it('should not return a 304 status code when requesting a page after the max-age period', async function() {
apos.page.options.cache = {
page: {
maxAge: 4444,
etags: true
}
};
const response1 = await apos.http.get('/', { fullResponse: true });
const eTagParts = response1.headers.etag.split(':');
const outOfDateETagParts = [ ...eTagParts ];
outOfDateETagParts[2] = Number(outOfDateETagParts[2]) -
(4444 + 1) * 1000; // 1s outdated
const response2 = await apos.http.get('/', {
fullResponse: true,
headers: {
'if-none-match': outOfDateETagParts.join(':')
}
});
const eTag1Parts = response1.headers.etag.split(':');
const eTag2Parts = response2.headers.etag.split(':');
assert(response1.status === 200);
assert(response1.body);
assert(response2.status === 200);
assert(response2.body);
// New timestamp
assert(eTag1Parts[2] !== eTag2Parts[2]);
delete apos.page.options.cache;
});
it('should not set a custom etag when retrieving a single page, when user is connected', async function() {
apos.page.options.cache = {
api: {
maxAge: 4444,
etags: true
}
};
const jar = apos.http.jar();
await apos.http.post('/api/v1/@apostrophecms/login/login', {
body: {
username: 'admin',
password: 'admin',
session: true
},
jar
});
const response = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
fullResponse: true,
jar
});
const eTagParts = response.headers.etag.split(':');
assert(eTagParts[0] !== apos.asset.getReleaseId());
assert(eTagParts[1] !== (new Date(response.body.cacheInvalidatedAt))
.getTime().toString());
delete apos.page.options.cache;
});
it('should not set a custom etag when retrieving a single page, when user is connected using an api key', async function() {
apos.page.options.cache = {
api: {
maxAge: 4444,
etags: true
}
};
const response = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}?apiKey=${apiKey}`, { fullResponse: true });
const eTagParts = response.headers.etag.split(':');
assert(eTagParts[0] !== apos.asset.getReleaseId());
assert(eTagParts[1] !== (new Date(response.body.cacheInvalidatedAt))
.getTime().toString());
delete apos.page.options.cache;
});
describe('unpublish', function() {
const baseItem = {
aposDocId: 'some-page',
type: 'test-page',
slug: '/some-page',
visibility: 'public',
path: '/some-page',
level: 1,
rank: 0
};
const draftItem = {
...baseItem,
_id: 'some-page:en:draft',
aposLocale: 'en:draft'
};
const publishedItem = {
...baseItem,
_id: 'some-page:en:published',
aposLocale: 'en:published'
};
const previousItem = {
...baseItem,
_id: 'some-page:en:previous',
aposLocale: 'en:previous'
};
let draft;
let published;
let previous;
this.beforeEach(async function() {
await apos.doc.db.insertMany([
draftItem,
publishedItem,
previousItem
]);
draft = await apos.http.post(
`/api/v1/@apostrophecms/page/${publishedItem._id}/unpublish?apiKey=${apiKey}`,
{
body: {},
busy: true
}
);
published = await apos.doc.db.findOne({ _id: 'some-page:en:published' });
previous = await apos.doc.db.findOne({ _id: 'some-page:en:previous' });
});
this.afterEach(async function() {
await apos.doc.db.deleteMany({
aposDocId: 'some-page'
});
});
it('should remove the published and previous versions of a page', function() {
assert(published === null);
assert(previous === null);
});
it('should update the draft version of a page', function() {
assert(draft._id === draftItem._id);
assert(draft.modified === true);
assert(draft.lastPublishedAt === null);
});
});
describe('draft sharing', function() {
const page = {
_id: 'some-page:en:published',
title: 'Some Page',
aposDocId: 'some-page',
type: 'test-page',
slug: '/some-page',
visibility: 'public',
path: '/some-page',
level: 1,
rank: 0
};
let req;
let previousDraft;
let previousPublished;
let shareResponse;
const generatePublicUrl = shareResponse =>
`${shareResponse._url}?aposShareKey=${encodeURIComponent(shareResponse.aposShareKey)}&aposShareId=${encodeURIComponent(shareResponse._id)}`;
this.beforeEach(async function() {
req = apos.task.getReq();
previousPublished = await apos.page.insert(req, homeId, 'lastChild', page);
previousDraft = await apos.page.findOneForEditing(
apos.task.getReq({ mode: 'draft' }),
{ _id: 'some-page:en:draft' }
);
await apos.page.update(req, {
...previousDraft,
title: 'Some Page EDITED'
});
});
this.afterEach(async function() {
await apos.doc.db.deleteMany({ aposDocId: page.aposDocId });
});
describe('share', function() {
this.beforeEach(async function() {
shareResponse = await apos.page.share(req, previousDraft);
});
it('should have a "share" method that returns a draft with aposShareKey', async function() {
const draft = await apos.doc.db.findOne({
_id: `${previousDraft.aposDocId}:en:draft`
});
const published = await apos.doc.db.findOne({
_id: `${previousDraft.aposDocId}:en:published`
});
assert(apos.page.share);
assert(!Object.prototype.hasOwnProperty.call(published, 'aposShareKey'));
assert(!Object.prototype.hasOwnProperty.call(previousDraft, 'aposShareKey'));
assert(shareResponse.aposShareKey);
assert(draft.aposShareKey);
assert(shareResponse.aposShareKey === draft.aposShareKey);
});
it('should grant public access to a draft after having enabled draft sharing', async function() {
const publicUrl = generatePublicUrl(shareResponse);
const response = await apos.http.get(shareResponse._url, { fullResponse: true });
const publicResponse = await apos.http.get(publicUrl, { fullResponse: true });
assert(response.status === 200);
assert(response.body.includes('Some Page'));
assert(!response.body.includes('Some Page EDITED'));
assert(publicResponse.status === 200);
assert(publicResponse.body.includes('Some Page EDITED'));
});
it('should grant public access to a draft without admin UI, even when logged-in', async function() {
const jar = apos.http.jar();
await apos.http.post('/api/v1/@apostrophecms/login/login', {
body: {
username: 'admin',
password: 'admin',
session: true
},
jar
});
const publicUrl = generatePublicUrl(shareResponse);
const publicResponse = await apos.http.get(publicUrl, {
fullResponse: true,
jar
});
assert(publicResponse.status === 200);
assert(publicResponse.body.includes('Some Page EDITED'));
assert(!publicResponse.body.includes('apos-admin-bar'));
});
it('should grant public access to a draft after having re-enabled draft sharing', async function() {
await apos.page.unshare(req, previousDraft);
const shareResponse = await apos.page.share(req, previousDraft);
const publicUrl = generatePublicUrl(shareResponse);
const publicResponse = await apos.http.get(publicUrl, { fullResponse: true });
assert(publicResponse.status === 200);
assert(publicResponse.body.includes('Some Page EDITED'));
});
});
describe('unshare', function() {
this.beforeEach(async function() {
shareResponse = await apos.page.share(req, previousDraft);
});
it('should have a "unshare" method that returns a draft without aposShareKey', async function() {
const unshareResponse = await apos.page.unshare(req, previousDraft);
const draft = await apos.doc.db.findOne({
_id: `${previousDraft.aposDocId}:en:draft`
});
const published = await apos.doc.db.findOne({
_id: `${previousDraft.aposDocId}:en:published`
});
assert(apos.page.unshare);
assert(!Object.prototype.hasOwnProperty.call(previousPublished, 'aposShareKey'));
assert(!Object.prototype.hasOwnProperty.call(previousDraft, 'aposShareKey'));
assert(!Object.prototype.hasOwnProperty.call(published, 'aposShareKey'));
assert(!Object.prototype.hasOwnProperty.call(draft, 'aposShareKey'));
assert(!Object.prototype.hasOwnProperty.call(unshareResponse, 'aposShareKey'));
});
it('should remove public access to a draft after having disabled draft sharing', async function() {
await apos.page.unshare(req, previousDraft);
try {
const publicUrl = generatePublicUrl(shareResponse);
await apos.http.get(publicUrl, { fullResponse: true });
} catch (error) {
assert(error.status === 404);
return;
}
throw new Error('should have thrown 404 error');
});
});
});
describe('publish, move and draft', function () {
beforeEach(async function() {
await t.destroy(apos);
apos = await t.create({
root: module,
modules: {
'@apostrophecms/page': {
options: {
park: [],
types: [
{
name: '@apostrophecms/home-page',
label: 'Home'
},
{
name: 'test-page',
label: 'Test Page'
}
]
}
},
'test-page': {
extend: '@apostrophecms/page-type'
}
}
});
});