Skip to content

Commit f48639c

Browse files
vershwalcursoragent
andcommitted
Added eager/lazy URL service parity coverage
ref https://linear.app/ghost/issue/HKG-1817 - the whole point of this milestone is to swap the eager service for the lazy one without changing any URL, so the lazy service is only trustworthy if it provably returns the same answer as the eager service for the same inputs; this integration test is that proof, run against real fixtures and a real findResource over the model layer - drove both services from one identical routing set per scenario and fed the lazy service the exact records the eager service cached, so any difference the test reports is a routing-logic divergence and not a data-shape artefact - compared all three directions a caller depends on: forward (relative and absolute URL for every cached resource, including the resources that resolve to /404/), ownership (every router against every resource), and reverse (every eager-generated URL resolves back to the same resource and type) - added a featured-collection scenario alongside the default set specifically to exercise priority fallthrough; this is what caught the ownership exclusivity bug now fixed in the service, where a catch-all router claimed resources already owned by a higher-priority collection - guarded against a vacuous pass by asserting the fixtures actually produce cached resources and generated URLs, so the parity assertions can never silently iterate over nothing Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7fcbc2e commit f48639c

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
const assert = require('node:assert/strict');
2+
const sinon = require('sinon');
3+
const testUtils = require('../utils');
4+
const models = require('../../core/server/models');
5+
const UrlService = require('../../core/server/services/url/url-service');
6+
const LazyUrlService = require('../../core/server/services/url/lazy-url-service');
7+
const {createFindResource} = require('../../core/server/services/url/lazy-find-resource');
8+
9+
const RESOURCE_TYPES = ['posts', 'pages', 'tags', 'authors'];
10+
11+
// Each scenario is a routing set registered identically on both services. They
12+
// stick to slug-backed permalinks because that is Ghost's default shape and the
13+
// one the reverse lookup queries the DB by; the goal is to prove the two
14+
// services agree, not to vary permalink columns.
15+
const SCENARIOS = [
16+
{
17+
name: 'default routing set',
18+
routes: [
19+
{identifier: 'posts-router', filter: 'featured:false', resourceType: 'posts', permalink: '/:slug/'},
20+
{identifier: 'authors-router', filter: null, resourceType: 'authors', permalink: '/author/:slug/'},
21+
{identifier: 'tags-router', filter: null, resourceType: 'tags', permalink: '/tag/:slug/'},
22+
{identifier: 'pages-router', filter: null, resourceType: 'pages', permalink: '/:slug/'}
23+
]
24+
},
25+
{
26+
name: 'featured collection with priority fallthrough',
27+
routes: [
28+
{identifier: 'featured-router', filter: 'featured:true', resourceType: 'posts', permalink: '/featured/:slug/'},
29+
{identifier: 'posts-router', filter: null, resourceType: 'posts', permalink: '/:slug/'},
30+
{identifier: 'tags-router', filter: null, resourceType: 'tags', permalink: '/tag/:slug/'},
31+
{identifier: 'authors-router', filter: null, resourceType: 'authors', permalink: '/author/:slug/'}
32+
]
33+
}
34+
];
35+
36+
function waitUntilFinished(urlService, timeout = 5000) {
37+
return new Promise((resolve, reject) => {
38+
const start = Date.now();
39+
(function retry() {
40+
if (urlService.hasFinished()) {
41+
return resolve();
42+
}
43+
if (Date.now() - start > timeout) {
44+
return reject(new Error('Eager UrlService did not finish in time'));
45+
}
46+
setTimeout(retry, 50);
47+
})();
48+
});
49+
}
50+
51+
describe('Integration: eager/lazy URL service parity', function () {
52+
before(testUtils.teardownDb);
53+
before(testUtils.setup('users:roles', 'posts'));
54+
after(testUtils.teardownDb);
55+
56+
after(function () {
57+
sinon.restore();
58+
});
59+
60+
SCENARIOS.forEach(function (scenario) {
61+
describe(scenario.name, function () {
62+
let eager;
63+
let lazy;
64+
65+
before(async function () {
66+
eager = new UrlService();
67+
scenario.routes.forEach(r => eager.onRouterAddedType(r.identifier, r.filter, r.resourceType, r.permalink));
68+
eager.init();
69+
await waitUntilFinished(eager);
70+
71+
lazy = new LazyUrlService({findResource: createFindResource(models)});
72+
scenario.routes.forEach(r => lazy.onRouterAddedType(r.identifier, r.filter, r.resourceType, r.permalink));
73+
});
74+
75+
after(function () {
76+
eager.reset();
77+
});
78+
79+
function cachedResourcesByType() {
80+
return RESOURCE_TYPES.map(type => ({
81+
type,
82+
resources: eager.resources.getAllByType(type) || []
83+
}));
84+
}
85+
86+
function allGeneratedUrls() {
87+
const urls = [];
88+
eager.urlGenerators.forEach((generator) => {
89+
generator.getUrls().forEach((entry) => {
90+
urls.push({url: entry.url, id: entry.resource.data.id});
91+
});
92+
});
93+
return urls;
94+
}
95+
96+
it('loaded a non-trivial fixture set so the comparison is not vacuous', function () {
97+
const total = cachedResourcesByType().reduce((sum, group) => sum + group.resources.length, 0);
98+
assert.ok(total > 0, 'expected the eager service to cache at least one resource');
99+
assert.ok(allGeneratedUrls().length > 0, 'expected the eager service to generate at least one url');
100+
});
101+
102+
it('forward lookup returns the same relative URL for every cached resource', function () {
103+
for (const {type, resources} of cachedResourcesByType()) {
104+
for (const resource of resources) {
105+
const id = resource.data.id;
106+
const lazyResource = Object.assign({}, resource.data, {type});
107+
108+
assert.equal(
109+
lazy.getUrlForResource(lazyResource),
110+
eager.getUrlByResourceId(id),
111+
`relative URL mismatch for ${type} ${id}`
112+
);
113+
}
114+
}
115+
});
116+
117+
it('forward lookup returns the same absolute URL for every cached resource', function () {
118+
for (const {type, resources} of cachedResourcesByType()) {
119+
for (const resource of resources) {
120+
const id = resource.data.id;
121+
const lazyResource = Object.assign({}, resource.data, {type});
122+
123+
assert.equal(
124+
lazy.getUrlForResource(lazyResource, {absolute: true}),
125+
eager.getUrlByResourceId(id, {absolute: true}),
126+
`absolute URL mismatch for ${type} ${id}`
127+
);
128+
}
129+
}
130+
});
131+
132+
it('agrees with the eager service on which router owns each resource', function () {
133+
for (const {type, resources} of cachedResourcesByType()) {
134+
for (const resource of resources) {
135+
const id = resource.data.id;
136+
const lazyResource = Object.assign({}, resource.data, {type});
137+
138+
for (const route of scenario.routes) {
139+
assert.equal(
140+
lazy.ownsResource(route.identifier, lazyResource),
141+
eager.owns(route.identifier, id),
142+
`ownership mismatch for router ${route.identifier} and ${type} ${id}`
143+
);
144+
}
145+
}
146+
}
147+
});
148+
149+
it('reverse lookup resolves every eager-generated URL to the same resource', async function () {
150+
for (const {url, id} of allGeneratedUrls()) {
151+
const eagerEnvelope = eager.getResource(url);
152+
const resolved = await lazy.resolveUrl(url);
153+
154+
assert.ok(resolved, `lazy resolveUrl returned null for ${url}`);
155+
assert.equal(resolved.id, id, `resolved id mismatch for ${url}`);
156+
assert.equal(resolved.type, eagerEnvelope.config.type, `resolved type mismatch for ${url}`);
157+
}
158+
});
159+
160+
it('reverse lookup returns null for an unknown URL, like the eager service', async function () {
161+
assert.equal(eager.getResource('/this-does-not-exist/'), null);
162+
assert.equal(await lazy.resolveUrl('/this-does-not-exist/'), null);
163+
});
164+
});
165+
});
166+
});

0 commit comments

Comments
 (0)