UNPKG

apostrophe

Version:
1,532 lines (1,410 loc) • 85.7 kB
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