apostrophe
Version:
The Apostrophe Content Management System.
1,532 lines (1,410 loc) • 85.7 kB
JavaScript
const t = require('../test-lib/test.js');
const assert = require('assert');
describe('Static Build Support', function () {
this.timeout(t.timeout);
describe('URL helper methods', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {}
}
});
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should initialize with static: false (default)', async function () {
assert(apos.url);
assert.strictEqual(apos.url.options.static, false);
});
it('getChoiceFilter returns query string format when static is false', function () {
assert.strictEqual(
apos.url.getChoiceFilter('category', 'tech', 1),
'?category=tech'
);
});
it('getChoiceFilter returns query string with page when static is false', function () {
assert.strictEqual(
apos.url.getChoiceFilter('category', 'tech', 2),
'?category=tech&page=2'
);
});
it('getChoiceFilter returns empty string for null value', function () {
assert.strictEqual(apos.url.getChoiceFilter('category', null, 1), '');
});
it('getChoiceFilter encodes special characters', function () {
assert.strictEqual(
apos.url.getChoiceFilter('my filter', 'hello world', 1),
'?my%20filter=hello%20world'
);
});
it('getPageFilter returns empty string for page 1', function () {
assert.strictEqual(apos.url.getPageFilter(1), '');
});
it('getPageFilter returns query string for page > 1 when static is false', function () {
assert.strictEqual(apos.url.getPageFilter(2), '?page=2');
});
});
describe('Static mode URL helpers', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
}
}
});
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should initialize with static: true', async function () {
assert.strictEqual(apos.url.options.static, true);
});
it('getChoiceFilter returns path format when static is true', function () {
assert.strictEqual(
apos.url.getChoiceFilter('category', 'tech', 1),
'/category/tech'
);
});
it('getChoiceFilter returns path with page when static is true', function () {
assert.strictEqual(
apos.url.getChoiceFilter('category', 'tech', 2),
'/category/tech/page/2'
);
});
it('getPageFilter returns path format for page > 1 when static is true', function () {
assert.strictEqual(apos.url.getPageFilter(2), '/page/2');
assert.strictEqual(apos.url.getPageFilter(3), '/page/3');
});
it('getPageFilter still returns empty string for page 1 in static mode', function () {
assert.strictEqual(apos.url.getPageFilter(1), '');
});
});
describe('getAllUrlMetadata', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
},
article: {
extend: '@apostrophecms/piece-type',
options: {
name: 'article',
label: 'Article',
alias: 'article',
sort: { title: 1 }
},
fields: {
add: {
category: {
type: 'select',
label: 'Category',
choices: [
{
label: 'Tech',
value: 'tech'
},
{
label: 'Science',
value: 'science'
},
{
label: 'Art',
value: 'art'
}
]
}
}
}
},
'article-page': {
extend: '@apostrophecms/piece-page-type',
options: {
name: 'articlePage',
label: 'Articles',
alias: 'articlePage',
perPage: 5,
piecesFilters: [
{ name: 'category' }
]
}
},
'@apostrophecms/page': {
options: {
park: [
{
title: 'Articles',
type: 'articlePage',
slug: '/articles',
parkedId: 'articles'
}
]
}
}
}
});
// Insert 12 articles across 3 categories
const req = apos.task.getReq();
for (let i = 1; i <= 12; i++) {
const padded = String(i).padStart(3, '0');
const categories = [ 'tech', 'science', 'art' ];
const category = categories[(i - 1) % 3];
await apos.article.insert(req, {
title: `Article ${padded}`,
slug: `article-${padded}`,
visibility: 'public',
category
});
}
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should return URL metadata for all documents', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
assert(Array.isArray(results));
assert(results.length > 0);
const articlesPage = results.find(r => r.url === '/articles');
assert(articlesPage, 'Should include the articles index page');
assert.strictEqual(articlesPage.type, 'articlePage');
assert(articlesPage.aposDocId);
assert(articlesPage.i18nId);
});
it('should include individual article URLs', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
const articleUrls = results.filter(r => r.type === 'article');
assert.strictEqual(articleUrls.length, 12);
assert(articleUrls.every(a => a.url.startsWith('/articles/article-')));
});
it('document entries should not have contentType', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
const docEntries = results.filter(r => r.aposDocId);
assert(docEntries.length > 0, 'Should have document entries');
for (const entry of docEntries) {
assert.strictEqual(
entry.contentType,
undefined,
`Document entry ${entry.url} should not have contentType`
);
assert.notStrictEqual(
entry.sitemap,
false,
`Document entry ${entry.url} should not set sitemap: false`
);
}
});
it('should include filter URLs in static mode', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
// Should include filter URLs like /articles/category/tech
const filterUrls = results.filter(
r => r.url && r.url.match(/\/articles\/category\//)
);
assert(filterUrls.length > 0, 'Should include filter URLs');
// Should have entries for each category with pieces
const categories = [ 'tech', 'science', 'art' ];
for (const cat of categories) {
const catUrl = filterUrls.find(
r => r.url === `/articles/category/${cat}`
);
assert(catUrl, `Should include URL for category: ${cat}`);
}
});
it('should include pagination URLs in static mode', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
// 12 articles with perPage=5 means 3 pages.
// Page 1 is the base URL (/articles), so only /page/2 and /page/3
// should appear as separate entries.
const paginationUrls = results.filter(
r => r.url && r.url.match(/\/articles\/page\/\d+$/)
);
assert.strictEqual(
paginationUrls.length,
2,
'Should have exactly 2 pagination URLs'
);
assert(
paginationUrls.some(r => r.url === '/articles/page/2'),
'Should include page 2'
);
assert(
paginationUrls.some(r => r.url === '/articles/page/3'),
'Should include page 3'
);
assert(
!paginationUrls.some(r => r.url === '/articles/page/1'),
'Should not include page 1 (that is the base URL)'
);
});
it('filter URLs should use path format in static mode', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
const filterUrls = results.filter(
r => r.url && r.url.match(/\/articles\/category\//)
);
// 3 categories with 4 articles each, perPage=5: 1 page per category,
// so exactly 3 filter URLs (no paginated filter URLs)
assert.strictEqual(filterUrls.length, 3, 'Should have exactly 3 filter URLs');
for (const entry of filterUrls) {
assert(!entry.url.includes('?'), `URL should not contain query string: ${entry.url}`);
}
});
it('should have consistent i18nId values', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
for (const entry of results) {
assert(entry.i18nId, `Entry with url ${entry.url} should have i18nId`);
}
const ids = results.map(r => r.i18nId);
const unique = new Set(ids);
assert.strictEqual(
unique.size,
ids.length,
'All i18nId values should be unique'
);
});
it('should include home page URL', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
const home = results.find(r => r.url === '/');
assert(home, 'Should include the home page');
});
it('should respect excludeTypes option', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req, {
excludeTypes: [ 'article' ]
});
const articles = results.filter(r => r.type === 'article');
assert.strictEqual(articles.length, 0, 'Should not include excluded types');
});
});
describe('getAllUrlMetadata with literal content entries', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
}
}
});
// Simulate a styles stylesheet being present in the global doc
// by setting it directly in the database
await apos.doc.db.updateOne(
{
type: '@apostrophecms/global',
aposLocale: 'en:published'
},
{
$set: {
stylesStylesheet: 'body { color: red; }',
stylesStylesheetVersion: 'test-version'
}
}
);
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should include styles stylesheet as a literal content entry', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
const stylesheet = results.find(
r => r.i18nId === '@apostrophecms/styles:stylesheet'
);
assert(stylesheet, 'Should include styles stylesheet entry');
assert.strictEqual(stylesheet.contentType, 'text/css');
assert.strictEqual(stylesheet.sitemap, false, 'Literal content entries should have sitemap: false');
assert(
stylesheet.url.includes('/api/v1/@apostrophecms/styles/stylesheet'),
'URL should point to the styles API route'
);
assert(
stylesheet.url.includes('version=test-version'),
'URL should include the stylesheet version for cache busting'
);
});
it('literal content entries have contentType property', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
const literals = results.filter(r => r.contentType);
for (const entry of literals) {
assert(typeof entry.contentType === 'string');
assert(entry.url);
assert(entry.i18nId);
assert.strictEqual(entry.sitemap, false, 'Literal content entries should opt out of sitemaps');
assert(!entry.changefreq, 'Literal content entries should not have changefreq');
assert(!entry.priority, 'Literal content entries should not have priority');
}
});
});
describe('getAllUrlMetadata with attachments', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
},
article: {
extend: '@apostrophecms/piece-type',
options: {
name: 'article',
label: 'Article',
alias: 'article'
},
fields: {
add: {
_image: {
type: 'relationship',
withType: '@apostrophecms/image',
label: 'Image',
max: 1
},
_file: {
type: 'relationship',
withType: '@apostrophecms/file',
label: 'File',
max: 1
}
}
}
},
'article-page': {
extend: '@apostrophecms/piece-page-type',
options: {
name: 'articlePage',
label: 'Articles',
alias: 'articlePage',
perPage: 10
}
},
'@apostrophecms/page': {
options: {
park: [
{
title: 'Articles',
type: 'articlePage',
slug: '/articles',
parkedId: 'articles'
}
]
}
}
}
});
const req = apos.task.getReq();
// Insert an article so we have a document with a known _id
const article = await apos.article.insert(req, {
title: 'Attachment Test Article',
visibility: 'public'
});
// Update the article raw record to reference image and file
// docs via idsStorage fields, as if a user had chosen media
// through the CMS UI.
await apos.doc.db.updateMany(
{ aposDocId: article.aposDocId },
{
$set: {
imageIds: [ 'img-1' ],
fileIds: [ 'file-1' ]
}
}
);
// Seed attachment records directly into the DB to avoid
// needing real uploaded files. Attachment `docIds` reference
// the image/file doc IDs (not the article), matching how the
// core attachment module stores references.
const imgDocId = 'img-1:en:published';
const fileDocId = 'file-1:en:published';
await apos.attachment.db.insertMany([
{
_id: 'att-jpg-1',
name: 'photo',
extension: 'jpg',
group: 'images',
width: 800,
height: 600,
archived: false,
docIds: [ imgDocId ],
crops: [],
used: true,
utilized: true
},
{
_id: 'att-pdf-1',
name: 'document',
extension: 'pdf',
group: 'office',
archived: false,
docIds: [ fileDocId ],
crops: [],
used: true,
utilized: true
},
{
_id: 'att-orphan-1',
name: 'orphan',
extension: 'png',
group: 'images',
width: 100,
height: 100,
archived: false,
docIds: [ 'img-orphan:en:published' ],
crops: [],
used: false,
utilized: false
},
{
_id: 'att-archived-1',
name: 'archived-photo',
extension: 'jpg',
group: 'images',
width: 200,
height: 200,
archived: true,
docIds: [ imgDocId ],
crops: [],
used: true,
utilized: true
},
{
_id: 'att-cropped-1',
name: 'cropped-photo',
extension: 'jpg',
group: 'images',
width: 1000,
height: 800,
archived: false,
docIds: [ imgDocId ],
crops: [
{
top: 10,
left: 20,
width: 300,
height: 400
}
],
used: true,
utilized: true
}
]);
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should return attachments as null when not requested', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req);
assert.strictEqual(result.attachments, null);
assert(Array.isArray(result.pages));
});
it('should return attachment metadata when requested', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
assert(result.attachments);
assert(typeof result.attachments.uploadsUrl === 'string');
assert(Array.isArray(result.attachments.results));
assert(result.attachments.results.length > 0);
});
it('used scope should only include attachments referenced by content docs', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const ids = result.attachments.results.map(a => a._id);
assert(ids.includes('att-jpg-1'), 'Should include attachment referenced via image relationship');
assert(ids.includes('att-pdf-1'), 'Should include attachment referenced via file relationship');
assert(!ids.includes('att-orphan-1'), 'Should not include attachment whose image doc is unreferenced by content');
});
it('all scope should include all attachments', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'all' }
});
const ids = result.attachments.results.map(a => a._id);
assert(ids.includes('att-jpg-1'));
assert(ids.includes('att-pdf-1'));
assert(ids.includes('att-orphan-1'), 'all scope should include attachments not referenced by content docs');
});
it('sized attachment should have multiple size variants', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const jpgAtt = result.attachments.results.find(a => a._id === 'att-jpg-1');
assert(jpgAtt, 'Should find the jpg attachment');
assert(jpgAtt.urls.length > 1, 'Sized attachment should have multiple URL entries');
const sizeNames = jpgAtt.urls.map(u => u.size);
assert(sizeNames.includes('full'), 'Should include full size');
assert(sizeNames.includes('one-half'), 'Should include one-half size');
assert(sizeNames.includes('original'), 'Should include original size');
for (const entry of jpgAtt.urls) {
assert(typeof entry.path === 'string');
assert(entry.path.startsWith('/attachments/'));
}
});
it('non-sized attachment should have only a path', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const pdfAtt = result.attachments.results.find(a => a._id === 'att-pdf-1');
assert(pdfAtt, 'Should find the pdf attachment');
assert.strictEqual(pdfAtt.urls.length, 1, 'Non-sized attachment should have one entry');
assert.strictEqual(
pdfAtt.urls[0].size,
undefined,
'Non-sized attachment should not have a size property'
);
assert(pdfAtt.urls[0].path.includes('.pdf'));
});
it('skipSizes should exclude specified sizes', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: {
scope: 'used',
skipSizes: [ 'original', 'max' ]
}
});
const jpgAtt = result.attachments.results.find(a => a._id === 'att-jpg-1');
const sizeNames = jpgAtt.urls.map(u => u.size);
assert(!sizeNames.includes('original'), 'original should be skipped');
assert(!sizeNames.includes('max'), 'max should be skipped');
assert(sizeNames.includes('full'), 'full should still be present');
});
it('sizes should include only specified sizes', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: {
scope: 'used',
sizes: [ 'full', 'one-half' ]
}
});
const jpgAtt = result.attachments.results.find(a => a._id === 'att-jpg-1');
const sizeNames = jpgAtt.urls.map(u => u.size);
assert(sizeNames.includes('full'));
assert(sizeNames.includes('one-half'));
assert(!sizeNames.includes('original'), 'original should not be included when sizes is explicit');
assert(!sizeNames.includes('max'), 'max should not be included when sizes is explicit');
});
it('uploadsUrl should match the uploadfs base URL', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
assert.strictEqual(
result.attachments.uploadsUrl,
apos.attachment.uploadfs.getUrl()
);
});
it('should exclude archived attachments even if they have docIds', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const ids = result.attachments.results.map(a => a._id);
assert(!ids.includes('att-archived-1'), 'Archived attachments should be excluded');
assert(ids.includes('att-jpg-1'), 'Non-archived attachments should be included');
});
it('should exclude archived attachments in all scope too', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'all' }
});
const ids = result.attachments.results.map(a => a._id);
assert(!ids.includes('att-archived-1'), 'Archived attachments should be excluded in all scope');
});
it('crop variants should include all sizes by default', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const att = result.attachments.results.find(a => a._id === 'att-cropped-1');
assert(att, 'Should find the cropped attachment');
// Should have all regular sizes + all crop sizes
const cropUrls = att.urls.filter(u => u.path.includes('.20.'));
assert(cropUrls.length > 0, 'Should have crop variant URLs');
const cropSizes = cropUrls.map(u => u.size);
assert(cropSizes.includes('full'), 'Crop should include full size');
assert(cropSizes.includes('original'), 'Crop should include original size');
});
it('crop variants should respect skipSizes', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: {
scope: 'used',
skipSizes: [ 'original', 'max' ]
}
});
const att = result.attachments.results.find(a => a._id === 'att-cropped-1');
const cropUrls = att.urls.filter(u => u.path.includes('.20.'));
const cropSizes = cropUrls.map(u => u.size);
assert(!cropSizes.includes('original'), 'Crop should skip original when told to');
assert(!cropSizes.includes('max'), 'Crop should skip max when told to');
assert(cropSizes.includes('full'), 'Crop should still include full');
});
it('crop variants should respect sizes filter', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: {
scope: 'used',
sizes: [ 'full', 'one-half' ]
}
});
const att = result.attachments.results.find(a => a._id === 'att-cropped-1');
const cropUrls = att.urls.filter(u => u.path.includes('.20.'));
const cropSizes = cropUrls.map(u => u.size);
assert.strictEqual(cropSizes.length, 2, 'Crop should only have the 2 requested sizes');
assert(cropSizes.includes('full'));
assert(cropSizes.includes('one-half'));
assert(!cropSizes.includes('original'));
});
});
describe('used scope with direct attachment fields', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
},
// Piece type with a direct attachment field in its own schema
'direct-attachment-piece': {
extend: '@apostrophecms/piece-type',
options: {
name: 'direct-attachment-piece',
label: 'Direct Attachment Piece',
alias: 'directAttachmentPiece'
},
fields: {
add: {
file: {
type: 'attachment',
label: 'File',
group: 'office'
}
}
}
},
// Widget with a direct attachment field (not a relationship)
'attachment-widget': {
extend: '@apostrophecms/widget-type',
options: {
label: 'Attachment Widget'
},
fields: {
add: {
photo: {
type: 'attachment',
label: 'Photo',
fileGroup: 'images'
}
}
}
},
// Page type with an area that allows the attachment widget
'test-page': {
extend: '@apostrophecms/page-type',
options: {
label: 'Test Page'
},
fields: {
add: {
body: {
type: 'area',
label: 'Body',
options: {
widgets: {
attachment: {}
}
}
}
}
}
},
'@apostrophecms/page': {
options: {
types: [
{
name: 'test-page',
label: 'Test Page'
}
],
park: [
{
title: 'Widget Attachment Page',
type: 'test-page',
slug: '/widget-att',
parkedId: 'widget-att'
}
]
}
}
}
});
const req = apos.task.getReq();
// --- Piece with a direct attachment field ---
const piece = await apos.directAttachmentPiece.insert(req, {
title: 'Piece With Direct Attachment',
visibility: 'public'
});
// Seed an attachment referencing the piece doc (as
// updateDocReferences would do at save time)
const pieceDocId = `${piece.aposDocId}:en:published`;
await apos.attachment.db.insertOne({
_id: 'att-direct-piece',
name: 'piece-doc',
extension: 'pdf',
group: 'office',
archived: false,
docIds: [ pieceDocId ],
crops: [],
utilized: true
});
// --- Page with a widget that has a direct attachment field ---
const page = await apos.doc.db.findOne({
slug: '/widget-att',
aposLocale: 'en:published'
});
// Simulate an area with an attachment-widget containing an
// attachment object, as if uploaded through the CMS UI.
// updateDocReferences stores the parent page's _id in
// attachment.docIds.
const pageDocId = page._id;
await apos.doc.db.updateOne(
{ _id: pageDocId },
{
$set: {
body: {
metaType: 'area',
items: [
{
_id: 'widget-1',
metaType: 'widget',
type: 'attachment-widget',
photo: {
_id: 'att-widget-photo',
type: 'attachment',
group: 'images',
name: 'widget-photo',
extension: 'jpg'
}
}
]
}
}
}
);
await apos.attachment.db.insertOne({
_id: 'att-widget-photo',
name: 'widget-photo',
extension: 'jpg',
group: 'images',
width: 400,
height: 300,
archived: false,
docIds: [ pageDocId ],
crops: [],
utilized: true
});
// --- Unrelated attachment (should not appear in used scope) ---
await apos.attachment.db.insertOne({
_id: 'att-unrelated',
name: 'unrelated',
extension: 'png',
group: 'images',
width: 50,
height: 50,
archived: false,
docIds: [ 'some-other-doc:en:published' ],
crops: [],
utilized: true
});
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('used scope includes attachment from piece with direct attachment field', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const ids = result.attachments.results.map(a => a._id);
assert(
ids.includes('att-direct-piece'),
'Should include attachment owned by a piece with a direct attachment field'
);
});
it('used scope includes attachment from widget with direct attachment field', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const ids = result.attachments.results.map(a => a._id);
assert(
ids.includes('att-widget-photo'),
'Should include attachment from a widget with a direct attachment field inside a page area'
);
});
it('used scope excludes unrelated attachments', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const result = await apos.url.getAllUrlMetadata(req, {
attachments: { scope: 'used' }
});
const ids = result.attachments.results.map(a => a._id);
assert(
!ids.includes('att-unrelated'),
'Should not include attachments not referenced by any content doc'
);
});
});
describe('REST API endpoint', function () {
let apos;
const externalFrontKey = 'test-static-build-key';
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
},
'@apostrophecms/express': {
options: {
externalFrontKey
}
},
article: {
extend: '@apostrophecms/piece-type',
options: {
name: 'article',
label: 'Article',
alias: 'article'
},
fields: {
add: {
_image: {
type: 'relationship',
withType: '@apostrophecms/image',
label: 'Image',
max: 1
}
}
}
},
'article-page': {
extend: '@apostrophecms/piece-page-type',
options: {
name: 'articlePage',
label: 'Articles',
alias: 'articlePage',
perPage: 10
}
},
'@apostrophecms/page': {
options: {
park: [
{
title: 'Articles',
type: 'articlePage',
slug: '/articles',
parkedId: 'articles'
}
]
}
}
}
});
const req = apos.task.getReq();
const article = await apos.article.insert(req, {
title: 'Test Article',
visibility: 'public'
});
// Set up idsStorage so the article references an image doc
await apos.doc.db.updateMany(
{ aposDocId: article.aposDocId },
{ $set: { imageIds: [ 'api-img-1' ] } }
);
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should return 403 without external front headers', async function () {
await assert.rejects(
apos.http.get('/api/v1/@apostrophecms/url', {}),
{ status: 403 }
);
});
it('should return 403 with wrong external front key', async function () {
await assert.rejects(
apos.http.get('/api/v1/@apostrophecms/url', {
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': 'wrong-key'
}
}),
{ status: 403 }
);
});
it('should return URL metadata with valid external front key', async function () {
const response = await apos.http.get('/api/v1/@apostrophecms/url', {
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
});
assert(response);
assert(Array.isArray(response.pages));
assert(response.pages.length > 0);
// Should include at least the home page and articles page
assert(
response.pages.some(r => r.url === '/'),
'Should include home page'
);
assert(
response.pages.some(r => r.url === '/articles'),
'Should include articles page'
);
});
it('each result should have url and i18nId', async function () {
const response = await apos.http.get('/api/v1/@apostrophecms/url', {
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
});
for (const entry of response.pages) {
assert(entry.url, `Entry should have url: ${JSON.stringify(entry)}`);
assert(entry.i18nId, `Entry should have i18nId: ${JSON.stringify(entry)}`);
}
});
it('should return attachments as null without query param', async function () {
const response = await apos.http.get('/api/v1/@apostrophecms/url', {
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
});
assert.strictEqual(response.attachments, null);
});
it('should return attachment metadata with attachments=1', async function () {
// Seed an attachment referencing an image doc ID that
// the article doc points to via imageIds idsStorage
await apos.attachment.db.insertOne({
_id: 'att-api-jpg',
name: 'api-photo',
extension: 'jpg',
group: 'images',
width: 400,
height: 300,
archived: false,
docIds: [ 'api-img-1:en:published' ],
crops: [],
used: true,
utilized: true
});
const response = await apos.http.get(
'/api/v1/@apostrophecms/url?attachments=1',
{
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}
);
assert(response.attachments);
assert(typeof response.attachments.uploadsUrl === 'string');
assert(Array.isArray(response.attachments.results));
const att = response.attachments.results.find(a => a._id === 'att-api-jpg');
assert(att, 'Should include the seeded attachment');
assert(att.urls.length > 1, 'Sized image should have multiple URL entries');
});
it('should accept attachmentSkipSizes as comma-separated list', async function () {
const response = await apos.http.get(
'/api/v1/@apostrophecms/url?attachments=1&attachmentSkipSizes=original,max',
{
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}
);
const att = response.attachments.results.find(a => a._id === 'att-api-jpg');
const sizeNames = att.urls.map(u => u.size);
assert(!sizeNames.includes('original'), 'original should be skipped');
assert(!sizeNames.includes('max'), 'max should be skipped');
assert(sizeNames.includes('full'), 'full should remain');
});
it('should accept attachmentSizes as comma-separated list', async function () {
const response = await apos.http.get(
'/api/v1/@apostrophecms/url?attachments=1&attachmentSizes=full,one-half',
{
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}
);
const att = response.attachments.results.find(a => a._id === 'att-api-jpg');
const sizeNames = att.urls.map(u => u.size);
assert(sizeNames.includes('full'));
assert(sizeNames.includes('one-half'));
assert(!sizeNames.includes('original'));
assert(!sizeNames.includes('max'));
});
it('should accept attachmentScope=all', async function () {
// Insert an attachment not referenced by any content doc
await apos.attachment.db.insertOne({
_id: 'att-api-orphan',
name: 'api-orphan',
extension: 'png',
group: 'images',
width: 50,
height: 50,
archived: false,
docIds: [ 'unreferenced-img:en:published' ],
crops: [],
used: false,
utilized: false
});
const response = await apos.http.get(
'/api/v1/@apostrophecms/url?attachments=1&attachmentScope=all',
{
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}
);
const ids = response.attachments.results.map(a => a._id);
assert(ids.includes('att-api-orphan'), 'all scope should include attachments not in URL results');
});
it('should default scope to used and exclude orphaned attachments', async function () {
const response = await apos.http.get(
'/api/v1/@apostrophecms/url?attachments=1',
{
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}
);
const ids = response.attachments.results.map(a => a._id);
assert(!ids.includes('att-api-orphan'), 'used scope should not include attachments not in URL results');
});
it('should ignore invalid attachmentScope values', async function () {
const response = await apos.http.get(
'/api/v1/@apostrophecms/url?attachments=1&attachmentScope=evil',
{
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}
);
// Invalid scope falls back to 'used' via launder.select
const ids = response.attachments.results.map(a => a._id);
assert(!ids.includes('att-api-orphan'), 'invalid scope should fall back to used');
});
it('should ignore non-boolean attachments values', async function () {
const response = await apos.http.get(
'/api/v1/@apostrophecms/url?attachments=evil',
{
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}
);
assert.strictEqual(response.attachments, null, 'Non-boolean value should result in null attachments');
});
});
describe('REST API endpoint without static option', function () {
let apos;
const externalFrontKey = 'test-no-static-key';
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/express': {
options: {
externalFrontKey
}
}
}
});
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should return 400 when static option is not enabled', async function () {
await assert.rejects(
() => apos.http.get('/api/v1/@apostrophecms/url', {
headers: {
'x-requested-with': 'AposExternalFront',
'apos-external-front-key': externalFrontKey
}
}),
(err) => {
assert.strictEqual(err.status, 400);
assert(
err.body?.message?.includes('static: true'),
'Error message should mention the static option'
);
return true;
}
);
});
});
describe('Piece page dispatch routes in static mode', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
},
article: {
extend: '@apostrophecms/piece-type',
options: {
name: 'article',
label: 'Article',
alias: 'article',
sort: { title: 1 }
},
fields: {
add: {
category: {
type: 'select',
label: 'Category',
choices: [
{
label: 'Tech',
value: 'tech'
},
{
label: 'Science',
value: 'science'
}
]
}
}
}
},
'article-page': {
extend: '@apostrophecms/piece-page-type',
options: {
name: 'articlePage',
label: 'Articles',
alias: 'articlePage',
perPage: 5,
piecesFilters: [
{ name: 'category' }
]
}
},
'@apostrophecms/page': {
options: {
park: [
{
title: 'Articles',
type: 'articlePage',
slug: '/articles',
parkedId: 'articles'
}
]
}
}
}
});
const req = apos.task.getReq();
for (let i = 1; i <= 12; i++) {
const padded = String(i).padStart(3, '0');
const category = i <= 6 ? 'tech' : 'science';
await apos.article.insert(req, {
title: `Article ${padded}`,
slug: `article-${padded}`,
visibility: 'public',
category
});
}
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should serve index page at /', async function () {
const body = await apos.http.get('/articles');
assert(body.includes('article-001'));
});
it('should serve paginated page via path in static mode', async function () {
const body = await apos.http.get('/articles/page/2');
// Page 2 with perPage=5 should show articles 6-10
assert(body.includes('article-006'));
assert(!body.includes('article-001'));
});
it('should serve filter page via path in static mode', async function () {
const body = await apos.http.get('/articles/category/tech');
// Should only show tech articles (1-6)
assert(body.includes('article-001'));
});
it('should serve filter + pagination via path in static mode', async function () {
const body = await apos.http.get('/articles/category/tech/page/2');
// 6 tech articles with perPage=5 means page 2 has 1 article
assert(body.includes('article-006'));
assert(!body.includes('article-001'));
});
it('should still serve individual piece show pages', async function () {
const body = await apos.http.get('/articles/article-001');
assert(body.includes('Article 001'));
});
});
describe('getAllUrlMetadata event', function () {
let apos;
let eventFired = false;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
},
'custom-urls': {
handlers(self) {
return {
'@apostrophecms/url:getAllUrlMetadata': {
addCustomUrl(req, results, { excludeTypes }) {
eventFired = true;
results.push({
url: '/custom-resource.txt',
contentType: 'text/plain',
i18nId: 'custom:resource',
sitemap: false
});
}
}
};
}
}
}
});
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should emit getAllUrlMetadata event and include custom URLs', async function () {
const req = apos.task.getAnonReq({ mode: 'published' });
const { pages: results } = await apos.url.getAllUrlMetadata(req);
assert(eventFired, 'Event should have been fired');
const custom = results.find(r => r.i18nId === 'custom:resource');
assert(custom, 'Should include custom URL from event handler');
assert.strictEqual(custom.url, '/custom-resource.txt');
assert.strictEqual(custom.contentType, 'text/plain');
assert.strictEqual(custom.sitemap, false);
});
});
describe('getFiltersWithChoices', function () {
let apos;
before(async function () {
apos = await t.create({
root: module,
modules: {
'@apostrophecms/url': {
options: { static: true }
},
article: {
extend: '@apostrophecms/piece-type',
options: {
name: 'article',
label: 'Article',
alias: 'article',
sort: { title: 1 }
},
fields: {
add: {
category: {
type: 'select',
label: 'Category',
choices: [
{
label: 'Tech',
value: 'tech'
},
{
label: 'Science',
value: 'science'
}
]
}
}
}
},
'article-page': {
extend: '@apostrophecms/piece-page-type',
options: {
name: 'articlePage',
label: 'Articles',
alias: 'articlePage',
perPage: 10,
piecesFilters: [
{ name: 'category' }
]
}
},
'@apostrophecms/page': {
options: {
park: [
{
title: 'Articles',
type: 'articlePage',
slug: '/articles',
parkedId: 'articles'
}
]
}
}
}
});
const req = apos.task.getReq();
for (let i = 1; i <= 6; i++) {
const category = i <= 3 ? 'tech' : 'science';
await apos.article.insert(req, {
title: `Article ${i}`,
visibility: 'public',
category
});
}
});
after(async function () {
await t.destroy(apos);
apos = null;
});
it('should return filter choices with counts when requested', async function () {
const req = apos.task.getAno