diff --git a/ghost/core/core/frontend/services/sitemap/handler.js b/ghost/core/core/frontend/services/sitemap/handler.js index dcf376d004f..884f315ebfb 100644 --- a/ghost/core/core/frontend/services/sitemap/handler.js +++ b/ghost/core/core/frontend/services/sitemap/handler.js @@ -26,22 +26,34 @@ module.exports = function handler(siteApp) { res.send(content); }; - siteApp.get('/sitemap.xml', function sitemapXML(req, res) { - sendXml(res, manager.getIndexXml()); + // The XML reads are async: with a lazy URL backend the manager builds + // its index on first read. Express 4 does not forward async handler + // rejections, so each body is fully wrapped — a failed build or render + // becomes an error response instead of a hung socket. + siteApp.get('/sitemap.xml', async function sitemapXML(req, res, next) { + try { + sendXml(res, await manager.getIndexXml()); + } catch (err) { + next(err); + } }); - siteApp.get('/sitemap-:resource.xml', verifyResourceType, function sitemapResourceXML(req, res) { - const type = req.params.resource.replace(/-\d+$/, ''); - const pageParam = req.params.resource.match(/-(\d+)$/)?.[1]; - const page = pageParam ? parseInt(pageParam, 10) : 1; - - const content = manager.getSiteMapXml(type, page); - // Prevent x-1.xml as it is a duplicate of x.xml and empty sitemaps - // (except for the first page so that at least one sitemap exists per type) - if (pageParam === '1' || content === null) { - return res.sendStatus(404); + siteApp.get('/sitemap-:resource.xml', verifyResourceType, async function sitemapResourceXML(req, res, next) { + try { + const type = req.params.resource.replace(/-\d+$/, ''); + const pageParam = req.params.resource.match(/-(\d+)$/)?.[1]; + const page = pageParam ? parseInt(pageParam, 10) : 1; + + const content = await manager.getSiteMapXml(type, page); + // Prevent x-1.xml as it is a duplicate of x.xml and empty sitemaps + // (except for the first page so that at least one sitemap exists per type) + if (pageParam === '1' || content === null) { + return res.sendStatus(404); + } + + sendXml(res, content); + } catch (err) { + next(err); } - - sendXml(res, content); }); }; diff --git a/ghost/core/core/frontend/services/sitemap/site-map-manager.js b/ghost/core/core/frontend/services/sitemap/site-map-manager.js index ea3a7a5d835..6c13db78950 100644 --- a/ghost/core/core/frontend/services/sitemap/site-map-manager.js +++ b/ghost/core/core/frontend/services/sitemap/site-map-manager.js @@ -1,4 +1,6 @@ const DomainEvents = require('@tryghost/domain-events'); +const errors = require('@tryghost/errors'); +const urlUtils = require('../../../shared/url-utils'); const {URLResourceUpdatedEvent} = require('../../../shared/events'); const IndexMapGenerator = require('./site-map-index-generator'); const PagesMapGenerator = require('./page-map-generator'); @@ -9,6 +11,19 @@ const TagsMapGenerator = require('./tags-map-generator'); // This uses events from the routing service and the URL service const events = require('../../../server/lib/common/events'); +// What the sitemap XML reads off each resource, beyond the columns URL +// computation needs: lastmod dates, image nodes, and the canonical_url skip +// rule applied by the generators. +const SITEMAP_COLUMNS = [ + 'updated_at', + 'published_at', + 'created_at', + 'feature_image', + 'cover_image', + 'profile_image', + 'canonical_url' +]; + class SiteMapManager { constructor(options) { options = options || {}; @@ -21,6 +36,23 @@ class SiteMapManager { this.tags = options.tags || this.createTagsGenerator(options); this.index = options.index || this.createIndexGenerator(options); + // The URL service is injectable for tests; in production it is + // resolved lazily through the proxy seam on first use, because the + // url service loads at require time and loading it when this module + // loads would change boot order. + this._urlService = options.urlService || null; + + // Index state for the build path. _indexEpoch increments on every + // invalidation signal; a build compares the epoch it started with so + // an invalidated-while-running build never marks the index ready. + this._indexBuilt = false; + this._buildInFlight = null; + this._indexEpoch = 0; + // Static/collection route entries only arrive via router.created, + // which fires at boot and routes reload. They are recorded here so + // every rebuild can replay them after resetting the generators. + this._routerEntries = []; + events.on('router.created', (router) => { if (router.name !== 'StaticRoutesRouter' && router.name !== 'CollectionRouter') { return; @@ -29,9 +61,29 @@ class SiteMapManager { url: router.getRoute({absolute: true}), datum: {id: router.identifier, staticRoute: router.name === 'StaticRoutesRouter'} }; + this._routerEntries.push(entry); this.pages.addUrl(entry.url, entry.datum); + // A router registering after a build (routes reload re-registers + // them one macrotask after routers.reset) must not leave a + // zero-router index marked built — the CDN would pin it. + if (this._getUrlService().isLazy()) { + this._invalidateIndex(); + } + }); + + // Invalidation is lazy-mode only. Everywhere the eager service runs + // (flag off AND compare mode) its per-URL feed below keeps the index + // current after the initial build, exactly as before this change — + // deploying is a no-op until the lazy flip. Pure lazy fires no + // events, so there the index empties and the next read rebuilds. + events.on('site.changed', () => { + if (this._getUrlService().isLazy()) { + this._invalidateIndex(); + } }); + // The eager URL service's per-URL feed, active in both eager-only + // and compare mode; under pure lazy these events never fire. DomainEvents.subscribe(URLResourceUpdatedEvent, (event) => { this[event.data.resourceType].updateURL(event.data); }); @@ -49,6 +101,12 @@ class SiteMapManager { this.posts && this.posts.reset(); this.users && this.users.reset(); this.tags && this.tags.reset(); + // The routers re-register right after a reset and refill the + // list; keeping stale entries would resurrect deleted routes. + this._routerEntries = []; + if (this._getUrlService().isLazy()) { + this._invalidateIndex(); + } }); } @@ -80,13 +138,120 @@ class SiteMapManager { return new TagsMapGenerator(options); } - getIndexXml() { + async getIndexXml() { + await this._ensureIndexReady(); return this.index.getXml(); } - getSiteMapXml(type, page) { + async getSiteMapXml(type, page) { + await this._ensureIndexReady(); return this[type].getXml(page); } + + /** + * Make sure the index is ready to serve; every XML read awaits this, so + * no caller can render from an unbuilt index. The index is built once on + * first read in every mode. Wherever the eager service runs (eager-only + * and compare mode) the per-URL events keep it current from then on + * (and heal any gap in the initial snapshot, since addUrl is id-keyed); + * under pure lazy the invalidation signals empty it and the next read + * rebuilds. + * + * Concurrent readers share one build. A build whose result was + * invalidated while it ran is discarded and the read fails — the index + * must never serve pre-invalidation data (the CDN would pin it for the + * full cache maxAge), and a 503 is retried by crawlers and stored by + * nobody. Unreachable with eager (nothing invalidates), and + * deliberately no retry; if SITEMAP_BUILD_SUPERSEDED shows up in the + * logs at any rate worth caring about, add one then. + */ + async _ensureIndexReady() { + if (this._indexBuilt) { + return; + } + if (!this._buildInFlight) { + this._buildInFlight = this._buildIndex().finally(() => { + this._buildInFlight = null; + }); + } + await this._buildInFlight; + + if (!this._indexBuilt) { + throw new errors.MaintenanceError({ + message: 'Sitemap index build was invalidated by a concurrent site change', + code: 'SITEMAP_BUILD_SUPERSEDED' + }); + } + } + + async _buildIndex() { + const epoch = this._indexEpoch; + const urlService = this._getUrlService(); + const fetch = type => urlService.getRoutableResources(type, {columns: SITEMAP_COLUMNS}); + + const [posts, pages, tags, authors] = await Promise.all( + [fetch('posts'), fetch('pages'), fetch('tags'), fetch('authors')] + ); + const resources = {posts, pages, tags, authors}; + + if (epoch !== this._indexEpoch) { + // Invalidated while fetching: leave the generators alone and let + // _ensureIndexReady start over. + return; + } + // Everything from here on is synchronous, so no request can observe + // a half-applied index. + this.posts.reset(); + this.pages.reset(); + this.tags.reset(); + this.users.reset(); + for (const entry of this._routerEntries) { + this.pages.addUrl(entry.url, entry.datum); + } + for (const type of ['posts', 'pages', 'tags', 'authors']) { + for (const datum of resources[type]) { + this._applyResource(type, datum); + } + } + this._indexBuilt = true; + } + + _invalidateIndex() { + this._indexBuilt = false; + this._indexEpoch += 1; + } + + /** + * Add a single resource to the index. + */ + _applyResource(type, datum) { + // skipComparison: teeing every bulk row through the compare machinery + // would capture a stack and queue a background lazy computation per + // resource, per build. Enumeration parity comes from the + // getRoutableResources id-set comparison; per-URL parity from + // organic request traffic. + const url = this._getUrlService().getUrlForResource({...datum, type}, {absolute: true, skipComparison: true}); + // Exact match on the not-found sentinel: a real resource can carry + // a slug like "404" (/tag/404/) and must stay in the sitemap. + if (url && url !== this._notFoundUrl()) { + this[type].addUrl(url, datum); + } + } + + _notFoundUrl() { + // The site URL is fixed at boot, so compute the sentinel once. + if (!this._notFoundUrlCached) { + this._notFoundUrlCached = urlUtils.createUrl('/404/', true); + } + return this._notFoundUrlCached; + } + + _getUrlService() { + if (!this._urlService) { + this._urlService = require('../proxy').urlService.facade; + } + return this._urlService; + } } module.exports = SiteMapManager; diff --git a/ghost/core/core/server/models/base/plugins/raw-knex.js b/ghost/core/core/server/models/base/plugins/raw-knex.js index a8358f2f85d..d0c3b9a95bf 100644 --- a/ghost/core/core/server/models/base/plugins/raw-knex.js +++ b/ghost/core/core/server/models/base/plugins/raw-knex.js @@ -26,7 +26,8 @@ module.exports = function (Bookshelf) { withRelatedFields, id, offset, - limit + limit, + orderBy } = options; const bookshelfPrototype = Bookshelf.registry.models[modelName].prototype; @@ -65,6 +66,10 @@ module.exports = function (Bookshelf) { let query = Bookshelf.knex(tableNames[modelName]); + if (orderBy) { + query.orderBy(orderBy); + } + if (offset) { query.offset(offset); } diff --git a/ghost/core/core/server/services/url/index.js b/ghost/core/core/server/services/url/index.js index 6f7f2db4f0d..e3075d2e185 100644 --- a/ghost/core/core/server/services/url/index.js +++ b/ghost/core/core/server/services/url/index.js @@ -27,15 +27,18 @@ const urlService = new UrlService({cache}); // existing `lazyRouting` flag (unset by default = pure eager, unchanged). // models is already loaded via url-service -> resources, so this require is safe. let lazyUrlService = null; +let fetchRoutableResources = null; if (config.get('lazyRouting')) { const LazyUrlService = require('./lazy-url-service'); const {createFindResource} = require('./lazy-find-resource'); + const {createFetchRoutableResources} = require('./routable-resources'); const models = require('../../models'); lazyUrlService = new LazyUrlService({findResource: createFindResource(models)}); + fetchRoutableResources = createFetchRoutableResources({lazyUrlService}); } const urlServiceFacade = lazyUrlService - ? new UrlServiceFacade({urlService, lazyUrlService, compare: true}) + ? new UrlServiceFacade({urlService, lazyUrlService, compare: true, fetchRoutableResources}) : new UrlServiceFacade({urlService}); // Singleton: default export remains the eager UrlService for backwards diff --git a/ghost/core/core/server/services/url/routable-resources.js b/ghost/core/core/server/services/url/routable-resources.js new file mode 100644 index 00000000000..32a8a340479 --- /dev/null +++ b/ghost/core/core/server/services/url/routable-resources.js @@ -0,0 +1,116 @@ +const errors = require('@tryghost/errors'); + +/** + * Enumerates the routable resources of a type: the rows the lazy URL service + * would produce a URL for. This is the lazy counterpart of the eager + * service's boot walk — same raw-knex fast path, same public-visibility + * gates — computed on demand instead of held in memory. + * + * Callers name the extra columns they want back; everything else stays out + * of memory. The columns URL computation needs (permalink fields, filter + * columns, relations) come from the lazy service itself, so rows are never + * thin for the active routing config. + */ + +// Which rows of each type are routable. visibility:public alone is not +// enough for tags and authors: without the has-posts join, empty tags and +// staff user accounts would be routable/listable. +const TYPE_CONFIG = { + posts: {modelName: 'Post', table: 'posts', filter: 'status:published+type:post'}, + pages: {modelName: 'Post', table: 'posts', filter: 'status:published+type:page'}, + tags: { + modelName: 'Tag', + table: 'tags', + filter: 'visibility:public', + shouldHavePosts: {joinTo: 'tag_id', joinTable: 'posts_tags'} + }, + authors: { + modelName: 'User', + table: 'users', + filter: 'visibility:public', + shouldHavePosts: {joinTo: 'author_id', joinTable: 'posts_authors'} + } +}; + +const RELATION_FIELDS = { + tags: ['tags.id', 'tags.slug'], + authors: ['users.id', 'users.slug'] +}; + +// Keeps each SQLite query under the bound-variable limit (#5810) — the same +// strategy as the eager boot walk (services/url/resources.js). +const SQLITE_BATCH_SIZE = 999; + +/** + * @param {Object} deps + * @param {Object} deps.lazyUrlService - source of truth for the columns and + * relations the active routing config reads + * @returns {(type: string, options?: {columns?: string[]}) => Promise} + */ +function createFetchRoutableResources({lazyUrlService}) { + if (!lazyUrlService) { + throw new errors.IncorrectUsageError({ + message: 'fetchRoutableResources requires a lazy URL service backend' + }); + } + + return async function fetchRoutableResources(type, {columns = []} = {}) { + const typeConfig = TYPE_CONFIG[type]; + if (!typeConfig) { + throw new errors.IncorrectUsageError({ + message: `Unknown routable resource type: ${type}` + }); + } + + // Lazy requires: the model layer must not load before boot wires it. + const models = require('../../models'); + const schema = require('../../data/schema'); + const DatabaseInfo = require('@tryghost/database-info'); + + // Callers speak include; raw_knex only speaks exclude, so translate + // against the table schema here, once. + const include = new Set(['id', ...columns, ...lazyUrlService.getRequiredFields(type)]); + const options = { + modelName: typeConfig.modelName, + filter: typeConfig.filter, + exclude: Object.keys(schema.tables[typeConfig.table]).filter(column => !include.has(column)) + }; + if (typeConfig.shouldHavePosts) { + options.shouldHavePosts = typeConfig.shouldHavePosts; + } + + // Relations only when the active routing config reads them (e.g. + // /:primary_tag/:slug/ permalinks, tag-filtered collections). Pages + // never carry relations, mirroring the eager resource config. + if (type === 'posts') { + const relations = lazyUrlService.getRequiredRelations(); + if (relations.length) { + options.withRelated = relations; + options.withRelatedFields = {}; + for (const relation of relations) { + options.withRelatedFields[relation] = RELATION_FIELDS[relation]; + } + } + } + + let rows; + if (!DatabaseInfo.isSQLite(models.Base.knex)) { + rows = await models.Base.Model.raw_knex.fetchAll(options); + } else { + rows = []; + let offset = 0; + let batch; + do { + // orderBy makes the pagination deterministic; without it the + // row order between batches is unspecified. + batch = await models.Base.Model.raw_knex.fetchAll({...options, orderBy: 'id', offset, limit: SQLITE_BATCH_SIZE}); + rows.push(...batch); + offset += SQLITE_BATCH_SIZE; + } while (batch.length); + } + + return rows; + }; +} + +module.exports = {createFetchRoutableResources}; diff --git a/ghost/core/core/server/services/url/url-service-facade.ts b/ghost/core/core/server/services/url/url-service-facade.ts index ebb1b1cee77..c10aaac1d89 100644 --- a/ghost/core/core/server/services/url/url-service-facade.ts +++ b/ghost/core/core/server/services/url/url-service-facade.ts @@ -37,6 +37,12 @@ export interface Resource { export interface UrlOptions { absolute?: boolean; withSubdirectory?: boolean; + // Bulk callers (the sitemap index build) set this: enumeration parity is + // covered by the getRoutableResources id-set comparison and per-URL + // parity by organic request traffic, so teeing hundreds of thousands of + // rows per rebuild would only capture stacks and recompute for nothing. + // Dies with compare mode. + skipComparison?: boolean; } /** @@ -61,8 +67,21 @@ export interface EagerUrlService { hasFinished(): boolean; onRouterAddedType(...args: unknown[]): unknown; onRouterUpdated(...args: unknown[]): unknown; + // undefined until the eager cache has initialised the type: the compiler + // enforces the guard at the read site. + resources: {getAllByType(type: string): Array<{data: Record}> | undefined}; } +/** + * On-demand enumeration of the routable rows of a type — the lazy + * counterpart of the eager service's in-memory cache. Built by + * `createFetchRoutableResources` (routable-resources.js). + */ +export type FetchRoutableResources = ( + type: string, + options?: {columns?: string[]} +) => Promise>>; + export interface LazyUrlServiceBackend { getUrlForResource(resource: Resource, options?: UrlOptions): string; ownsResource(routerId: string, resource: Resource): boolean; @@ -79,15 +98,25 @@ export class UrlServiceFacade { private urlService: EagerUrlService; private lazyUrlService: LazyUrlServiceBackend | null; private compare: boolean; + private fetchRoutableResources: FetchRoutableResources | null; + private enumComparesInFlight: Set; constructor({ urlService, lazyUrlService = null, - compare = false - }: {urlService: EagerUrlService; lazyUrlService?: LazyUrlServiceBackend | null; compare?: boolean}) { + compare = false, + fetchRoutableResources = null + }: { + urlService: EagerUrlService; + lazyUrlService?: LazyUrlServiceBackend | null; + compare?: boolean; + fetchRoutableResources?: FetchRoutableResources | null; + }) { this.urlService = urlService; this.lazyUrlService = lazyUrlService; this.compare = compare; + this.fetchRoutableResources = fetchRoutableResources; + this.enumComparesInFlight = new Set(); } isLazy(): boolean { @@ -98,6 +127,7 @@ export class UrlServiceFacade { return this.compare && !!this.lazyUrlService; } + /** * The full resource record is required: the lazy backend evaluates NQL * filters and applies permalink templates against it. @@ -107,7 +137,7 @@ export class UrlServiceFacade { return this.lazyUrlService!.getUrlForResource(resource, options); } const url = this.urlService.getUrlByResourceId(resource.id, options); - if (this.isComparing()) { + if (this.isComparing() && !options?.skipComparison) { const context = this._compareContext(resource); setImmediate(() => this._compare('getUrlForResource', url, () => this.lazyUrlService!.getUrlForResource(resource, options), @@ -116,6 +146,71 @@ export class UrlServiceFacade { return url; } + /** + * All routable rows of a type. Eager answers from its in-memory cache; + * lazy fetches from the database on demand. In compare mode the eager + * answer is returned and the lazy fetch runs in the background, with any + * id-set divergence logged — counts and id samples only, never row + * bodies (a large site has hundreds of thousands). + */ + async getRoutableResources(type: string, options: {columns?: string[]} = {}): Promise>> { + if (this.isLazy()) { + if (!this.fetchRoutableResources) { + throw new errors.IncorrectUsageError({ + message: 'getRoutableResources requires an injected fetchRoutableResources in lazy mode' + }); + } + return this.fetchRoutableResources(type, options); + } + + const eagerRows = (this.urlService.resources.getAllByType(type) || []).map(resource => resource.data); + + // Single-flight per type: rapid invalidation cycles must not stack + // concurrent full-table comparison walks. + if (this.isComparing() && this.fetchRoutableResources && !this.enumComparesInFlight.has(type)) { + this.enumComparesInFlight.add(type); + void this._compareRoutableResources(type, options, eagerRows).finally(() => { + this.enumComparesInFlight.delete(type); + }); + } + return eagerRows; + } + + private async _compareRoutableResources( + type: string, + options: {columns?: string[]}, + eagerRows: Array> + ): Promise { + let lazyRows; + try { + lazyRows = await this.fetchRoutableResources!(type, options); + } catch (err) { + this._reportLazyError('getRoutableResources', err as Error, {type}); + return; + } + + const eagerIds = new Set(eagerRows.map(row => row.id)); + const lazyIds = new Set(lazyRows.map(row => row.id)); + const missingFromLazy = [...eagerIds].filter(id => !lazyIds.has(id)); + const extraInLazy = [...lazyIds].filter(id => !eagerIds.has(id)); + + if (!missingFromLazy.length && !extraInLazy.length) { + return; + } + this._report(new errors.InternalServerError({ + message: 'URL service parity mismatch', + code: 'LAZY_URL_PARITY_MISMATCH', + errorDetails: { + method: 'getRoutableResources', + type, + eagerCount: eagerIds.size, + lazyCount: lazyIds.size, + missingFromLazy: missingFromLazy.slice(0, 10), + extraInLazy: extraInLazy.slice(0, 10) + } + })); + } + ownsResource(routerIdentifier: string, resource: Resource): boolean { if (this.isLazy()) { return this.lazyUrlService!.ownsResource(routerIdentifier, resource); diff --git a/ghost/core/test/integration/sitemap-parity.test.js b/ghost/core/test/integration/sitemap-parity.test.js new file mode 100644 index 00000000000..9a1ccbb30e4 --- /dev/null +++ b/ghost/core/test/integration/sitemap-parity.test.js @@ -0,0 +1,120 @@ +const assert = require('node:assert/strict'); +const testUtils = require('../utils'); +const {waitUntilFinished} = require('../utils/url-service-utils'); +const models = require('../../core/server/models'); +const UrlService = require('../../core/server/services/url/url-service'); +const LazyUrlService = require('../../core/server/services/url/lazy-url-service'); +const {createFindResource} = require('../../core/server/services/url/lazy-find-resource'); +const {createFetchRoutableResources} = require('../../core/server/services/url/routable-resources'); + +const RESOURCE_TYPES = ['posts', 'pages', 'tags', 'authors']; + +// The sitemap's own skip rule: resources no router owns resolve to /404/. +const emittedInSitemap = url => url && !url.includes('/404/'); + +// The sitemap must emit the same URL set whether it is fed by the eager URL +// service's events or built from routable-resources enumeration. The eager +// service's boot-time cache is the oracle: everything it caches (and routes) +// belongs in the sitemap, everything else does not. +// +// This pins the enumeration (filters, has-posts gates, loaded relations) +// against the eager resource configs with a real database, which unit tests +// on stubbed queries cannot do. +const SCENARIOS = [ + { + name: 'default routing set', + routes: [ + {identifier: 'posts-router', filter: null, resourceType: 'posts', permalink: '/:slug/'}, + {identifier: 'pages-router', filter: null, resourceType: 'pages', permalink: '/:slug/'}, + {identifier: 'tags-router', filter: null, resourceType: 'tags', permalink: '/tag/:slug/'}, + {identifier: 'authors-router', filter: null, resourceType: 'authors', permalink: '/author/:slug/'} + ] + }, + { + name: 'primary_tag permalinks need the tags relation loaded', + routes: [ + {identifier: 'posts-router', filter: null, resourceType: 'posts', permalink: '/:primary_tag/:slug/'}, + {identifier: 'pages-router', filter: null, resourceType: 'pages', permalink: '/:slug/'}, + {identifier: 'tags-router', filter: null, resourceType: 'tags', permalink: '/tag/:slug/'}, + {identifier: 'authors-router', filter: null, resourceType: 'authors', permalink: '/author/:slug/'} + ] + }, + { + // primary_author is set by the Post authors-relation serializer + // inside raw-knex toJSON; this pins that guarantee against a real + // database, since the enumeration itself adds nothing for it. + name: 'primary_author permalinks need the authors relation loaded', + routes: [ + {identifier: 'posts-router', filter: null, resourceType: 'posts', permalink: '/:primary_author/:slug/'}, + {identifier: 'pages-router', filter: null, resourceType: 'pages', permalink: '/:slug/'}, + {identifier: 'tags-router', filter: null, resourceType: 'tags', permalink: '/tag/:slug/'}, + {identifier: 'authors-router', filter: null, resourceType: 'authors', permalink: '/author/:slug/'} + ] + } +]; + +describe('Integration: sitemap eager/lazy parity', function () { + beforeAll(testUtils.teardownDb); + beforeAll(testUtils.setup('users:roles', 'posts')); + beforeAll(async function () { + // A public tag with no posts: the has-posts gate must keep it out of + // the enumeration the same way eager's shouldHavePosts keeps it out + // of the cache. Explicit so the case does not depend on fixture + // makeup. + await models.Tag.add( + {name: 'Sitemap Orphan Tag', slug: 'sitemap-orphan-tag'}, + {context: {internal: true}} + ); + }); + afterAll(testUtils.teardownDb); + + SCENARIOS.forEach(function (scenario) { + describe(scenario.name, function () { + let eager; + let lazy; + let fetchRoutableResources; + + beforeAll(async function () { + eager = new UrlService(); + scenario.routes.forEach(r => eager.onRouterAddedType(r.identifier, r.filter, r.resourceType, r.permalink)); + eager.init(); + await waitUntilFinished(eager); + + lazy = new LazyUrlService({findResource: createFindResource(models)}); + scenario.routes.forEach(r => lazy.onRouterAddedType(r.identifier, r.filter, r.resourceType, r.permalink)); + fetchRoutableResources = createFetchRoutableResources({lazyUrlService: lazy}); + }); + + afterAll(function () { + eager.reset(); + }); + + it('enumeration produces the same URL set as the eager cache', async function () { + for (const type of RESOURCE_TYPES) { + // What the lazy sitemap build would emit: enumerated rows + // through the lazy backend. + const rows = await fetchRoutableResources(type); + const lazyUrls = rows + .map(datum => lazy.getUrlForResource({...datum, type})) + .filter(emittedInSitemap); + + // What the event-fed sitemap holds: every cached eager + // resource, through the eager URL map. + const eagerUrls = (eager.resources.getAllByType(type) || []) + .map(resource => eager.getUrlByResourceId(resource.data.id)) + .filter(emittedInSitemap); + + assert.deepEqual( + [...lazyUrls].sort(), + [...eagerUrls].sort(), + `URL set mismatch for ${type}` + ); + + if (type === 'posts') { + assert.ok(rows.length > 0, 'expected fixture posts so the comparison is not vacuous'); + } + } + }); + }); + }); +}); diff --git a/ghost/core/test/unit/frontend/services/sitemap/handler.test.js b/ghost/core/test/unit/frontend/services/sitemap/handler.test.js new file mode 100644 index 00000000000..60c4bccb92e --- /dev/null +++ b/ghost/core/test/unit/frontend/services/sitemap/handler.test.js @@ -0,0 +1,63 @@ +const sinon = require('sinon'); +const assert = require('node:assert/strict'); + +const Manager = require('../../../../../core/frontend/services/sitemap/site-map-manager'); +const handler = require('../../../../../core/frontend/services/sitemap/handler'); + +describe('Unit: sitemap/handler', function () { + let sandbox; + let routes; + let res; + let next; + + function register() { + routes = {}; + handler({ + get(path, ...handlers) { + routes[path] = handlers.at(-1); + } + }); + } + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(Manager.prototype, 'getIndexXml').resolves(''); + sandbox.stub(Manager.prototype, 'getSiteMapXml').resolves(''); + res = {set: sandbox.stub(), send: sandbox.stub(), sendStatus: sandbox.stub()}; + next = sandbox.stub(); + register(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('serves the index', async function () { + await routes['/sitemap.xml']({}, res, next); + + sinon.assert.calledWith(res.send, ''); + sinon.assert.notCalled(next); + }); + + it('serves a resource sitemap', async function () { + await routes['/sitemap-:resource.xml']({params: {resource: 'posts'}}, res, next); + + sinon.assert.calledWith(res.send, ''); + sinon.assert.notCalled(next); + }); + + it('forwards a failed XML read to the error handler instead of hanging the request', async function () { + // Express 4 does not forward async handler rejections — see handler.js. + const buildError = new Error('connection lost'); + Manager.prototype.getIndexXml.rejects(buildError); + Manager.prototype.getSiteMapXml.rejects(buildError); + + await routes['/sitemap.xml']({}, res, next); + await routes['/sitemap-:resource.xml']({params: {resource: 'posts'}}, res, next); + + sinon.assert.calledTwice(next); + assert.equal(next.firstCall.args[0], buildError); + assert.equal(next.secondCall.args[0], buildError); + sinon.assert.notCalled(res.send); + }); +}); diff --git a/ghost/core/test/unit/frontend/services/sitemap/manager.test.js b/ghost/core/test/unit/frontend/services/sitemap/manager.test.js index c767b3f01e4..817ede08f78 100644 --- a/ghost/core/test/unit/frontend/services/sitemap/manager.test.js +++ b/ghost/core/test/unit/frontend/services/sitemap/manager.test.js @@ -7,6 +7,7 @@ const DomainEvents = require('@tryghost/domain-events'); const {URLResourceUpdatedEvent} = require('../../../../../core/shared/events'); const events = require('../../../../../core/server/lib/common/events'); +const urlUtils = require('../../../../../core/shared/url-utils'); const SiteMapManager = require('../../../../../core/frontend/services/sitemap/site-map-manager'); const PostGenerator = require('../../../../../core/frontend/services/sitemap/post-map-generator'); @@ -29,7 +30,19 @@ describe('Unit: sitemap/manager', function () { tags = new TagGenerator(); authors = new UserGenerator(); - return new SiteMapManager({posts: posts, pages: pages, tags: tags, authors: authors}); + // Eager-shaped url service: every mode builds on first read now, + // so even the legacy render tests need one injected. + return new SiteMapManager({ + posts: posts, + pages: pages, + tags: tags, + authors: authors, + urlService: { + isLazy: () => false, + getRoutableResources: async () => [], + getUrlForResource: () => '/x/' + } + }); }; beforeAll(function () { @@ -60,11 +73,12 @@ describe('Unit: sitemap/manager', function () { it('can create a SiteMapManager instance', function () { assertExists(manager); - assert.equal(Object.keys(eventsToRemember).length, 4); + assert.equal(Object.keys(eventsToRemember).length, 5); assertExists(eventsToRemember['url.added']); assertExists(eventsToRemember['url.removed']); assertExists(eventsToRemember['router.created']); assertExists(eventsToRemember['routers.reset']); + assertExists(eventsToRemember['site.changed']); }); describe('trigger url events', function () { @@ -114,15 +128,247 @@ describe('Unit: sitemap/manager', function () { }); }); - it('fn: getSiteMapXml', function () { + describe('build path: the index is built from routable resources on first read', function () { + let sandbox; + let urlService; + let fetchStub; + let getUrlForResource; + + function makeManager() { + return new SiteMapManager({ + posts: new PostGenerator(), + pages: new PageGenerator(), + tags: new TagGenerator(), + authors: new UserGenerator(), + urlService + }); + } + + function emitAboutRouter() { + eventsToRemember['router.created']({ + name: 'StaticRoutesRouter', + identifier: 'sr1', + getRoute: () => 'http://example.com/about/' + }); + } + + beforeEach(function () { + sandbox = sinon.createSandbox(); + urlService = { + isLazy: sinon.stub().returns(true), + getRoutableResources: sinon.stub().resolves([]), + getUrlForResource: sinon.stub().returns('http://example.com/x/') + }; + fetchStub = urlService.getRoutableResources; + getUrlForResource = urlService.getUrlForResource; + + // The outer suite stubs PostGenerator.addUrl on the prototype for + // the whole file; reset its history and sandbox-stub the rest so + // call assertions are scoped to each test. + sandbox.stub(PageGenerator.prototype, 'addUrl'); + sandbox.stub(TagGenerator.prototype, 'addUrl'); + sandbox.stub(UserGenerator.prototype, 'addUrl'); + PostGenerator.prototype.addUrl.resetHistory(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('builds once on first read while eager is authoritative, then leaves freshness to the events', async function () { + // The manager only consults isLazy, so eager-only and + // compare mode are the same code path here: build once, + // never invalidate, the per-URL events own freshness. + urlService.isLazy.returns(false); + + const siteMapManager = makeManager(); + await siteMapManager.getSiteMapXml('posts'); + sinon.assert.calledWith(fetchStub, 'posts', sinon.match({columns: sinon.match.array.contains(['canonical_url'])})); + sinon.assert.callCount(fetchStub, 4); + + // Deploying compare mode must be a serving no-op: neither + // site changes nor router registrations trigger rebuilds. + eventsToRemember['site.changed'](); + emitAboutRouter(); + await siteMapManager.getSiteMapXml('posts'); + sinon.assert.callCount(fetchStub, 4); + }); + + it('builds the index from routable resources, skipping /404/ URLs', async function () { + fetchStub.withArgs('posts').resolves([ + {id: 'p1', slug: 'hello'}, + {id: 'p2', slug: 'orphan'} + ]); + fetchStub.withArgs('pages').resolves([{id: 'pg1', slug: 'about'}]); + fetchStub.withArgs('tags').resolves([{id: 't1', slug: 'food'}]); + fetchStub.withArgs('authors').resolves([{id: 'u1', slug: 'jane'}]); + getUrlForResource.callsFake(function (resource) { + if (resource.id === 'p2') { + return urlUtils.createUrl('/404/', true); + } + return `http://example.com/${resource.type}/${resource.slug}/`; + }); + + await makeManager().getSiteMapXml('posts'); + + sinon.assert.calledWith(PostGenerator.prototype.addUrl, 'http://example.com/posts/hello/', sinon.match({id: 'p1'})); + sinon.assert.calledWith(PageGenerator.prototype.addUrl, 'http://example.com/pages/about/', sinon.match({id: 'pg1'})); + sinon.assert.calledWith(TagGenerator.prototype.addUrl, 'http://example.com/tags/food/', sinon.match({id: 't1'})); + sinon.assert.calledWith(UserGenerator.prototype.addUrl, 'http://example.com/authors/jane/', sinon.match({id: 'u1'})); + + const orphanCalls = PostGenerator.prototype.addUrl.getCalls().filter(call => call.args[1] && call.args[1].id === 'p2'); + assert.equal(orphanCalls.length, 0, 'p2 resolves to /404/ and must not enter the sitemap'); + + // Bulk rows must skip the per-call comparison tee: one + // rebuild on a big site would otherwise capture a stack and + // queue a background lazy computation per resource. + sinon.assert.alwaysCalledWith(getUrlForResource, sinon.match.any, sinon.match({skipComparison: true})); + }); + + it('keeps a real resource whose slug is 404, dropping only the exact sentinel', async function () { + fetchStub.withArgs('tags').resolves([{id: 't404', slug: '404'}]); + fetchStub.withArgs('posts').resolves([{id: 'p1', slug: 'orphan'}]); + getUrlForResource.callsFake(function (resource) { + if (resource.id === 't404') { + return `${urlUtils.urlFor('home', true)}tag/404/`; + } + return urlUtils.createUrl('/404/', true); + }); + + await makeManager().getSiteMapXml('posts'); + + sinon.assert.calledWith(TagGenerator.prototype.addUrl, sinon.match(/\/tag\/404\/$/), sinon.match({id: 't404'})); + sinon.assert.notCalled(PostGenerator.prototype.addUrl); + }); + + it('shares one build between concurrent readers, whichever method they use', async function () { + const siteMapManager = makeManager(); + + await Promise.all([siteMapManager.getIndexXml(), siteMapManager.getSiteMapXml('posts')]); + + // One build = one fetch per type. + sinon.assert.callCount(fetchStub, 4); + }); + + it('serves from the built index without refetching', async function () { + const siteMapManager = makeManager(); + + await siteMapManager.getSiteMapXml('posts'); + await siteMapManager.getSiteMapXml('posts'); + + sinon.assert.callCount(fetchStub, 4); + }); + + it('rebuilds after site.changed empties the index', async function () { + const siteMapManager = makeManager(); + await siteMapManager.getSiteMapXml('posts'); + + eventsToRemember['site.changed'](); + await siteMapManager.getSiteMapXml('posts'); + + sinon.assert.callCount(fetchStub, 8); + }); + + it('resets the generators at the start of every apply so a rebuild holds no dropped resources', async function () { + sandbox.stub(PostGenerator.prototype, 'reset'); + const siteMapManager = makeManager(); + await siteMapManager.getSiteMapXml('posts'); + + eventsToRemember['site.changed'](); + await siteMapManager.getSiteMapXml('posts'); + + // Once per build. Without this, a post unpublished between builds + // would stay in the sitemap forever. + sinon.assert.calledTwice(PostGenerator.prototype.reset); + }); + + it('replays static/collection route entries into every rebuild', async function () { + const siteMapManager = makeManager(); + emitAboutRouter(); + PageGenerator.prototype.addUrl.resetHistory(); + + await siteMapManager.getSiteMapXml('posts'); + + // router.created only fires at boot and routes reload; the entry + // must survive the apply-phase generator reset. + sinon.assert.calledWith(PageGenerator.prototype.addUrl, 'http://example.com/about/', sinon.match({id: 'sr1'})); + }); + + it('rebuilds after a router registers, so a reload window cannot pin a routerless index', async function () { + const siteMapManager = makeManager(); + await siteMapManager.getSiteMapXml('posts'); + sinon.assert.callCount(fetchStub, 4); + + emitAboutRouter(); + + await siteMapManager.getSiteMapXml('posts'); + sinon.assert.callCount(fetchStub, 8); + }); + + it('forgets recorded route entries when routers.reset fires', async function () { + const siteMapManager = makeManager(); + emitAboutRouter(); + + eventsToRemember['routers.reset'](); + PageGenerator.prototype.addUrl.resetHistory(); + await siteMapManager.getSiteMapXml('posts'); + + // The routers re-register right after a reset and refill the + // list; a stale entry here would resurrect a deleted route. + sinon.assert.neverCalledWith(PageGenerator.prototype.addUrl, 'http://example.com/about/', sinon.match({id: 'sr1'})); + }); + + it('fails a read with a 503 when the build is invalidated mid-flight, and rebuilds on the next read', async function () { + let resolveFirstFetch; + fetchStub.withArgs('posts') + .onFirstCall().returns(new Promise((resolve) => { + resolveFirstFetch = () => resolve([]); + })) + .onSecondCall().resolves([]); + + const siteMapManager = makeManager(); + const reader = siteMapManager.getSiteMapXml('posts'); + + eventsToRemember['site.changed'](); + resolveFirstFetch(); + + // Never serve pre-invalidation data: a stale 200 would be + // pinned by the CDN for the full cache maxAge. A 503 is + // retried by crawlers and stored by nobody. + await assert.rejects(reader, (err) => { + assert.equal(err.statusCode, 503); + assert.equal(err.code, 'SITEMAP_BUILD_SUPERSEDED'); + return true; + }); + + // The next read starts fresh and succeeds. + await siteMapManager.getSiteMapXml('posts'); + sinon.assert.callCount(fetchStub, 8); + }); + + it('rejects readers when the build fails and retries on the next read', async function () { + fetchStub.withArgs('tags') + .onFirstCall().rejects(new Error('connection lost')) + .onSecondCall().resolves([]); + + const siteMapManager = makeManager(); + + await assert.rejects(siteMapManager.getSiteMapXml('posts'), /connection lost/); + await siteMapManager.getSiteMapXml('posts'); + }); + }); + + it('fn: getSiteMapXml', async function () { + PostGenerator.prototype.getXml.resetHistory(); PostGenerator.prototype.getXml.returns('xml'); - assert.equal(manager.getSiteMapXml('posts'), 'xml'); + assert.equal(await manager.getSiteMapXml('posts'), 'xml'); sinon.assert.calledOnce(PostGenerator.prototype.getXml); }); - it('fn: getIndexXml', function () { + it('fn: getIndexXml', async function () { + IndexGenerator.prototype.getXml.resetHistory(); IndexGenerator.prototype.getXml.returns('xml'); - assert.equal(manager.getIndexXml(), 'xml'); + assert.equal(await manager.getIndexXml(), 'xml'); sinon.assert.calledOnce(IndexGenerator.prototype.getXml); }); }); diff --git a/ghost/core/test/unit/server/services/url/routable-resources.test.js b/ghost/core/test/unit/server/services/url/routable-resources.test.js new file mode 100644 index 00000000000..5cd3efcc091 --- /dev/null +++ b/ghost/core/test/unit/server/services/url/routable-resources.test.js @@ -0,0 +1,105 @@ +const sinon = require('sinon'); +const assert = require('node:assert/strict'); +const DatabaseInfo = require('@tryghost/database-info'); + +const models = require('../../../../../core/server/models'); +const {createFetchRoutableResources} = require('../../../../../core/server/services/url/routable-resources'); + +describe('Unit: services/url/routable-resources', function () { + let sandbox; + let fetchAll; + let lazyUrlService; + let fetchRoutableResources; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + fetchAll = sandbox.stub(models.Base.Model.raw_knex, 'fetchAll').resolves([]); + sandbox.stub(DatabaseInfo, 'isSQLite').returns(false); + lazyUrlService = { + getRequiredFields: sandbox.stub().returns([]), + getRequiredRelations: sandbox.stub().returns([]) + }; + fetchRoutableResources = createFetchRoutableResources({lazyUrlService}); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('requires a lazy URL service backend', function () { + assert.throws(() => createFetchRoutableResources({}), /lazy/i); + }); + + it('rejects an unknown resource type', async function () { + await assert.rejects(fetchRoutableResources('collections'), /collections/); + }); + + it('selects only id, the requested columns and what the routers require', async function () { + lazyUrlService.getRequiredFields.withArgs('posts').returns(['slug', 'featured']); + + await fetchRoutableResources('posts', {columns: ['feature_image', 'canonical_url']}); + + const {exclude} = fetchAll.firstCall.args[0]; + for (const kept of ['id', 'slug', 'featured', 'feature_image', 'canonical_url']) { + assert.ok(!exclude.includes(kept), `${kept} must not be excluded`); + } + for (const dropped of ['mobiledoc', 'lexical', 'html', 'plaintext', 'title']) { + assert.ok(exclude.includes(dropped), `${dropped} must be excluded`); + } + }); + + it('applies the routing gates for each type', async function () { + await fetchRoutableResources('posts'); + await fetchRoutableResources('pages'); + await fetchRoutableResources('tags'); + await fetchRoutableResources('authors'); + + sinon.assert.calledWith(fetchAll, sinon.match({modelName: 'Post', filter: 'status:published+type:post'})); + sinon.assert.calledWith(fetchAll, sinon.match({modelName: 'Post', filter: 'status:published+type:page'})); + // visibility:public alone is not enough for tags and authors: without + // the has-posts join, empty tags and staff user accounts would be + // routable/listable. + sinon.assert.calledWith(fetchAll, sinon.match({ + modelName: 'Tag', + filter: 'visibility:public', + shouldHavePosts: {joinTo: 'tag_id', joinTable: 'posts_tags'} + })); + sinon.assert.calledWith(fetchAll, sinon.match({ + modelName: 'User', + filter: 'visibility:public', + shouldHavePosts: {joinTo: 'author_id', joinTable: 'posts_authors'} + })); + }); + + it('loads post relations only when the active routing config reads them', async function () { + await fetchRoutableResources('posts'); + assert.equal(fetchAll.firstCall.args[0].withRelated, undefined); + + lazyUrlService.getRequiredRelations.returns(['tags', 'authors']); + await fetchRoutableResources('posts'); + assert.deepEqual(fetchAll.secondCall.args[0].withRelated, ['tags', 'authors']); + assert.deepEqual(fetchAll.secondCall.args[0].withRelatedFields, { + tags: ['tags.id', 'tags.slug'], + authors: ['users.id', 'users.slug'] + }); + + // Pages never carry relations, mirroring the eager resource config. + await fetchRoutableResources('pages'); + assert.equal(fetchAll.thirdCall.args[0].withRelated, undefined); + }); + + it('batches on SQLite to avoid the bound-variable limit, in a deterministic order', async function () { + DatabaseInfo.isSQLite.returns(true); + fetchAll.onFirstCall().resolves([{id: 'p1'}]); + fetchAll.onSecondCall().resolves([]); + + const rows = await fetchRoutableResources('posts'); + + assert.deepEqual(rows, [{id: 'p1'}]); + assert.equal(fetchAll.firstCall.args[0].offset, 0); + assert.equal(fetchAll.firstCall.args[0].orderBy, 'id'); + assert.equal(fetchAll.secondCall.args[0].offset, 999); + assert.equal(fetchAll.secondCall.args[0].limit, 999); + }); + +}); diff --git a/ghost/core/test/unit/server/services/url/url-service-facade.test.js b/ghost/core/test/unit/server/services/url/url-service-facade.test.js index 6e0f8e32c4a..b16e2cfd182 100644 --- a/ghost/core/test/unit/server/services/url/url-service-facade.test.js +++ b/ghost/core/test/unit/server/services/url/url-service-facade.test.js @@ -176,6 +176,143 @@ describe('UrlServiceFacade', function () { }); }); + describe('getRoutableResources', function () { + const flush = () => new Promise((resolve) => { + setImmediate(resolve); + }); + + beforeEach(function () { + urlService.resources = { + getAllByType: sinon.stub().returns([ + {data: {id: 'a', slug: 'one'}}, + {data: {id: 'b', slug: 'two'}} + ]) + }; + }); + + it('returns [] when the eager cache has not initialised the type yet', async function () { + urlService.resources.getAllByType.returns(undefined); + + assert.deepEqual(await facade.getRoutableResources('posts'), []); + }); + + it('answers from the eager cache with no lazy backend', async function () { + const rows = await facade.getRoutableResources('posts'); + + sinon.assert.calledWith(urlService.resources.getAllByType, 'posts'); + assert.deepEqual(rows, [{id: 'a', slug: 'one'}, {id: 'b', slug: 'two'}]); + }); + + it('routes to the injected fetcher in lazy mode', async function () { + const fetchRoutableResources = sinon.stub().resolves([{id: 'c'}]); + const lazyFacade = new UrlServiceFacade({ + urlService, + lazyUrlService: {}, + fetchRoutableResources + }); + + const rows = await lazyFacade.getRoutableResources('posts', {columns: ['feature_image']}); + + sinon.assert.calledWith(fetchRoutableResources, 'posts', {columns: ['feature_image']}); + assert.deepEqual(rows, [{id: 'c'}]); + }); + + it('throws in lazy mode without an injected fetcher rather than answering from eager', async function () { + const lazyFacade = new UrlServiceFacade({urlService, lazyUrlService: {}}); + + await assert.rejects(lazyFacade.getRoutableResources('posts'), /fetchRoutableResources/); + }); + + describe('in compare mode', function () { + let fetchRoutableResources; + let compareFacade; + + beforeEach(function () { + fetchRoutableResources = sinon.stub().resolves([{id: 'a'}, {id: 'b'}]); + compareFacade = new UrlServiceFacade({ + urlService, + lazyUrlService: {}, + compare: true, + fetchRoutableResources + }); + sinon.stub(logging, 'error'); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('returns the eager rows and stays silent when the id sets match', async function () { + const rows = await compareFacade.getRoutableResources('posts'); + await flush(); + + assert.deepEqual(rows.map(row => row.id), ['a', 'b']); + sinon.assert.calledOnce(fetchRoutableResources); + sinon.assert.notCalled(logging.error); + }); + + it('logs a parity mismatch when the id sets diverge, without dumping the rows', async function () { + fetchRoutableResources.resolves([{id: 'a'}, {id: 'c'}]); + + await compareFacade.getRoutableResources('posts'); + await flush(); + + sinon.assert.calledOnce(logging.error); + const reported = logging.error.firstCall.args[0]; + assert.equal(reported.code, 'LAZY_URL_PARITY_MISMATCH'); + assert.deepEqual(reported.errorDetails.missingFromLazy, ['b']); + assert.deepEqual(reported.errorDetails.extraInLazy, ['c']); + assert.equal(reported.errorDetails.eagerCount, 2); + assert.ok(!JSON.stringify(reported.errorDetails).includes('slug'), 'row bodies must not be logged'); + }); + + it('does not stack concurrent comparison walks for the same type', async function () { + let resolveWalk; + fetchRoutableResources.onFirstCall().returns(new Promise((resolve) => { + resolveWalk = () => resolve([{id: 'a'}, {id: 'b'}]); + })); + + await compareFacade.getRoutableResources('posts'); + await compareFacade.getRoutableResources('posts'); + + sinon.assert.calledOnce(fetchRoutableResources); + + resolveWalk(); + await flush(); + + // Once the walk settles, the next call may compare again. + await compareFacade.getRoutableResources('posts'); + sinon.assert.calledTwice(fetchRoutableResources); + }); + + it('logs instead of throwing when the lazy fetch fails', async function () { + fetchRoutableResources.rejects(new Error('connection lost')); + + const rows = await compareFacade.getRoutableResources('posts'); + await flush(); + + assert.equal(rows.length, 2, 'eager answer unaffected'); + sinon.assert.calledOnce(logging.error); + assert.equal(logging.error.firstCall.args[0].code, 'LAZY_URL_COMPARE_ERROR'); + }); + }); + }); + + describe('getUrlForResource with skipComparison', function () { + it('answers from eager without teeing the lazy backend', async function () { + const lazyUrlService = {getUrlForResource: sinon.stub()}; + const compareFacade = new UrlServiceFacade({urlService, lazyUrlService, compare: true}); + + const url = compareFacade.getUrlForResource({id: 'abc', type: 'posts'}, {absolute: true, skipComparison: true}); + + assert.equal(url, '/hello-world/'); + await new Promise((resolve) => { + setImmediate(resolve); + }); + sinon.assert.notCalled(lazyUrlService.getUrlForResource); + }); + }); + describe('compare mode (eager authoritative, lazy teed alongside)', function () { let lazyUrlService; let compareFacade;