apostrophe 4.27.1 → 4.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/index.js +3 -0
  3. package/lib/stream-proxy.js +49 -0
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +2 -11
  5. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +38 -6
  6. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +12 -1
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +111 -41
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +1 -0
  9. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +22 -10
  10. package/modules/@apostrophecms/area/ui/apos/logic/AposAreaEditor.js +40 -0
  11. package/modules/@apostrophecms/asset/index.js +3 -2
  12. package/modules/@apostrophecms/attachment/index.js +270 -0
  13. package/modules/@apostrophecms/doc/index.js +8 -2
  14. package/modules/@apostrophecms/doc-type/index.js +81 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +18 -2
  16. package/modules/@apostrophecms/express/index.js +30 -1
  17. package/modules/@apostrophecms/file/index.js +71 -6
  18. package/modules/@apostrophecms/i18n/index.js +20 -1
  19. package/modules/@apostrophecms/image/index.js +11 -0
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +31 -6
  21. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +12 -10
  22. package/modules/@apostrophecms/login/index.js +43 -11
  23. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +2 -1
  24. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +5 -0
  25. package/modules/@apostrophecms/page/index.js +9 -11
  26. package/modules/@apostrophecms/page-type/index.js +6 -1
  27. package/modules/@apostrophecms/piece-page-type/index.js +100 -13
  28. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +1 -0
  29. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +28 -12
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +1 -0
  31. package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +1 -1
  32. package/modules/@apostrophecms/styles/lib/apiRoutes.js +25 -5
  33. package/modules/@apostrophecms/styles/lib/handlers.js +19 -0
  34. package/modules/@apostrophecms/styles/lib/methods.js +35 -12
  35. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +7 -2
  36. package/modules/@apostrophecms/task/index.js +9 -1
  37. package/modules/@apostrophecms/template/views/outerLayoutBase.html +3 -0
  38. package/modules/@apostrophecms/ui/index.js +2 -0
  39. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  40. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +5 -0
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +5 -0
  42. package/modules/@apostrophecms/ui/ui/apos/lib/vue.js +2 -0
  43. package/modules/@apostrophecms/ui/ui/apos/stores/widget.js +12 -7
  44. package/modules/@apostrophecms/ui/ui/apos/stores/widgetGraph.js +461 -0
  45. package/modules/@apostrophecms/ui/ui/apos/universal/graph.js +452 -0
  46. package/modules/@apostrophecms/ui/ui/apos/universal/widgetGraph.js +10 -0
  47. package/modules/@apostrophecms/uploadfs/index.js +15 -1
  48. package/modules/@apostrophecms/url/index.js +419 -1
  49. package/package.json +6 -6
  50. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  51. package/test/external-front.js +1 -0
  52. package/test/files.js +135 -0
  53. package/test/login-requirements.js +145 -3
  54. package/test/static-build.js +2701 -0
  55. package/test/universal-graph.js +1135 -0
@@ -0,0 +1,2701 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ describe('Static Build Support', function () {
5
+ this.timeout(t.timeout);
6
+
7
+ describe('URL helper methods', function () {
8
+ let apos;
9
+
10
+ before(async function () {
11
+ apos = await t.create({
12
+ root: module,
13
+ modules: {
14
+ '@apostrophecms/url': {}
15
+ }
16
+ });
17
+ });
18
+
19
+ after(async function () {
20
+ await t.destroy(apos);
21
+ apos = null;
22
+ });
23
+
24
+ it('should initialize with static: false (default)', async function () {
25
+ assert(apos.url);
26
+ assert.strictEqual(apos.url.options.static, false);
27
+ });
28
+
29
+ it('getChoiceFilter returns query string format when static is false', function () {
30
+ assert.strictEqual(
31
+ apos.url.getChoiceFilter('category', 'tech', 1),
32
+ '?category=tech'
33
+ );
34
+ });
35
+
36
+ it('getChoiceFilter returns query string with page when static is false', function () {
37
+ assert.strictEqual(
38
+ apos.url.getChoiceFilter('category', 'tech', 2),
39
+ '?category=tech&page=2'
40
+ );
41
+ });
42
+
43
+ it('getChoiceFilter returns empty string for null value', function () {
44
+ assert.strictEqual(apos.url.getChoiceFilter('category', null, 1), '');
45
+ });
46
+
47
+ it('getChoiceFilter encodes special characters', function () {
48
+ assert.strictEqual(
49
+ apos.url.getChoiceFilter('my filter', 'hello world', 1),
50
+ '?my%20filter=hello%20world'
51
+ );
52
+ });
53
+
54
+ it('getPageFilter returns empty string for page 1', function () {
55
+ assert.strictEqual(apos.url.getPageFilter(1), '');
56
+ });
57
+
58
+ it('getPageFilter returns query string for page > 1 when static is false', function () {
59
+ assert.strictEqual(apos.url.getPageFilter(2), '?page=2');
60
+ });
61
+ });
62
+
63
+ describe('Static mode URL helpers', function () {
64
+ let apos;
65
+
66
+ before(async function () {
67
+ apos = await t.create({
68
+ root: module,
69
+ modules: {
70
+ '@apostrophecms/url': {
71
+ options: { static: true }
72
+ }
73
+ }
74
+ });
75
+ });
76
+
77
+ after(async function () {
78
+ await t.destroy(apos);
79
+ apos = null;
80
+ });
81
+
82
+ it('should initialize with static: true', async function () {
83
+ assert.strictEqual(apos.url.options.static, true);
84
+ });
85
+
86
+ it('getChoiceFilter returns path format when static is true', function () {
87
+ assert.strictEqual(
88
+ apos.url.getChoiceFilter('category', 'tech', 1),
89
+ '/category/tech'
90
+ );
91
+ });
92
+
93
+ it('getChoiceFilter returns path with page when static is true', function () {
94
+ assert.strictEqual(
95
+ apos.url.getChoiceFilter('category', 'tech', 2),
96
+ '/category/tech/page/2'
97
+ );
98
+ });
99
+
100
+ it('getPageFilter returns path format for page > 1 when static is true', function () {
101
+ assert.strictEqual(apos.url.getPageFilter(2), '/page/2');
102
+ assert.strictEqual(apos.url.getPageFilter(3), '/page/3');
103
+ });
104
+
105
+ it('getPageFilter still returns empty string for page 1 in static mode', function () {
106
+ assert.strictEqual(apos.url.getPageFilter(1), '');
107
+ });
108
+ });
109
+
110
+ describe('getAllUrlMetadata', function () {
111
+ let apos;
112
+
113
+ before(async function () {
114
+ apos = await t.create({
115
+ root: module,
116
+ modules: {
117
+ '@apostrophecms/url': {
118
+ options: { static: true }
119
+ },
120
+ article: {
121
+ extend: '@apostrophecms/piece-type',
122
+ options: {
123
+ name: 'article',
124
+ label: 'Article',
125
+ alias: 'article',
126
+ sort: { title: 1 }
127
+ },
128
+ fields: {
129
+ add: {
130
+ category: {
131
+ type: 'select',
132
+ label: 'Category',
133
+ choices: [
134
+ {
135
+ label: 'Tech',
136
+ value: 'tech'
137
+ },
138
+ {
139
+ label: 'Science',
140
+ value: 'science'
141
+ },
142
+ {
143
+ label: 'Art',
144
+ value: 'art'
145
+ }
146
+ ]
147
+ }
148
+ }
149
+ }
150
+ },
151
+ 'article-page': {
152
+ extend: '@apostrophecms/piece-page-type',
153
+ options: {
154
+ name: 'articlePage',
155
+ label: 'Articles',
156
+ alias: 'articlePage',
157
+ perPage: 5,
158
+ piecesFilters: [
159
+ { name: 'category' }
160
+ ]
161
+ }
162
+ },
163
+ '@apostrophecms/page': {
164
+ options: {
165
+ park: [
166
+ {
167
+ title: 'Articles',
168
+ type: 'articlePage',
169
+ slug: '/articles',
170
+ parkedId: 'articles'
171
+ }
172
+ ]
173
+ }
174
+ }
175
+ }
176
+ });
177
+
178
+ // Insert 12 articles across 3 categories
179
+ const req = apos.task.getReq();
180
+ for (let i = 1; i <= 12; i++) {
181
+ const padded = String(i).padStart(3, '0');
182
+ const categories = [ 'tech', 'science', 'art' ];
183
+ const category = categories[(i - 1) % 3];
184
+ await apos.article.insert(req, {
185
+ title: `Article ${padded}`,
186
+ slug: `article-${padded}`,
187
+ visibility: 'public',
188
+ category
189
+ });
190
+ }
191
+ });
192
+
193
+ after(async function () {
194
+ await t.destroy(apos);
195
+ apos = null;
196
+ });
197
+
198
+ it('should return URL metadata for all documents', async function () {
199
+ const req = apos.task.getAnonReq({ mode: 'published' });
200
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
201
+ assert(Array.isArray(results));
202
+ assert(results.length > 0);
203
+
204
+ const articlesPage = results.find(r => r.url === '/articles');
205
+ assert(articlesPage, 'Should include the articles index page');
206
+ assert.strictEqual(articlesPage.type, 'articlePage');
207
+ assert(articlesPage.aposDocId);
208
+ assert(articlesPage.i18nId);
209
+ });
210
+
211
+ it('should include individual article URLs', async function () {
212
+ const req = apos.task.getAnonReq({ mode: 'published' });
213
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
214
+ const articleUrls = results.filter(r => r.type === 'article');
215
+
216
+ assert.strictEqual(articleUrls.length, 12);
217
+ assert(articleUrls.every(a => a.url.startsWith('/articles/article-')));
218
+ });
219
+
220
+ it('document entries should not have contentType', async function () {
221
+ const req = apos.task.getAnonReq({ mode: 'published' });
222
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
223
+ const docEntries = results.filter(r => r.aposDocId);
224
+
225
+ assert(docEntries.length > 0, 'Should have document entries');
226
+ for (const entry of docEntries) {
227
+ assert.strictEqual(
228
+ entry.contentType,
229
+ undefined,
230
+ `Document entry ${entry.url} should not have contentType`
231
+ );
232
+ assert.notStrictEqual(
233
+ entry.sitemap,
234
+ false,
235
+ `Document entry ${entry.url} should not set sitemap: false`
236
+ );
237
+ }
238
+ });
239
+
240
+ it('should include filter URLs in static mode', async function () {
241
+ const req = apos.task.getAnonReq({ mode: 'published' });
242
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
243
+
244
+ // Should include filter URLs like /articles/category/tech
245
+ const filterUrls = results.filter(
246
+ r => r.url && r.url.match(/\/articles\/category\//)
247
+ );
248
+ assert(filterUrls.length > 0, 'Should include filter URLs');
249
+
250
+ // Should have entries for each category with pieces
251
+ const categories = [ 'tech', 'science', 'art' ];
252
+ for (const cat of categories) {
253
+ const catUrl = filterUrls.find(
254
+ r => r.url === `/articles/category/${cat}`
255
+ );
256
+ assert(catUrl, `Should include URL for category: ${cat}`);
257
+ }
258
+ });
259
+
260
+ it('should include pagination URLs in static mode', async function () {
261
+ const req = apos.task.getAnonReq({ mode: 'published' });
262
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
263
+
264
+ // 12 articles with perPage=5 means 3 pages.
265
+ // Page 1 is the base URL (/articles), so only /page/2 and /page/3
266
+ // should appear as separate entries.
267
+ const paginationUrls = results.filter(
268
+ r => r.url && r.url.match(/\/articles\/page\/\d+$/)
269
+ );
270
+ assert.strictEqual(
271
+ paginationUrls.length,
272
+ 2,
273
+ 'Should have exactly 2 pagination URLs'
274
+ );
275
+ assert(
276
+ paginationUrls.some(r => r.url === '/articles/page/2'),
277
+ 'Should include page 2'
278
+ );
279
+ assert(
280
+ paginationUrls.some(r => r.url === '/articles/page/3'),
281
+ 'Should include page 3'
282
+ );
283
+ assert(
284
+ !paginationUrls.some(r => r.url === '/articles/page/1'),
285
+ 'Should not include page 1 (that is the base URL)'
286
+ );
287
+ });
288
+
289
+ it('filter URLs should use path format in static mode', async function () {
290
+ const req = apos.task.getAnonReq({ mode: 'published' });
291
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
292
+ const filterUrls = results.filter(
293
+ r => r.url && r.url.match(/\/articles\/category\//)
294
+ );
295
+ // 3 categories with 4 articles each, perPage=5: 1 page per category,
296
+ // so exactly 3 filter URLs (no paginated filter URLs)
297
+ assert.strictEqual(filterUrls.length, 3, 'Should have exactly 3 filter URLs');
298
+ for (const entry of filterUrls) {
299
+ assert(!entry.url.includes('?'), `URL should not contain query string: ${entry.url}`);
300
+ }
301
+ });
302
+
303
+ it('should have consistent i18nId values', async function () {
304
+ const req = apos.task.getAnonReq({ mode: 'published' });
305
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
306
+ for (const entry of results) {
307
+ assert(entry.i18nId, `Entry with url ${entry.url} should have i18nId`);
308
+ }
309
+ const ids = results.map(r => r.i18nId);
310
+ const unique = new Set(ids);
311
+ assert.strictEqual(
312
+ unique.size,
313
+ ids.length,
314
+ 'All i18nId values should be unique'
315
+ );
316
+ });
317
+
318
+ it('should include home page URL', async function () {
319
+ const req = apos.task.getAnonReq({ mode: 'published' });
320
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
321
+ const home = results.find(r => r.url === '/');
322
+ assert(home, 'Should include the home page');
323
+ });
324
+
325
+ it('should respect excludeTypes option', async function () {
326
+ const req = apos.task.getAnonReq({ mode: 'published' });
327
+ const { pages: results } = await apos.url.getAllUrlMetadata(req, {
328
+ excludeTypes: [ 'article' ]
329
+ });
330
+ const articles = results.filter(r => r.type === 'article');
331
+ assert.strictEqual(articles.length, 0, 'Should not include excluded types');
332
+ });
333
+ });
334
+
335
+ describe('getAllUrlMetadata with literal content entries', function () {
336
+ let apos;
337
+
338
+ before(async function () {
339
+ apos = await t.create({
340
+ root: module,
341
+ modules: {
342
+ '@apostrophecms/url': {
343
+ options: { static: true }
344
+ }
345
+ }
346
+ });
347
+ // Simulate a styles stylesheet being present in the global doc
348
+ // by setting it directly in the database
349
+ await apos.doc.db.updateOne(
350
+ {
351
+ type: '@apostrophecms/global',
352
+ aposLocale: 'en:published'
353
+ },
354
+ {
355
+ $set: {
356
+ stylesStylesheet: 'body { color: red; }',
357
+ stylesStylesheetVersion: 'test-version'
358
+ }
359
+ }
360
+ );
361
+ });
362
+
363
+ after(async function () {
364
+ await t.destroy(apos);
365
+ apos = null;
366
+ });
367
+
368
+ it('should include styles stylesheet as a literal content entry', async function () {
369
+ const req = apos.task.getAnonReq({ mode: 'published' });
370
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
371
+ const stylesheet = results.find(
372
+ r => r.i18nId === '@apostrophecms/styles:stylesheet'
373
+ );
374
+ assert(stylesheet, 'Should include styles stylesheet entry');
375
+ assert.strictEqual(stylesheet.contentType, 'text/css');
376
+ assert.strictEqual(stylesheet.sitemap, false, 'Literal content entries should have sitemap: false');
377
+ assert(
378
+ stylesheet.url.includes('/api/v1/@apostrophecms/styles/stylesheet'),
379
+ 'URL should point to the styles API route'
380
+ );
381
+ assert(
382
+ stylesheet.url.includes('version=test-version'),
383
+ 'URL should include the stylesheet version for cache busting'
384
+ );
385
+ });
386
+
387
+ it('literal content entries have contentType property', async function () {
388
+ const req = apos.task.getAnonReq({ mode: 'published' });
389
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
390
+ const literals = results.filter(r => r.contentType);
391
+
392
+ for (const entry of literals) {
393
+ assert(typeof entry.contentType === 'string');
394
+ assert(entry.url);
395
+ assert(entry.i18nId);
396
+ assert.strictEqual(entry.sitemap, false, 'Literal content entries should opt out of sitemaps');
397
+ assert(!entry.changefreq, 'Literal content entries should not have changefreq');
398
+ assert(!entry.priority, 'Literal content entries should not have priority');
399
+ }
400
+ });
401
+ });
402
+
403
+ describe('getAllUrlMetadata with attachments', function () {
404
+ let apos;
405
+
406
+ before(async function () {
407
+ apos = await t.create({
408
+ root: module,
409
+ modules: {
410
+ '@apostrophecms/url': {
411
+ options: { static: true }
412
+ },
413
+ article: {
414
+ extend: '@apostrophecms/piece-type',
415
+ options: {
416
+ name: 'article',
417
+ label: 'Article',
418
+ alias: 'article'
419
+ },
420
+ fields: {
421
+ add: {
422
+ _image: {
423
+ type: 'relationship',
424
+ withType: '@apostrophecms/image',
425
+ label: 'Image',
426
+ max: 1
427
+ },
428
+ _file: {
429
+ type: 'relationship',
430
+ withType: '@apostrophecms/file',
431
+ label: 'File',
432
+ max: 1
433
+ }
434
+ }
435
+ }
436
+ },
437
+ 'article-page': {
438
+ extend: '@apostrophecms/piece-page-type',
439
+ options: {
440
+ name: 'articlePage',
441
+ label: 'Articles',
442
+ alias: 'articlePage',
443
+ perPage: 10
444
+ }
445
+ },
446
+ '@apostrophecms/page': {
447
+ options: {
448
+ park: [
449
+ {
450
+ title: 'Articles',
451
+ type: 'articlePage',
452
+ slug: '/articles',
453
+ parkedId: 'articles'
454
+ }
455
+ ]
456
+ }
457
+ }
458
+ }
459
+ });
460
+
461
+ const req = apos.task.getReq();
462
+
463
+ // Insert an article so we have a document with a known _id
464
+ const article = await apos.article.insert(req, {
465
+ title: 'Attachment Test Article',
466
+ visibility: 'public'
467
+ });
468
+
469
+ // Update the article raw record to reference image and file
470
+ // docs via idsStorage fields, as if a user had chosen media
471
+ // through the CMS UI.
472
+ await apos.doc.db.updateMany(
473
+ { aposDocId: article.aposDocId },
474
+ {
475
+ $set: {
476
+ imageIds: [ 'img-1' ],
477
+ fileIds: [ 'file-1' ]
478
+ }
479
+ }
480
+ );
481
+
482
+ // Seed attachment records directly into the DB to avoid
483
+ // needing real uploaded files. Attachment `docIds` reference
484
+ // the image/file doc IDs (not the article), matching how the
485
+ // core attachment module stores references.
486
+ const imgDocId = 'img-1:en:published';
487
+ const fileDocId = 'file-1:en:published';
488
+
489
+ await apos.attachment.db.insertMany([
490
+ {
491
+ _id: 'att-jpg-1',
492
+ name: 'photo',
493
+ extension: 'jpg',
494
+ group: 'images',
495
+ width: 800,
496
+ height: 600,
497
+ archived: false,
498
+ docIds: [ imgDocId ],
499
+ crops: [],
500
+ used: true,
501
+ utilized: true
502
+ },
503
+ {
504
+ _id: 'att-pdf-1',
505
+ name: 'document',
506
+ extension: 'pdf',
507
+ group: 'office',
508
+ archived: false,
509
+ docIds: [ fileDocId ],
510
+ crops: [],
511
+ used: true,
512
+ utilized: true
513
+ },
514
+ {
515
+ _id: 'att-orphan-1',
516
+ name: 'orphan',
517
+ extension: 'png',
518
+ group: 'images',
519
+ width: 100,
520
+ height: 100,
521
+ archived: false,
522
+ docIds: [ 'img-orphan:en:published' ],
523
+ crops: [],
524
+ used: false,
525
+ utilized: false
526
+ },
527
+ {
528
+ _id: 'att-archived-1',
529
+ name: 'archived-photo',
530
+ extension: 'jpg',
531
+ group: 'images',
532
+ width: 200,
533
+ height: 200,
534
+ archived: true,
535
+ docIds: [ imgDocId ],
536
+ crops: [],
537
+ used: true,
538
+ utilized: true
539
+ },
540
+ {
541
+ _id: 'att-cropped-1',
542
+ name: 'cropped-photo',
543
+ extension: 'jpg',
544
+ group: 'images',
545
+ width: 1000,
546
+ height: 800,
547
+ archived: false,
548
+ docIds: [ imgDocId ],
549
+ crops: [
550
+ {
551
+ top: 10,
552
+ left: 20,
553
+ width: 300,
554
+ height: 400
555
+ }
556
+ ],
557
+ used: true,
558
+ utilized: true
559
+ }
560
+ ]);
561
+ });
562
+
563
+ after(async function () {
564
+ await t.destroy(apos);
565
+ apos = null;
566
+ });
567
+
568
+ it('should return attachments as null when not requested', async function () {
569
+ const req = apos.task.getAnonReq({ mode: 'published' });
570
+ const result = await apos.url.getAllUrlMetadata(req);
571
+ assert.strictEqual(result.attachments, null);
572
+ assert(Array.isArray(result.pages));
573
+ });
574
+
575
+ it('should return attachment metadata when requested', async function () {
576
+ const req = apos.task.getAnonReq({ mode: 'published' });
577
+ const result = await apos.url.getAllUrlMetadata(req, {
578
+ attachments: { scope: 'used' }
579
+ });
580
+ assert(result.attachments);
581
+ assert(typeof result.attachments.uploadsUrl === 'string');
582
+ assert(Array.isArray(result.attachments.results));
583
+ assert(result.attachments.results.length > 0);
584
+ });
585
+
586
+ it('used scope should only include attachments referenced by content docs', async function () {
587
+ const req = apos.task.getAnonReq({ mode: 'published' });
588
+ const result = await apos.url.getAllUrlMetadata(req, {
589
+ attachments: { scope: 'used' }
590
+ });
591
+ const ids = result.attachments.results.map(a => a._id);
592
+ assert(ids.includes('att-jpg-1'), 'Should include attachment referenced via image relationship');
593
+ assert(ids.includes('att-pdf-1'), 'Should include attachment referenced via file relationship');
594
+ assert(!ids.includes('att-orphan-1'), 'Should not include attachment whose image doc is unreferenced by content');
595
+ });
596
+
597
+ it('all scope should include all attachments', async function () {
598
+ const req = apos.task.getAnonReq({ mode: 'published' });
599
+ const result = await apos.url.getAllUrlMetadata(req, {
600
+ attachments: { scope: 'all' }
601
+ });
602
+ const ids = result.attachments.results.map(a => a._id);
603
+ assert(ids.includes('att-jpg-1'));
604
+ assert(ids.includes('att-pdf-1'));
605
+ assert(ids.includes('att-orphan-1'), 'all scope should include attachments not referenced by content docs');
606
+ });
607
+
608
+ it('sized attachment should have multiple size variants', async function () {
609
+ const req = apos.task.getAnonReq({ mode: 'published' });
610
+ const result = await apos.url.getAllUrlMetadata(req, {
611
+ attachments: { scope: 'used' }
612
+ });
613
+ const jpgAtt = result.attachments.results.find(a => a._id === 'att-jpg-1');
614
+ assert(jpgAtt, 'Should find the jpg attachment');
615
+ assert(jpgAtt.urls.length > 1, 'Sized attachment should have multiple URL entries');
616
+ const sizeNames = jpgAtt.urls.map(u => u.size);
617
+ assert(sizeNames.includes('full'), 'Should include full size');
618
+ assert(sizeNames.includes('one-half'), 'Should include one-half size');
619
+ assert(sizeNames.includes('original'), 'Should include original size');
620
+ for (const entry of jpgAtt.urls) {
621
+ assert(typeof entry.path === 'string');
622
+ assert(entry.path.startsWith('/attachments/'));
623
+ }
624
+ });
625
+
626
+ it('non-sized attachment should have only a path', async function () {
627
+ const req = apos.task.getAnonReq({ mode: 'published' });
628
+ const result = await apos.url.getAllUrlMetadata(req, {
629
+ attachments: { scope: 'used' }
630
+ });
631
+ const pdfAtt = result.attachments.results.find(a => a._id === 'att-pdf-1');
632
+ assert(pdfAtt, 'Should find the pdf attachment');
633
+ assert.strictEqual(pdfAtt.urls.length, 1, 'Non-sized attachment should have one entry');
634
+ assert.strictEqual(
635
+ pdfAtt.urls[0].size,
636
+ undefined,
637
+ 'Non-sized attachment should not have a size property'
638
+ );
639
+ assert(pdfAtt.urls[0].path.includes('.pdf'));
640
+ });
641
+
642
+ it('skipSizes should exclude specified sizes', async function () {
643
+ const req = apos.task.getAnonReq({ mode: 'published' });
644
+ const result = await apos.url.getAllUrlMetadata(req, {
645
+ attachments: {
646
+ scope: 'used',
647
+ skipSizes: [ 'original', 'max' ]
648
+ }
649
+ });
650
+ const jpgAtt = result.attachments.results.find(a => a._id === 'att-jpg-1');
651
+ const sizeNames = jpgAtt.urls.map(u => u.size);
652
+ assert(!sizeNames.includes('original'), 'original should be skipped');
653
+ assert(!sizeNames.includes('max'), 'max should be skipped');
654
+ assert(sizeNames.includes('full'), 'full should still be present');
655
+ });
656
+
657
+ it('sizes should include only specified sizes', async function () {
658
+ const req = apos.task.getAnonReq({ mode: 'published' });
659
+ const result = await apos.url.getAllUrlMetadata(req, {
660
+ attachments: {
661
+ scope: 'used',
662
+ sizes: [ 'full', 'one-half' ]
663
+ }
664
+ });
665
+ const jpgAtt = result.attachments.results.find(a => a._id === 'att-jpg-1');
666
+ const sizeNames = jpgAtt.urls.map(u => u.size);
667
+ assert(sizeNames.includes('full'));
668
+ assert(sizeNames.includes('one-half'));
669
+ assert(!sizeNames.includes('original'), 'original should not be included when sizes is explicit');
670
+ assert(!sizeNames.includes('max'), 'max should not be included when sizes is explicit');
671
+ });
672
+
673
+ it('uploadsUrl should match the uploadfs base URL', async function () {
674
+ const req = apos.task.getAnonReq({ mode: 'published' });
675
+ const result = await apos.url.getAllUrlMetadata(req, {
676
+ attachments: { scope: 'used' }
677
+ });
678
+ assert.strictEqual(
679
+ result.attachments.uploadsUrl,
680
+ apos.attachment.uploadfs.getUrl()
681
+ );
682
+ });
683
+
684
+ it('should exclude archived attachments even if they have docIds', async function () {
685
+ const req = apos.task.getAnonReq({ mode: 'published' });
686
+ const result = await apos.url.getAllUrlMetadata(req, {
687
+ attachments: { scope: 'used' }
688
+ });
689
+ const ids = result.attachments.results.map(a => a._id);
690
+ assert(!ids.includes('att-archived-1'), 'Archived attachments should be excluded');
691
+ assert(ids.includes('att-jpg-1'), 'Non-archived attachments should be included');
692
+ });
693
+
694
+ it('should exclude archived attachments in all scope too', async function () {
695
+ const req = apos.task.getAnonReq({ mode: 'published' });
696
+ const result = await apos.url.getAllUrlMetadata(req, {
697
+ attachments: { scope: 'all' }
698
+ });
699
+ const ids = result.attachments.results.map(a => a._id);
700
+ assert(!ids.includes('att-archived-1'), 'Archived attachments should be excluded in all scope');
701
+ });
702
+
703
+ it('crop variants should include all sizes by default', async function () {
704
+ const req = apos.task.getAnonReq({ mode: 'published' });
705
+ const result = await apos.url.getAllUrlMetadata(req, {
706
+ attachments: { scope: 'used' }
707
+ });
708
+ const att = result.attachments.results.find(a => a._id === 'att-cropped-1');
709
+ assert(att, 'Should find the cropped attachment');
710
+ // Should have all regular sizes + all crop sizes
711
+ const cropUrls = att.urls.filter(u => u.path.includes('.20.'));
712
+ assert(cropUrls.length > 0, 'Should have crop variant URLs');
713
+ const cropSizes = cropUrls.map(u => u.size);
714
+ assert(cropSizes.includes('full'), 'Crop should include full size');
715
+ assert(cropSizes.includes('original'), 'Crop should include original size');
716
+ });
717
+
718
+ it('crop variants should respect skipSizes', async function () {
719
+ const req = apos.task.getAnonReq({ mode: 'published' });
720
+ const result = await apos.url.getAllUrlMetadata(req, {
721
+ attachments: {
722
+ scope: 'used',
723
+ skipSizes: [ 'original', 'max' ]
724
+ }
725
+ });
726
+ const att = result.attachments.results.find(a => a._id === 'att-cropped-1');
727
+ const cropUrls = att.urls.filter(u => u.path.includes('.20.'));
728
+ const cropSizes = cropUrls.map(u => u.size);
729
+ assert(!cropSizes.includes('original'), 'Crop should skip original when told to');
730
+ assert(!cropSizes.includes('max'), 'Crop should skip max when told to');
731
+ assert(cropSizes.includes('full'), 'Crop should still include full');
732
+ });
733
+
734
+ it('crop variants should respect sizes filter', async function () {
735
+ const req = apos.task.getAnonReq({ mode: 'published' });
736
+ const result = await apos.url.getAllUrlMetadata(req, {
737
+ attachments: {
738
+ scope: 'used',
739
+ sizes: [ 'full', 'one-half' ]
740
+ }
741
+ });
742
+ const att = result.attachments.results.find(a => a._id === 'att-cropped-1');
743
+ const cropUrls = att.urls.filter(u => u.path.includes('.20.'));
744
+ const cropSizes = cropUrls.map(u => u.size);
745
+ assert.strictEqual(cropSizes.length, 2, 'Crop should only have the 2 requested sizes');
746
+ assert(cropSizes.includes('full'));
747
+ assert(cropSizes.includes('one-half'));
748
+ assert(!cropSizes.includes('original'));
749
+ });
750
+ });
751
+
752
+ describe('used scope with direct attachment fields', function () {
753
+ let apos;
754
+
755
+ before(async function () {
756
+ apos = await t.create({
757
+ root: module,
758
+ modules: {
759
+ '@apostrophecms/url': {
760
+ options: { static: true }
761
+ },
762
+ // Piece type with a direct attachment field in its own schema
763
+ 'direct-attachment-piece': {
764
+ extend: '@apostrophecms/piece-type',
765
+ options: {
766
+ name: 'direct-attachment-piece',
767
+ label: 'Direct Attachment Piece',
768
+ alias: 'directAttachmentPiece'
769
+ },
770
+ fields: {
771
+ add: {
772
+ file: {
773
+ type: 'attachment',
774
+ label: 'File',
775
+ group: 'office'
776
+ }
777
+ }
778
+ }
779
+ },
780
+ // Widget with a direct attachment field (not a relationship)
781
+ 'attachment-widget': {
782
+ extend: '@apostrophecms/widget-type',
783
+ options: {
784
+ label: 'Attachment Widget'
785
+ },
786
+ fields: {
787
+ add: {
788
+ photo: {
789
+ type: 'attachment',
790
+ label: 'Photo',
791
+ fileGroup: 'images'
792
+ }
793
+ }
794
+ }
795
+ },
796
+ // Page type with an area that allows the attachment widget
797
+ 'test-page': {
798
+ extend: '@apostrophecms/page-type',
799
+ options: {
800
+ label: 'Test Page'
801
+ },
802
+ fields: {
803
+ add: {
804
+ body: {
805
+ type: 'area',
806
+ label: 'Body',
807
+ options: {
808
+ widgets: {
809
+ attachment: {}
810
+ }
811
+ }
812
+ }
813
+ }
814
+ }
815
+ },
816
+ '@apostrophecms/page': {
817
+ options: {
818
+ types: [
819
+ {
820
+ name: 'test-page',
821
+ label: 'Test Page'
822
+ }
823
+ ],
824
+ park: [
825
+ {
826
+ title: 'Widget Attachment Page',
827
+ type: 'test-page',
828
+ slug: '/widget-att',
829
+ parkedId: 'widget-att'
830
+ }
831
+ ]
832
+ }
833
+ }
834
+ }
835
+ });
836
+
837
+ const req = apos.task.getReq();
838
+
839
+ // --- Piece with a direct attachment field ---
840
+ const piece = await apos.directAttachmentPiece.insert(req, {
841
+ title: 'Piece With Direct Attachment',
842
+ visibility: 'public'
843
+ });
844
+
845
+ // Seed an attachment referencing the piece doc (as
846
+ // updateDocReferences would do at save time)
847
+ const pieceDocId = `${piece.aposDocId}:en:published`;
848
+ await apos.attachment.db.insertOne({
849
+ _id: 'att-direct-piece',
850
+ name: 'piece-doc',
851
+ extension: 'pdf',
852
+ group: 'office',
853
+ archived: false,
854
+ docIds: [ pieceDocId ],
855
+ crops: [],
856
+ utilized: true
857
+ });
858
+
859
+ // --- Page with a widget that has a direct attachment field ---
860
+ const page = await apos.doc.db.findOne({
861
+ slug: '/widget-att',
862
+ aposLocale: 'en:published'
863
+ });
864
+
865
+ // Simulate an area with an attachment-widget containing an
866
+ // attachment object, as if uploaded through the CMS UI.
867
+ // updateDocReferences stores the parent page's _id in
868
+ // attachment.docIds.
869
+ const pageDocId = page._id;
870
+ await apos.doc.db.updateOne(
871
+ { _id: pageDocId },
872
+ {
873
+ $set: {
874
+ body: {
875
+ metaType: 'area',
876
+ items: [
877
+ {
878
+ _id: 'widget-1',
879
+ metaType: 'widget',
880
+ type: 'attachment-widget',
881
+ photo: {
882
+ _id: 'att-widget-photo',
883
+ type: 'attachment',
884
+ group: 'images',
885
+ name: 'widget-photo',
886
+ extension: 'jpg'
887
+ }
888
+ }
889
+ ]
890
+ }
891
+ }
892
+ }
893
+ );
894
+
895
+ await apos.attachment.db.insertOne({
896
+ _id: 'att-widget-photo',
897
+ name: 'widget-photo',
898
+ extension: 'jpg',
899
+ group: 'images',
900
+ width: 400,
901
+ height: 300,
902
+ archived: false,
903
+ docIds: [ pageDocId ],
904
+ crops: [],
905
+ utilized: true
906
+ });
907
+
908
+ // --- Unrelated attachment (should not appear in used scope) ---
909
+ await apos.attachment.db.insertOne({
910
+ _id: 'att-unrelated',
911
+ name: 'unrelated',
912
+ extension: 'png',
913
+ group: 'images',
914
+ width: 50,
915
+ height: 50,
916
+ archived: false,
917
+ docIds: [ 'some-other-doc:en:published' ],
918
+ crops: [],
919
+ utilized: true
920
+ });
921
+ });
922
+
923
+ after(async function () {
924
+ await t.destroy(apos);
925
+ apos = null;
926
+ });
927
+
928
+ it('used scope includes attachment from piece with direct attachment field', async function () {
929
+ const req = apos.task.getAnonReq({ mode: 'published' });
930
+ const result = await apos.url.getAllUrlMetadata(req, {
931
+ attachments: { scope: 'used' }
932
+ });
933
+ const ids = result.attachments.results.map(a => a._id);
934
+ assert(
935
+ ids.includes('att-direct-piece'),
936
+ 'Should include attachment owned by a piece with a direct attachment field'
937
+ );
938
+ });
939
+
940
+ it('used scope includes attachment from widget with direct attachment field', async function () {
941
+ const req = apos.task.getAnonReq({ mode: 'published' });
942
+ const result = await apos.url.getAllUrlMetadata(req, {
943
+ attachments: { scope: 'used' }
944
+ });
945
+ const ids = result.attachments.results.map(a => a._id);
946
+ assert(
947
+ ids.includes('att-widget-photo'),
948
+ 'Should include attachment from a widget with a direct attachment field inside a page area'
949
+ );
950
+ });
951
+
952
+ it('used scope excludes unrelated attachments', async function () {
953
+ const req = apos.task.getAnonReq({ mode: 'published' });
954
+ const result = await apos.url.getAllUrlMetadata(req, {
955
+ attachments: { scope: 'used' }
956
+ });
957
+ const ids = result.attachments.results.map(a => a._id);
958
+ assert(
959
+ !ids.includes('att-unrelated'),
960
+ 'Should not include attachments not referenced by any content doc'
961
+ );
962
+ });
963
+ });
964
+
965
+ describe('REST API endpoint', function () {
966
+ let apos;
967
+ const externalFrontKey = 'test-static-build-key';
968
+
969
+ before(async function () {
970
+ apos = await t.create({
971
+ root: module,
972
+ modules: {
973
+ '@apostrophecms/url': {
974
+ options: { static: true }
975
+ },
976
+ '@apostrophecms/express': {
977
+ options: {
978
+ externalFrontKey
979
+ }
980
+ },
981
+ article: {
982
+ extend: '@apostrophecms/piece-type',
983
+ options: {
984
+ name: 'article',
985
+ label: 'Article',
986
+ alias: 'article'
987
+ },
988
+ fields: {
989
+ add: {
990
+ _image: {
991
+ type: 'relationship',
992
+ withType: '@apostrophecms/image',
993
+ label: 'Image',
994
+ max: 1
995
+ }
996
+ }
997
+ }
998
+ },
999
+ 'article-page': {
1000
+ extend: '@apostrophecms/piece-page-type',
1001
+ options: {
1002
+ name: 'articlePage',
1003
+ label: 'Articles',
1004
+ alias: 'articlePage',
1005
+ perPage: 10
1006
+ }
1007
+ },
1008
+ '@apostrophecms/page': {
1009
+ options: {
1010
+ park: [
1011
+ {
1012
+ title: 'Articles',
1013
+ type: 'articlePage',
1014
+ slug: '/articles',
1015
+ parkedId: 'articles'
1016
+ }
1017
+ ]
1018
+ }
1019
+ }
1020
+ }
1021
+ });
1022
+
1023
+ const req = apos.task.getReq();
1024
+ const article = await apos.article.insert(req, {
1025
+ title: 'Test Article',
1026
+ visibility: 'public'
1027
+ });
1028
+
1029
+ // Set up idsStorage so the article references an image doc
1030
+ await apos.doc.db.updateMany(
1031
+ { aposDocId: article.aposDocId },
1032
+ { $set: { imageIds: [ 'api-img-1' ] } }
1033
+ );
1034
+ });
1035
+
1036
+ after(async function () {
1037
+ await t.destroy(apos);
1038
+ apos = null;
1039
+ });
1040
+
1041
+ it('should return 403 without external front headers', async function () {
1042
+ await assert.rejects(
1043
+ apos.http.get('/api/v1/@apostrophecms/url', {}),
1044
+ { status: 403 }
1045
+ );
1046
+ });
1047
+
1048
+ it('should return 403 with wrong external front key', async function () {
1049
+ await assert.rejects(
1050
+ apos.http.get('/api/v1/@apostrophecms/url', {
1051
+ headers: {
1052
+ 'x-requested-with': 'AposExternalFront',
1053
+ 'apos-external-front-key': 'wrong-key'
1054
+ }
1055
+ }),
1056
+ { status: 403 }
1057
+ );
1058
+ });
1059
+
1060
+ it('should return URL metadata with valid external front key', async function () {
1061
+ const response = await apos.http.get('/api/v1/@apostrophecms/url', {
1062
+ headers: {
1063
+ 'x-requested-with': 'AposExternalFront',
1064
+ 'apos-external-front-key': externalFrontKey
1065
+ }
1066
+ });
1067
+ assert(response);
1068
+ assert(Array.isArray(response.pages));
1069
+ assert(response.pages.length > 0);
1070
+ // Should include at least the home page and articles page
1071
+ assert(
1072
+ response.pages.some(r => r.url === '/'),
1073
+ 'Should include home page'
1074
+ );
1075
+ assert(
1076
+ response.pages.some(r => r.url === '/articles'),
1077
+ 'Should include articles page'
1078
+ );
1079
+ });
1080
+
1081
+ it('each result should have url and i18nId', async function () {
1082
+ const response = await apos.http.get('/api/v1/@apostrophecms/url', {
1083
+ headers: {
1084
+ 'x-requested-with': 'AposExternalFront',
1085
+ 'apos-external-front-key': externalFrontKey
1086
+ }
1087
+ });
1088
+ for (const entry of response.pages) {
1089
+ assert(entry.url, `Entry should have url: ${JSON.stringify(entry)}`);
1090
+ assert(entry.i18nId, `Entry should have i18nId: ${JSON.stringify(entry)}`);
1091
+ }
1092
+ });
1093
+
1094
+ it('should return attachments as null without query param', async function () {
1095
+ const response = await apos.http.get('/api/v1/@apostrophecms/url', {
1096
+ headers: {
1097
+ 'x-requested-with': 'AposExternalFront',
1098
+ 'apos-external-front-key': externalFrontKey
1099
+ }
1100
+ });
1101
+ assert.strictEqual(response.attachments, null);
1102
+ });
1103
+
1104
+ it('should return attachment metadata with attachments=1', async function () {
1105
+ // Seed an attachment referencing an image doc ID that
1106
+ // the article doc points to via imageIds idsStorage
1107
+ await apos.attachment.db.insertOne({
1108
+ _id: 'att-api-jpg',
1109
+ name: 'api-photo',
1110
+ extension: 'jpg',
1111
+ group: 'images',
1112
+ width: 400,
1113
+ height: 300,
1114
+ archived: false,
1115
+ docIds: [ 'api-img-1:en:published' ],
1116
+ crops: [],
1117
+ used: true,
1118
+ utilized: true
1119
+ });
1120
+
1121
+ const response = await apos.http.get(
1122
+ '/api/v1/@apostrophecms/url?attachments=1',
1123
+ {
1124
+ headers: {
1125
+ 'x-requested-with': 'AposExternalFront',
1126
+ 'apos-external-front-key': externalFrontKey
1127
+ }
1128
+ }
1129
+ );
1130
+ assert(response.attachments);
1131
+ assert(typeof response.attachments.uploadsUrl === 'string');
1132
+ assert(Array.isArray(response.attachments.results));
1133
+ const att = response.attachments.results.find(a => a._id === 'att-api-jpg');
1134
+ assert(att, 'Should include the seeded attachment');
1135
+ assert(att.urls.length > 1, 'Sized image should have multiple URL entries');
1136
+ });
1137
+
1138
+ it('should accept attachmentSkipSizes as comma-separated list', async function () {
1139
+ const response = await apos.http.get(
1140
+ '/api/v1/@apostrophecms/url?attachments=1&attachmentSkipSizes=original,max',
1141
+ {
1142
+ headers: {
1143
+ 'x-requested-with': 'AposExternalFront',
1144
+ 'apos-external-front-key': externalFrontKey
1145
+ }
1146
+ }
1147
+ );
1148
+ const att = response.attachments.results.find(a => a._id === 'att-api-jpg');
1149
+ const sizeNames = att.urls.map(u => u.size);
1150
+ assert(!sizeNames.includes('original'), 'original should be skipped');
1151
+ assert(!sizeNames.includes('max'), 'max should be skipped');
1152
+ assert(sizeNames.includes('full'), 'full should remain');
1153
+ });
1154
+
1155
+ it('should accept attachmentSizes as comma-separated list', async function () {
1156
+ const response = await apos.http.get(
1157
+ '/api/v1/@apostrophecms/url?attachments=1&attachmentSizes=full,one-half',
1158
+ {
1159
+ headers: {
1160
+ 'x-requested-with': 'AposExternalFront',
1161
+ 'apos-external-front-key': externalFrontKey
1162
+ }
1163
+ }
1164
+ );
1165
+ const att = response.attachments.results.find(a => a._id === 'att-api-jpg');
1166
+ const sizeNames = att.urls.map(u => u.size);
1167
+ assert(sizeNames.includes('full'));
1168
+ assert(sizeNames.includes('one-half'));
1169
+ assert(!sizeNames.includes('original'));
1170
+ assert(!sizeNames.includes('max'));
1171
+ });
1172
+
1173
+ it('should accept attachmentScope=all', async function () {
1174
+ // Insert an attachment not referenced by any content doc
1175
+ await apos.attachment.db.insertOne({
1176
+ _id: 'att-api-orphan',
1177
+ name: 'api-orphan',
1178
+ extension: 'png',
1179
+ group: 'images',
1180
+ width: 50,
1181
+ height: 50,
1182
+ archived: false,
1183
+ docIds: [ 'unreferenced-img:en:published' ],
1184
+ crops: [],
1185
+ used: false,
1186
+ utilized: false
1187
+ });
1188
+
1189
+ const response = await apos.http.get(
1190
+ '/api/v1/@apostrophecms/url?attachments=1&attachmentScope=all',
1191
+ {
1192
+ headers: {
1193
+ 'x-requested-with': 'AposExternalFront',
1194
+ 'apos-external-front-key': externalFrontKey
1195
+ }
1196
+ }
1197
+ );
1198
+ const ids = response.attachments.results.map(a => a._id);
1199
+ assert(ids.includes('att-api-orphan'), 'all scope should include attachments not in URL results');
1200
+ });
1201
+
1202
+ it('should default scope to used and exclude orphaned attachments', async function () {
1203
+ const response = await apos.http.get(
1204
+ '/api/v1/@apostrophecms/url?attachments=1',
1205
+ {
1206
+ headers: {
1207
+ 'x-requested-with': 'AposExternalFront',
1208
+ 'apos-external-front-key': externalFrontKey
1209
+ }
1210
+ }
1211
+ );
1212
+ const ids = response.attachments.results.map(a => a._id);
1213
+ assert(!ids.includes('att-api-orphan'), 'used scope should not include attachments not in URL results');
1214
+ });
1215
+
1216
+ it('should ignore invalid attachmentScope values', async function () {
1217
+ const response = await apos.http.get(
1218
+ '/api/v1/@apostrophecms/url?attachments=1&attachmentScope=evil',
1219
+ {
1220
+ headers: {
1221
+ 'x-requested-with': 'AposExternalFront',
1222
+ 'apos-external-front-key': externalFrontKey
1223
+ }
1224
+ }
1225
+ );
1226
+ // Invalid scope falls back to 'used' via launder.select
1227
+ const ids = response.attachments.results.map(a => a._id);
1228
+ assert(!ids.includes('att-api-orphan'), 'invalid scope should fall back to used');
1229
+ });
1230
+
1231
+ it('should ignore non-boolean attachments values', async function () {
1232
+ const response = await apos.http.get(
1233
+ '/api/v1/@apostrophecms/url?attachments=evil',
1234
+ {
1235
+ headers: {
1236
+ 'x-requested-with': 'AposExternalFront',
1237
+ 'apos-external-front-key': externalFrontKey
1238
+ }
1239
+ }
1240
+ );
1241
+ assert.strictEqual(response.attachments, null, 'Non-boolean value should result in null attachments');
1242
+ });
1243
+ });
1244
+
1245
+ describe('REST API endpoint without static option', function () {
1246
+ let apos;
1247
+ const externalFrontKey = 'test-no-static-key';
1248
+
1249
+ before(async function () {
1250
+ apos = await t.create({
1251
+ root: module,
1252
+ modules: {
1253
+ '@apostrophecms/express': {
1254
+ options: {
1255
+ externalFrontKey
1256
+ }
1257
+ }
1258
+ }
1259
+ });
1260
+ });
1261
+
1262
+ after(async function () {
1263
+ await t.destroy(apos);
1264
+ apos = null;
1265
+ });
1266
+
1267
+ it('should return 400 when static option is not enabled', async function () {
1268
+ await assert.rejects(
1269
+ () => apos.http.get('/api/v1/@apostrophecms/url', {
1270
+ headers: {
1271
+ 'x-requested-with': 'AposExternalFront',
1272
+ 'apos-external-front-key': externalFrontKey
1273
+ }
1274
+ }),
1275
+ (err) => {
1276
+ assert.strictEqual(err.status, 400);
1277
+ assert(
1278
+ err.body?.message?.includes('static: true'),
1279
+ 'Error message should mention the static option'
1280
+ );
1281
+ return true;
1282
+ }
1283
+ );
1284
+ });
1285
+ });
1286
+
1287
+ describe('Piece page dispatch routes in static mode', function () {
1288
+ let apos;
1289
+
1290
+ before(async function () {
1291
+ apos = await t.create({
1292
+ root: module,
1293
+ modules: {
1294
+ '@apostrophecms/url': {
1295
+ options: { static: true }
1296
+ },
1297
+ article: {
1298
+ extend: '@apostrophecms/piece-type',
1299
+ options: {
1300
+ name: 'article',
1301
+ label: 'Article',
1302
+ alias: 'article',
1303
+ sort: { title: 1 }
1304
+ },
1305
+ fields: {
1306
+ add: {
1307
+ category: {
1308
+ type: 'select',
1309
+ label: 'Category',
1310
+ choices: [
1311
+ {
1312
+ label: 'Tech',
1313
+ value: 'tech'
1314
+ },
1315
+ {
1316
+ label: 'Science',
1317
+ value: 'science'
1318
+ }
1319
+ ]
1320
+ }
1321
+ }
1322
+ }
1323
+ },
1324
+ 'article-page': {
1325
+ extend: '@apostrophecms/piece-page-type',
1326
+ options: {
1327
+ name: 'articlePage',
1328
+ label: 'Articles',
1329
+ alias: 'articlePage',
1330
+ perPage: 5,
1331
+ piecesFilters: [
1332
+ { name: 'category' }
1333
+ ]
1334
+ }
1335
+ },
1336
+ '@apostrophecms/page': {
1337
+ options: {
1338
+ park: [
1339
+ {
1340
+ title: 'Articles',
1341
+ type: 'articlePage',
1342
+ slug: '/articles',
1343
+ parkedId: 'articles'
1344
+ }
1345
+ ]
1346
+ }
1347
+ }
1348
+ }
1349
+ });
1350
+
1351
+ const req = apos.task.getReq();
1352
+ for (let i = 1; i <= 12; i++) {
1353
+ const padded = String(i).padStart(3, '0');
1354
+ const category = i <= 6 ? 'tech' : 'science';
1355
+ await apos.article.insert(req, {
1356
+ title: `Article ${padded}`,
1357
+ slug: `article-${padded}`,
1358
+ visibility: 'public',
1359
+ category
1360
+ });
1361
+ }
1362
+ });
1363
+
1364
+ after(async function () {
1365
+ await t.destroy(apos);
1366
+ apos = null;
1367
+ });
1368
+
1369
+ it('should serve index page at /', async function () {
1370
+ const body = await apos.http.get('/articles');
1371
+ assert(body.includes('article-001'));
1372
+ });
1373
+
1374
+ it('should serve paginated page via path in static mode', async function () {
1375
+ const body = await apos.http.get('/articles/page/2');
1376
+ // Page 2 with perPage=5 should show articles 6-10
1377
+ assert(body.includes('article-006'));
1378
+ assert(!body.includes('article-001'));
1379
+ });
1380
+
1381
+ it('should serve filter page via path in static mode', async function () {
1382
+ const body = await apos.http.get('/articles/category/tech');
1383
+ // Should only show tech articles (1-6)
1384
+ assert(body.includes('article-001'));
1385
+ });
1386
+
1387
+ it('should serve filter + pagination via path in static mode', async function () {
1388
+ const body = await apos.http.get('/articles/category/tech/page/2');
1389
+ // 6 tech articles with perPage=5 means page 2 has 1 article
1390
+ assert(body.includes('article-006'));
1391
+ assert(!body.includes('article-001'));
1392
+ });
1393
+
1394
+ it('should still serve individual piece show pages', async function () {
1395
+ const body = await apos.http.get('/articles/article-001');
1396
+ assert(body.includes('Article 001'));
1397
+ });
1398
+ });
1399
+
1400
+ describe('getAllUrlMetadata event', function () {
1401
+ let apos;
1402
+ let eventFired = false;
1403
+
1404
+ before(async function () {
1405
+ apos = await t.create({
1406
+ root: module,
1407
+ modules: {
1408
+ '@apostrophecms/url': {
1409
+ options: { static: true }
1410
+ },
1411
+ 'custom-urls': {
1412
+ handlers(self) {
1413
+ return {
1414
+ '@apostrophecms/url:getAllUrlMetadata': {
1415
+ addCustomUrl(req, results, { excludeTypes }) {
1416
+ eventFired = true;
1417
+ results.push({
1418
+ url: '/custom-resource.txt',
1419
+ contentType: 'text/plain',
1420
+ i18nId: 'custom:resource',
1421
+ sitemap: false
1422
+ });
1423
+ }
1424
+ }
1425
+ };
1426
+ }
1427
+ }
1428
+ }
1429
+ });
1430
+ });
1431
+
1432
+ after(async function () {
1433
+ await t.destroy(apos);
1434
+ apos = null;
1435
+ });
1436
+
1437
+ it('should emit getAllUrlMetadata event and include custom URLs', async function () {
1438
+ const req = apos.task.getAnonReq({ mode: 'published' });
1439
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
1440
+
1441
+ assert(eventFired, 'Event should have been fired');
1442
+
1443
+ const custom = results.find(r => r.i18nId === 'custom:resource');
1444
+ assert(custom, 'Should include custom URL from event handler');
1445
+ assert.strictEqual(custom.url, '/custom-resource.txt');
1446
+ assert.strictEqual(custom.contentType, 'text/plain');
1447
+ assert.strictEqual(custom.sitemap, false);
1448
+ });
1449
+ });
1450
+
1451
+ describe('getFiltersWithChoices', function () {
1452
+ let apos;
1453
+
1454
+ before(async function () {
1455
+ apos = await t.create({
1456
+ root: module,
1457
+ modules: {
1458
+ '@apostrophecms/url': {
1459
+ options: { static: true }
1460
+ },
1461
+ article: {
1462
+ extend: '@apostrophecms/piece-type',
1463
+ options: {
1464
+ name: 'article',
1465
+ label: 'Article',
1466
+ alias: 'article',
1467
+ sort: { title: 1 }
1468
+ },
1469
+ fields: {
1470
+ add: {
1471
+ category: {
1472
+ type: 'select',
1473
+ label: 'Category',
1474
+ choices: [
1475
+ {
1476
+ label: 'Tech',
1477
+ value: 'tech'
1478
+ },
1479
+ {
1480
+ label: 'Science',
1481
+ value: 'science'
1482
+ }
1483
+ ]
1484
+ }
1485
+ }
1486
+ }
1487
+ },
1488
+ 'article-page': {
1489
+ extend: '@apostrophecms/piece-page-type',
1490
+ options: {
1491
+ name: 'articlePage',
1492
+ label: 'Articles',
1493
+ alias: 'articlePage',
1494
+ perPage: 10,
1495
+ piecesFilters: [
1496
+ { name: 'category' }
1497
+ ]
1498
+ }
1499
+ },
1500
+ '@apostrophecms/page': {
1501
+ options: {
1502
+ park: [
1503
+ {
1504
+ title: 'Articles',
1505
+ type: 'articlePage',
1506
+ slug: '/articles',
1507
+ parkedId: 'articles'
1508
+ }
1509
+ ]
1510
+ }
1511
+ }
1512
+ }
1513
+ });
1514
+
1515
+ const req = apos.task.getReq();
1516
+ for (let i = 1; i <= 6; i++) {
1517
+ const category = i <= 3 ? 'tech' : 'science';
1518
+ await apos.article.insert(req, {
1519
+ title: `Article ${i}`,
1520
+ visibility: 'public',
1521
+ category
1522
+ });
1523
+ }
1524
+ });
1525
+
1526
+ after(async function () {
1527
+ await t.destroy(apos);
1528
+ apos = null;
1529
+ });
1530
+
1531
+ it('should return filter choices with counts when requested', async function () {
1532
+ const req = apos.task.getAnonReq({ mode: 'published' });
1533
+ const query = apos.articlePage.indexQuery(req);
1534
+ const filters = await apos.articlePage.getFiltersWithChoices(query, {
1535
+ allCounts: true
1536
+ });
1537
+
1538
+ assert(Array.isArray(filters));
1539
+ assert.strictEqual(filters.length, 1);
1540
+ assert.strictEqual(filters[0].name, 'category');
1541
+ assert(Array.isArray(filters[0].choices));
1542
+
1543
+ // Should have choices for tech and science
1544
+ const techChoice = filters[0].choices.find(c => c.value === 'tech');
1545
+ const scienceChoice = filters[0].choices.find(c => c.value === 'science');
1546
+ assert(techChoice, 'Should have tech choice');
1547
+ assert(scienceChoice, 'Should have science choice');
1548
+ assert.strictEqual(techChoice.count, 3);
1549
+ assert.strictEqual(scienceChoice.count, 3);
1550
+ });
1551
+
1552
+ it('choices should have _url with path format when page context exists', async function () {
1553
+ const req = apos.task.getAnonReq({ mode: 'published' });
1554
+
1555
+ // Simulate a real page context by fetching the articles page
1556
+ const articlesPage = await apos.page.find(req, { slug: '/articles' }).toObject();
1557
+ req.data.page = articlesPage;
1558
+ const query = apos.articlePage.indexQuery(req);
1559
+ const filters = await apos.articlePage.getFiltersWithChoices(query, {
1560
+ allCounts: true
1561
+ });
1562
+ const techChoice = filters[0].choices.find(c => c.value === 'tech');
1563
+ const scienceChoice = filters[0].choices.find(c => c.value === 'science');
1564
+
1565
+ // In static mode, _url should use path segments, not query strings
1566
+ assert.strictEqual(
1567
+ techChoice._url,
1568
+ '/articles/category/tech',
1569
+ 'Tech choice _url should use path format'
1570
+ );
1571
+ assert.strictEqual(
1572
+ scienceChoice._url,
1573
+ '/articles/category/science',
1574
+ 'Science choice _url should use path format'
1575
+ );
1576
+ assert(
1577
+ !techChoice._url.includes('?'),
1578
+ 'Static mode _url should not contain query string'
1579
+ );
1580
+ });
1581
+ });
1582
+
1583
+ describe('getFiltersWithChoices for relationship fields', function () {
1584
+ let apos;
1585
+
1586
+ before(async function () {
1587
+ apos = await t.create({
1588
+ root: module,
1589
+ modules: {
1590
+ '@apostrophecms/url': {
1591
+ options: { static: true }
1592
+ },
1593
+ category: {
1594
+ extend: '@apostrophecms/piece-type',
1595
+ options: {
1596
+ name: 'category',
1597
+ label: 'Category',
1598
+ alias: 'category'
1599
+ }
1600
+ },
1601
+ article: {
1602
+ extend: '@apostrophecms/piece-type',
1603
+ options: {
1604
+ name: 'article',
1605
+ label: 'Article',
1606
+ alias: 'article',
1607
+ sort: { title: 1 }
1608
+ },
1609
+ fields: {
1610
+ add: {
1611
+ _categories: {
1612
+ type: 'relationship',
1613
+ withType: 'category',
1614
+ label: 'Categories'
1615
+ }
1616
+ }
1617
+ }
1618
+ },
1619
+ 'article-page': {
1620
+ extend: '@apostrophecms/piece-page-type',
1621
+ options: {
1622
+ name: 'articlePage',
1623
+ label: 'Articles',
1624
+ alias: 'articlePage',
1625
+ perPage: 10,
1626
+ piecesFilters: [
1627
+ { name: 'categories' }
1628
+ ]
1629
+ }
1630
+ },
1631
+ '@apostrophecms/page': {
1632
+ options: {
1633
+ park: [
1634
+ {
1635
+ title: 'Articles',
1636
+ type: 'articlePage',
1637
+ slug: '/articles',
1638
+ parkedId: 'articles'
1639
+ }
1640
+ ]
1641
+ }
1642
+ }
1643
+ }
1644
+ });
1645
+
1646
+ const req = apos.task.getReq();
1647
+ const tech = await apos.category.insert(req, {
1648
+ title: 'Tech',
1649
+ visibility: 'public'
1650
+ });
1651
+ const science = await apos.category.insert(req, {
1652
+ title: 'Science',
1653
+ visibility: 'public'
1654
+ });
1655
+
1656
+ for (let i = 1; i <= 6; i++) {
1657
+ const cats = i <= 3 ? [ tech ] : [ science ];
1658
+ await apos.article.insert(req, {
1659
+ title: `Article ${i}`,
1660
+ visibility: 'public',
1661
+ _categories: cats
1662
+ });
1663
+ }
1664
+ });
1665
+
1666
+ after(async function () {
1667
+ await t.destroy(apos);
1668
+ apos = null;
1669
+ });
1670
+
1671
+ it('should return relationship filter choices with counts', async function () {
1672
+ const req = apos.task.getAnonReq({ mode: 'published' });
1673
+ const query = apos.articlePage.indexQuery(req);
1674
+ const filters = await apos.articlePage.getFiltersWithChoices(query, {
1675
+ allCounts: true
1676
+ });
1677
+
1678
+ assert(Array.isArray(filters));
1679
+ assert.strictEqual(filters.length, 1);
1680
+ assert.strictEqual(filters[0].name, 'categories');
1681
+
1682
+ const techChoice = filters[0].choices.find(c => c.value === 'tech');
1683
+ const scienceChoice = filters[0].choices.find(c => c.value === 'science');
1684
+ assert(techChoice, 'Should have tech choice');
1685
+ assert(scienceChoice, 'Should have science choice');
1686
+ assert.strictEqual(techChoice.count, 3, 'Tech should have count 3');
1687
+ assert.strictEqual(scienceChoice.count, 3, 'Science should have count 3');
1688
+ });
1689
+
1690
+ it('should enumerate relationship filter URLs in getUrlMetadata', async function () {
1691
+ const req = apos.task.getAnonReq({ mode: 'published' });
1692
+ const { pages: results } = await apos.url.getAllUrlMetadata(req);
1693
+
1694
+ const filterUrls = results.filter(r =>
1695
+ r.url && r.url.startsWith('/articles/categories/')
1696
+ );
1697
+ assert(filterUrls.length >= 2, `Should have at least 2 filter URLs, got ${filterUrls.length}`);
1698
+ assert(
1699
+ filterUrls.some(r => r.url === '/articles/categories/tech'),
1700
+ 'Should enumerate /articles/categories/tech'
1701
+ );
1702
+ assert(
1703
+ filterUrls.some(r => r.url === '/articles/categories/science'),
1704
+ 'Should enumerate /articles/categories/science'
1705
+ );
1706
+ });
1707
+ });
1708
+
1709
+ describe('URL support', function () {
1710
+
1711
+ describe('baseUrl configured, no staticBaseUrl', function () {
1712
+ let apos;
1713
+
1714
+ before(async function () {
1715
+ apos = await t.create({
1716
+ root: module,
1717
+ baseUrl: 'http://localhost:3000',
1718
+ modules: {
1719
+ '@apostrophecms/url': {
1720
+ options: { static: true }
1721
+ }
1722
+ }
1723
+ });
1724
+ });
1725
+
1726
+ after(async function () {
1727
+ await t.destroy(apos);
1728
+ apos = null;
1729
+ });
1730
+
1731
+ it('apos.baseUrl is set correctly', function () {
1732
+ assert.strictEqual(apos.baseUrl, 'http://localhost:3000');
1733
+ });
1734
+
1735
+ it('apos.staticBaseUrl is undefined when not configured', function () {
1736
+ assert.strictEqual(apos.staticBaseUrl, undefined);
1737
+ });
1738
+
1739
+ it('getBaseUrl returns empty string when req.aposStaticBuild is true and no staticBaseUrl', function () {
1740
+ const req = apos.task.getAnonReq({
1741
+ mode: 'published',
1742
+ staticBuild: true
1743
+ });
1744
+ const baseUrl = apos.page.getBaseUrl(req);
1745
+ assert.strictEqual(baseUrl, '');
1746
+ });
1747
+
1748
+ it('getBaseUrl returns apos.baseUrl when req.aposStaticBuild is not set', function () {
1749
+ const req = apos.task.getAnonReq({ mode: 'published' });
1750
+ const baseUrl = apos.page.getBaseUrl(req);
1751
+ assert.strictEqual(baseUrl, 'http://localhost:3000');
1752
+ });
1753
+
1754
+ it('getAllUrlMetadata strips baseUrl from page URLs for static build requests', async function () {
1755
+ const req = apos.task.getAnonReq({
1756
+ mode: 'published',
1757
+ staticBuild: true
1758
+ });
1759
+ const { pages } = await apos.url.getAllUrlMetadata(req);
1760
+ assert(pages.length > 0);
1761
+ for (const entry of pages) {
1762
+ assert(
1763
+ !entry.url.startsWith('http://'),
1764
+ `URL should be path-only, got: ${entry.url}`
1765
+ );
1766
+ }
1767
+ const home = pages.find(r => r.url === '/');
1768
+ assert(home, 'Should include the home page with path-only URL');
1769
+ });
1770
+
1771
+ it('getAllUrlMetadata strips origin from uploadsUrl, keeping it relative', async function () {
1772
+ const req = apos.task.getAnonReq({
1773
+ mode: 'published',
1774
+ staticBuild: true
1775
+ });
1776
+ const { attachments } = await apos.url.getAllUrlMetadata(req, {
1777
+ attachments: { scope: 'all' }
1778
+ });
1779
+ assert(attachments);
1780
+ // Without staticBaseUrl, uploadfs is initialized with the
1781
+ // full baseUrl. getAllUrlMetadata strips the origin so the
1782
+ // consumer receives a relative path.
1783
+ assert.strictEqual(
1784
+ apos.attachment.uploadfs.getUrl(),
1785
+ 'http://localhost:3000/uploads'
1786
+ );
1787
+ assert.strictEqual(
1788
+ attachments.uploadsUrl,
1789
+ '/uploads'
1790
+ );
1791
+ });
1792
+
1793
+ it('apos.baseUrl is NOT modified after static build requests', async function () {
1794
+ const req = apos.task.getAnonReq({
1795
+ mode: 'published',
1796
+ staticBuild: true
1797
+ });
1798
+ await apos.url.getAllUrlMetadata(req);
1799
+ assert.strictEqual(apos.baseUrl, 'http://localhost:3000');
1800
+ });
1801
+ });
1802
+
1803
+ describe('baseUrl + staticBaseUrl configured', function () {
1804
+ let apos;
1805
+
1806
+ before(async function () {
1807
+ apos = await t.create({
1808
+ root: module,
1809
+ baseUrl: 'http://localhost:3000',
1810
+ staticBaseUrl: 'https://www.example.com',
1811
+ modules: {
1812
+ '@apostrophecms/url': {
1813
+ options: { static: true }
1814
+ }
1815
+ }
1816
+ });
1817
+ });
1818
+
1819
+ after(async function () {
1820
+ await t.destroy(apos);
1821
+ apos = null;
1822
+ });
1823
+
1824
+ it('apos.staticBaseUrl is set correctly', function () {
1825
+ assert.strictEqual(apos.staticBaseUrl, 'https://www.example.com');
1826
+ });
1827
+
1828
+ it('getBaseUrl returns staticBaseUrl when req.aposStaticBuild is true', function () {
1829
+ const req = apos.task.getAnonReq({
1830
+ mode: 'published',
1831
+ staticBuild: true
1832
+ });
1833
+ const baseUrl = apos.page.getBaseUrl(req);
1834
+ assert.strictEqual(baseUrl, 'https://www.example.com');
1835
+ });
1836
+
1837
+ it('getBaseUrl returns apos.baseUrl when req.aposStaticBuild is not set', function () {
1838
+ const req = apos.task.getAnonReq({ mode: 'published' });
1839
+ const baseUrl = apos.page.getBaseUrl(req);
1840
+ assert.strictEqual(baseUrl, 'http://localhost:3000');
1841
+ });
1842
+
1843
+ it('getAllUrlMetadata strips staticBaseUrl from page URLs for static build requests', async function () {
1844
+ const req = apos.task.getAnonReq({
1845
+ mode: 'published',
1846
+ staticBuild: true
1847
+ });
1848
+ const { pages } = await apos.url.getAllUrlMetadata(req);
1849
+ assert(pages.length > 0);
1850
+ for (const entry of pages) {
1851
+ assert(
1852
+ !entry.url.startsWith('https://'),
1853
+ `URL should be path-only, got: ${entry.url}`
1854
+ );
1855
+ }
1856
+ const home = pages.find(r => r.url === '/');
1857
+ assert(home, 'Should include the home page with path-only URL');
1858
+ });
1859
+
1860
+ it('getAllUrlMetadata strips original baseUrl from uploadsUrl', async function () {
1861
+ const req = apos.task.getAnonReq({
1862
+ mode: 'published',
1863
+ staticBuild: true
1864
+ });
1865
+ const { attachments } = await apos.url.getAllUrlMetadata(req, {
1866
+ attachments: { scope: 'all' }
1867
+ });
1868
+ assert(attachments);
1869
+ assert.strictEqual(
1870
+ attachments.uploadsUrl,
1871
+ '/uploads',
1872
+ 'uploadsUrl should strip the original baseUrl, not staticBaseUrl'
1873
+ );
1874
+ });
1875
+
1876
+ it('req.absoluteUrl uses staticBaseUrl during static builds', function () {
1877
+ const req = apos.task.getAnonReq({
1878
+ mode: 'published',
1879
+ staticBuild: true,
1880
+ url: '/some-page'
1881
+ });
1882
+ assert(
1883
+ req.absoluteUrl.startsWith('https://www.example.com'),
1884
+ `req.absoluteUrl should use staticBaseUrl, got: ${req.absoluteUrl}`
1885
+ );
1886
+ });
1887
+
1888
+ it('apos.baseUrl is NOT modified after static build requests', async function () {
1889
+ const req = apos.task.getAnonReq({
1890
+ mode: 'published',
1891
+ staticBuild: true
1892
+ });
1893
+ await apos.url.getAllUrlMetadata(req);
1894
+ assert.strictEqual(apos.baseUrl, 'http://localhost:3000');
1895
+ });
1896
+ });
1897
+
1898
+ describe('staticBaseUrl only (no baseUrl)', function () {
1899
+ let apos;
1900
+
1901
+ before(async function () {
1902
+ apos = await t.create({
1903
+ root: module,
1904
+ staticBaseUrl: 'https://www.example.com',
1905
+ modules: {
1906
+ '@apostrophecms/url': {
1907
+ options: { static: true }
1908
+ }
1909
+ }
1910
+ });
1911
+ });
1912
+
1913
+ after(async function () {
1914
+ await t.destroy(apos);
1915
+ apos = null;
1916
+ });
1917
+
1918
+ it('apos.baseUrl is undefined', function () {
1919
+ assert.strictEqual(apos.baseUrl, undefined);
1920
+ });
1921
+
1922
+ it('apos.staticBaseUrl is set', function () {
1923
+ assert.strictEqual(apos.staticBaseUrl, 'https://www.example.com');
1924
+ });
1925
+
1926
+ it('getBaseUrl returns staticBaseUrl when req.aposStaticBuild is true', function () {
1927
+ const req = apos.task.getAnonReq({
1928
+ mode: 'published',
1929
+ staticBuild: true
1930
+ });
1931
+ const baseUrl = apos.page.getBaseUrl(req);
1932
+ assert.strictEqual(baseUrl, 'https://www.example.com');
1933
+ });
1934
+
1935
+ it('getBaseUrl returns empty string without static build flag', function () {
1936
+ const req = apos.task.getAnonReq({ mode: 'published' });
1937
+ const baseUrl = apos.page.getBaseUrl(req);
1938
+ assert.strictEqual(baseUrl, '');
1939
+ });
1940
+
1941
+ it('getAllUrlMetadata strips staticBaseUrl from page URLs', async function () {
1942
+ const req = apos.task.getAnonReq({
1943
+ mode: 'published',
1944
+ staticBuild: true
1945
+ });
1946
+ const { pages } = await apos.url.getAllUrlMetadata(req);
1947
+ assert(pages.length > 0);
1948
+ for (const entry of pages) {
1949
+ assert(
1950
+ !entry.url.startsWith('https://'),
1951
+ `URL should be path-only, got: ${entry.url}`
1952
+ );
1953
+ }
1954
+ });
1955
+
1956
+ it('uploadsUrl is path-only (no baseUrl to strip)', async function () {
1957
+ const req = apos.task.getAnonReq({
1958
+ mode: 'published',
1959
+ staticBuild: true
1960
+ });
1961
+ const { attachments } = await apos.url.getAllUrlMetadata(req, {
1962
+ attachments: { scope: 'all' }
1963
+ });
1964
+ assert(attachments);
1965
+ assert.strictEqual(attachments.uploadsUrl, '/uploads');
1966
+ });
1967
+ });
1968
+
1969
+ describe('baseUrl + prefix', function () {
1970
+ let apos;
1971
+
1972
+ before(async function () {
1973
+ apos = await t.create({
1974
+ root: module,
1975
+ baseUrl: 'http://localhost:3000',
1976
+ prefix: '/cms',
1977
+ modules: {
1978
+ '@apostrophecms/url': {
1979
+ options: { static: true }
1980
+ }
1981
+ }
1982
+ });
1983
+ });
1984
+
1985
+ after(async function () {
1986
+ await t.destroy(apos);
1987
+ apos = null;
1988
+ });
1989
+
1990
+ it('getAllUrlMetadata strips baseUrl and prefix from page URLs', async function () {
1991
+ const req = apos.task.getAnonReq({
1992
+ mode: 'published',
1993
+ staticBuild: true
1994
+ });
1995
+ const { pages } = await apos.url.getAllUrlMetadata(req);
1996
+ assert(pages.length > 0);
1997
+ for (const entry of pages) {
1998
+ assert(
1999
+ !entry.url.startsWith('http://'),
2000
+ `URL should not start with http://, got: ${entry.url}`
2001
+ );
2002
+ assert(
2003
+ !entry.url.startsWith('/cms'),
2004
+ `URL should not start with /cms prefix, got: ${entry.url}`
2005
+ );
2006
+ }
2007
+ const home = pages.find(r => r.url === '/');
2008
+ assert(home, 'Should include the home page as /');
2009
+ });
2010
+
2011
+ it('getAllUrlMetadata strips origin from uploadsUrl, preserves prefix', async function () {
2012
+ const req = apos.task.getAnonReq({
2013
+ mode: 'published',
2014
+ staticBuild: true
2015
+ });
2016
+ const { attachments } = await apos.url.getAllUrlMetadata(req, {
2017
+ attachments: { scope: 'all' }
2018
+ });
2019
+ assert(attachments);
2020
+ // Without staticBaseUrl, uploadfs is initialized with the
2021
+ // full baseUrl + prefix. getAllUrlMetadata strips the
2022
+ // origin, leaving a relative path that still includes the
2023
+ // prefix — the consumer strips the prefix separately.
2024
+ assert.strictEqual(
2025
+ apos.attachment.uploadfs.getUrl(),
2026
+ 'http://localhost:3000/cms/uploads'
2027
+ );
2028
+ assert.strictEqual(
2029
+ attachments.uploadsUrl,
2030
+ '/cms/uploads'
2031
+ );
2032
+ });
2033
+ });
2034
+
2035
+ describe('no static header (normal external front)', function () {
2036
+ let apos;
2037
+
2038
+ before(async function () {
2039
+ apos = await t.create({
2040
+ root: module,
2041
+ baseUrl: 'http://localhost:3000',
2042
+ modules: {
2043
+ '@apostrophecms/url': {
2044
+ options: { static: true }
2045
+ }
2046
+ }
2047
+ });
2048
+ });
2049
+
2050
+ after(async function () {
2051
+ await t.destroy(apos);
2052
+ apos = null;
2053
+ });
2054
+
2055
+ it('getAllUrlMetadata returns path-only page URLs even without static build flag', async function () {
2056
+ const req = apos.task.getAnonReq({ mode: 'published' });
2057
+ const { pages } = await apos.url.getAllUrlMetadata(req);
2058
+ assert(pages.length > 0);
2059
+ const home = pages.find(r => r.url === '/');
2060
+ assert(home, 'Should return path-only URLs regardless of static build flag');
2061
+ for (const entry of pages) {
2062
+ assert(
2063
+ !entry.url.startsWith('http://'),
2064
+ `URL should be path-only, got: ${entry.url}`
2065
+ );
2066
+ }
2067
+ });
2068
+
2069
+ it('getAllUrlMetadata strips origin from uploadsUrl even without static flag', async function () {
2070
+ const req = apos.task.getAnonReq({ mode: 'published' });
2071
+ const { attachments } = await apos.url.getAllUrlMetadata(req, {
2072
+ attachments: { scope: 'all' }
2073
+ });
2074
+ assert(attachments);
2075
+ // The origin is always stripped from uploadsUrl so the
2076
+ // consumer receives a consistent relative path.
2077
+ assert.strictEqual(
2078
+ apos.attachment.uploadfs.getUrl(),
2079
+ 'http://localhost:3000/uploads'
2080
+ );
2081
+ assert.strictEqual(
2082
+ attachments.uploadsUrl,
2083
+ '/uploads'
2084
+ );
2085
+ });
2086
+ });
2087
+
2088
+ describe('APOS_STATIC_BASE_URL environment variable', function () {
2089
+ let apos;
2090
+ let savedEnvVar;
2091
+
2092
+ before(async function () {
2093
+ savedEnvVar = process.env.APOS_STATIC_BASE_URL;
2094
+ process.env.APOS_STATIC_BASE_URL = 'https://env.example.com';
2095
+ apos = await t.create({
2096
+ root: module,
2097
+ baseUrl: 'http://localhost:3000',
2098
+ staticBaseUrl: 'https://option.example.com',
2099
+ modules: {
2100
+ '@apostrophecms/url': {
2101
+ options: { static: true }
2102
+ }
2103
+ }
2104
+ });
2105
+ });
2106
+
2107
+ after(async function () {
2108
+ if (savedEnvVar) {
2109
+ process.env.APOS_STATIC_BASE_URL = savedEnvVar;
2110
+ } else {
2111
+ delete process.env.APOS_STATIC_BASE_URL;
2112
+ }
2113
+ await t.destroy(apos);
2114
+ apos = null;
2115
+ });
2116
+
2117
+ it('env variable overrides the staticBaseUrl option', function () {
2118
+ assert.strictEqual(apos.staticBaseUrl, 'https://env.example.com');
2119
+ });
2120
+
2121
+ it('getBaseUrl uses env-based staticBaseUrl for static builds', function () {
2122
+ const req = apos.task.getAnonReq({
2123
+ mode: 'published',
2124
+ staticBuild: true
2125
+ });
2126
+ const baseUrl = apos.page.getBaseUrl(req);
2127
+ assert.strictEqual(baseUrl, 'https://env.example.com');
2128
+ });
2129
+ });
2130
+
2131
+ describe('express middleware sets req.aposStaticBuild', function () {
2132
+ let apos;
2133
+
2134
+ before(async function () {
2135
+ apos = await t.create({
2136
+ root: module,
2137
+ baseUrl: 'http://localhost:3000',
2138
+ staticBaseUrl: 'https://www.example.com',
2139
+ modules: {
2140
+ '@apostrophecms/express': {
2141
+ options: {
2142
+ externalFrontKey: 'test-key'
2143
+ }
2144
+ },
2145
+ '@apostrophecms/url': {
2146
+ options: { static: true }
2147
+ }
2148
+ }
2149
+ });
2150
+ });
2151
+
2152
+ after(async function () {
2153
+ await t.destroy(apos);
2154
+ apos = null;
2155
+ });
2156
+
2157
+ it('sets req.aposStaticBuild and req.staticBaseUrl when header is present', async function () {
2158
+ const jar = apos.http.jar();
2159
+ // Use the URL API endpoint which requires externalFront
2160
+ const response = await apos.http.get('/api/v1/@apostrophecms/url', {
2161
+ jar,
2162
+ headers: {
2163
+ 'x-requested-with': 'AposExternalFront',
2164
+ 'apos-external-front-key': 'test-key',
2165
+ 'x-apos-static-base-url': '1'
2166
+ }
2167
+ });
2168
+ assert(response);
2169
+ assert(response.pages);
2170
+ // Page URLs should be path-only (stripped)
2171
+ for (const entry of response.pages) {
2172
+ assert(
2173
+ !entry.url.startsWith('http://') && !entry.url.startsWith('https://'),
2174
+ `URL should be path-only via HTTP, got: ${entry.url}`
2175
+ );
2176
+ }
2177
+ });
2178
+
2179
+ it('returns path-only URLs even without the static header', async function () {
2180
+ const jar = apos.http.jar();
2181
+ const response = await apos.http.get('/api/v1/@apostrophecms/url', {
2182
+ jar,
2183
+ headers: {
2184
+ 'x-requested-with': 'AposExternalFront',
2185
+ 'apos-external-front-key': 'test-key'
2186
+ }
2187
+ });
2188
+ assert(response);
2189
+ assert(response.pages);
2190
+ const home = response.pages.find(r => r.url === '/');
2191
+ assert(home, 'URLs should be path-only regardless of static header');
2192
+ });
2193
+ });
2194
+
2195
+ describe('CDN uploadsUrl (cloud provider)', function () {
2196
+ let apos;
2197
+
2198
+ before(async function () {
2199
+ apos = await t.create({
2200
+ root: module,
2201
+ baseUrl: 'http://localhost:3000',
2202
+ modules: {
2203
+ '@apostrophecms/url': {
2204
+ options: { static: true }
2205
+ },
2206
+ '@apostrophecms/uploadfs': {
2207
+ options: {
2208
+ uploadfs: {
2209
+ uploadsUrl: 'https://cdn.example.com/uploads'
2210
+ }
2211
+ }
2212
+ }
2213
+ }
2214
+ });
2215
+ });
2216
+
2217
+ after(async function () {
2218
+ await t.destroy(apos);
2219
+ apos = null;
2220
+ });
2221
+
2222
+ it('does not strip CDN uploadsUrl that differs from baseUrl', async function () {
2223
+ const req = apos.task.getAnonReq({ mode: 'published' });
2224
+ const { attachments } = await apos.url.getAllUrlMetadata(req, {
2225
+ attachments: { scope: 'all' }
2226
+ });
2227
+ assert(attachments);
2228
+ assert.strictEqual(
2229
+ attachments.uploadsUrl,
2230
+ 'https://cdn.example.com/uploads',
2231
+ 'CDN uploadsUrl should be left unchanged'
2232
+ );
2233
+ });
2234
+
2235
+ it('still strips baseUrl from page URLs', async function () {
2236
+ const req = apos.task.getAnonReq({ mode: 'published' });
2237
+ const { pages } = await apos.url.getAllUrlMetadata(req);
2238
+ assert(pages.length > 0);
2239
+ for (const entry of pages) {
2240
+ assert(
2241
+ !entry.url.startsWith('http://localhost:3000'),
2242
+ `URL should be path-only, got: ${entry.url}`
2243
+ );
2244
+ }
2245
+ });
2246
+ });
2247
+ });
2248
+
2249
+ describe('uploadfs relative URLs', function () {
2250
+
2251
+ describe('default (no staticBaseUrl, no relativeUrls)', function () {
2252
+ let apos;
2253
+
2254
+ before(async function () {
2255
+ apos = await t.create({
2256
+ root: module,
2257
+ baseUrl: 'http://localhost:3000',
2258
+ modules: {}
2259
+ });
2260
+ });
2261
+
2262
+ after(async function () {
2263
+ await t.destroy(apos);
2264
+ apos = null;
2265
+ });
2266
+
2267
+ it('uploadsUrl includes baseUrl (BC)', function () {
2268
+ assert.strictEqual(
2269
+ apos.uploadfs.getUrl(),
2270
+ 'http://localhost:3000/uploads'
2271
+ );
2272
+ });
2273
+ });
2274
+
2275
+ describe('with staticBaseUrl configured', function () {
2276
+ let apos;
2277
+
2278
+ before(async function () {
2279
+ apos = await t.create({
2280
+ root: module,
2281
+ baseUrl: 'http://localhost:3000',
2282
+ staticBaseUrl: 'https://www.example.com',
2283
+ modules: {}
2284
+ });
2285
+ });
2286
+
2287
+ after(async function () {
2288
+ await t.destroy(apos);
2289
+ apos = null;
2290
+ });
2291
+
2292
+ it('uploadsUrl is path-only (no host)', function () {
2293
+ assert.strictEqual(apos.uploadfs.getUrl(), '/uploads');
2294
+ });
2295
+ });
2296
+
2297
+ describe('with staticBaseUrl as empty string', function () {
2298
+ let apos;
2299
+
2300
+ before(async function () {
2301
+ apos = await t.create({
2302
+ root: module,
2303
+ baseUrl: 'http://localhost:3000',
2304
+ staticBaseUrl: '',
2305
+ modules: {}
2306
+ });
2307
+ });
2308
+
2309
+ after(async function () {
2310
+ await t.destroy(apos);
2311
+ apos = null;
2312
+ });
2313
+
2314
+ it('uploadsUrl includes baseUrl (empty string is falsy, BC preserved)', function () {
2315
+ assert.strictEqual(
2316
+ apos.uploadfs.getUrl(),
2317
+ 'http://localhost:3000/uploads'
2318
+ );
2319
+ });
2320
+ });
2321
+
2322
+ describe('with relativeUrls option (no staticBaseUrl)', function () {
2323
+ let apos;
2324
+
2325
+ before(async function () {
2326
+ apos = await t.create({
2327
+ root: module,
2328
+ baseUrl: 'http://localhost:3000',
2329
+ modules: {
2330
+ '@apostrophecms/uploadfs': {
2331
+ options: {
2332
+ relativeUrls: true
2333
+ }
2334
+ }
2335
+ }
2336
+ });
2337
+ });
2338
+
2339
+ after(async function () {
2340
+ await t.destroy(apos);
2341
+ apos = null;
2342
+ });
2343
+
2344
+ it('uploadsUrl is path-only', function () {
2345
+ assert.strictEqual(apos.uploadfs.getUrl(), '/uploads');
2346
+ });
2347
+ });
2348
+
2349
+ describe('with both staticBaseUrl and relativeUrls', function () {
2350
+ let apos;
2351
+
2352
+ before(async function () {
2353
+ apos = await t.create({
2354
+ root: module,
2355
+ baseUrl: 'http://localhost:3000',
2356
+ staticBaseUrl: 'https://www.example.com',
2357
+ modules: {
2358
+ '@apostrophecms/uploadfs': {
2359
+ options: {
2360
+ relativeUrls: true
2361
+ }
2362
+ }
2363
+ }
2364
+ });
2365
+ });
2366
+
2367
+ after(async function () {
2368
+ await t.destroy(apos);
2369
+ apos = null;
2370
+ });
2371
+
2372
+ it('uploadsUrl is path-only', function () {
2373
+ assert.strictEqual(apos.uploadfs.getUrl(), '/uploads');
2374
+ });
2375
+ });
2376
+
2377
+ describe('with prefix and staticBaseUrl', function () {
2378
+ let apos;
2379
+
2380
+ before(async function () {
2381
+ apos = await t.create({
2382
+ root: module,
2383
+ baseUrl: 'http://localhost:3000',
2384
+ staticBaseUrl: 'https://www.example.com',
2385
+ prefix: '/cms',
2386
+ modules: {}
2387
+ });
2388
+ });
2389
+
2390
+ after(async function () {
2391
+ await t.destroy(apos);
2392
+ apos = null;
2393
+ });
2394
+
2395
+ it('uploadsUrl preserves prefix but omits host', function () {
2396
+ assert.strictEqual(apos.uploadfs.getUrl(), '/cms/uploads');
2397
+ });
2398
+ });
2399
+
2400
+ describe('with prefix and relativeUrls (no staticBaseUrl)', function () {
2401
+ let apos;
2402
+
2403
+ before(async function () {
2404
+ apos = await t.create({
2405
+ root: module,
2406
+ baseUrl: 'http://localhost:3000',
2407
+ prefix: '/cms',
2408
+ modules: {
2409
+ '@apostrophecms/uploadfs': {
2410
+ options: {
2411
+ relativeUrls: true
2412
+ }
2413
+ }
2414
+ }
2415
+ });
2416
+ });
2417
+
2418
+ after(async function () {
2419
+ await t.destroy(apos);
2420
+ apos = null;
2421
+ });
2422
+
2423
+ it('uploadsUrl preserves prefix but omits host', function () {
2424
+ assert.strictEqual(apos.uploadfs.getUrl(), '/cms/uploads');
2425
+ });
2426
+ });
2427
+
2428
+ describe('cloud storage overrides uploadsUrl', function () {
2429
+ let apos;
2430
+
2431
+ before(async function () {
2432
+ apos = await t.create({
2433
+ root: module,
2434
+ baseUrl: 'http://localhost:3000',
2435
+ staticBaseUrl: 'https://www.example.com',
2436
+ modules: {
2437
+ '@apostrophecms/uploadfs': {
2438
+ options: {
2439
+ uploadfs: {
2440
+ uploadsUrl: 'https://cdn.example.com/uploads'
2441
+ }
2442
+ }
2443
+ }
2444
+ }
2445
+ });
2446
+ });
2447
+
2448
+ after(async function () {
2449
+ await t.destroy(apos);
2450
+ apos = null;
2451
+ });
2452
+
2453
+ it('explicit uploadsUrl from cloud config takes precedence', function () {
2454
+ assert.strictEqual(
2455
+ apos.uploadfs.getUrl(),
2456
+ 'https://cdn.example.com/uploads'
2457
+ );
2458
+ });
2459
+ });
2460
+
2461
+ });
2462
+
2463
+ describe('staticBuildHeader middleware', function () {
2464
+
2465
+ describe('with staticBaseUrl configured', function () {
2466
+ let apos;
2467
+
2468
+ before(async function () {
2469
+ apos = await t.create({
2470
+ root: module,
2471
+ baseUrl: 'http://localhost:3000',
2472
+ staticBaseUrl: 'https://www.example.com',
2473
+ modules: {
2474
+ '@apostrophecms/express': {
2475
+ options: {
2476
+ externalFrontKey: 'test-key'
2477
+ }
2478
+ },
2479
+ '@apostrophecms/url': {
2480
+ options: { static: true }
2481
+ },
2482
+ article: {
2483
+ extend: '@apostrophecms/piece-type',
2484
+ options: {
2485
+ name: 'article',
2486
+ label: 'Article',
2487
+ alias: 'article',
2488
+ publicApiProjection: {
2489
+ title: 1,
2490
+ _url: 1
2491
+ }
2492
+ }
2493
+ },
2494
+ 'article-page': {
2495
+ extend: '@apostrophecms/piece-page-type',
2496
+ options: {
2497
+ name: 'articlePage',
2498
+ label: 'Articles',
2499
+ alias: 'articlePage'
2500
+ }
2501
+ },
2502
+ '@apostrophecms/page': {
2503
+ options: {
2504
+ park: [
2505
+ {
2506
+ title: 'Articles',
2507
+ type: 'articlePage',
2508
+ slug: '/articles',
2509
+ parkedId: 'articles'
2510
+ }
2511
+ ]
2512
+ }
2513
+ }
2514
+ }
2515
+ });
2516
+
2517
+ const req = apos.task.getReq();
2518
+ await apos.article.insert(req, {
2519
+ title: 'Middleware Test Article',
2520
+ visibility: 'public'
2521
+ });
2522
+ });
2523
+
2524
+ after(async function () {
2525
+ await t.destroy(apos);
2526
+ apos = null;
2527
+ });
2528
+
2529
+ it('sets req.aposStaticBuild via direct API call with header', async function () {
2530
+ const jar = apos.http.jar();
2531
+ const response = await apos.http.get('/api/v1/article', {
2532
+ jar,
2533
+ headers: {
2534
+ 'x-apos-static-base-url': '1'
2535
+ }
2536
+ });
2537
+ assert(response);
2538
+ assert(response.results);
2539
+ assert(response.results.length > 0);
2540
+ // With aposStaticBuild and staticBaseUrl configured, piece _url
2541
+ // should use staticBaseUrl (not baseUrl)
2542
+ for (const piece of response.results) {
2543
+ assert(
2544
+ piece._url.startsWith('https://www.example.com'),
2545
+ `_url should use staticBaseUrl, got: ${piece._url}`
2546
+ );
2547
+ assert(
2548
+ !piece._url.startsWith('http://localhost:3000'),
2549
+ `_url should NOT use baseUrl, got: ${piece._url}`
2550
+ );
2551
+ }
2552
+ });
2553
+
2554
+ it('does not set aposStaticBuild without the header', async function () {
2555
+ const jar = apos.http.jar();
2556
+ const response = await apos.http.get('/api/v1/article', {
2557
+ jar
2558
+ });
2559
+ assert(response);
2560
+ assert(response.results);
2561
+ assert(response.results.length > 0);
2562
+ // Without the header, piece _url should include baseUrl
2563
+ for (const piece of response.results) {
2564
+ assert(
2565
+ piece._url.startsWith('http://localhost:3000'),
2566
+ `_url should include baseUrl without the header, got: ${piece._url}`
2567
+ );
2568
+ }
2569
+ });
2570
+
2571
+ it('externalFront still works when both headers are sent', async function () {
2572
+ const jar = apos.http.jar();
2573
+ const response = await apos.http.get('/api/v1/@apostrophecms/url', {
2574
+ jar,
2575
+ headers: {
2576
+ 'x-requested-with': 'AposExternalFront',
2577
+ 'apos-external-front-key': 'test-key',
2578
+ 'x-apos-static-base-url': '1'
2579
+ }
2580
+ });
2581
+ assert(response);
2582
+ assert(response.pages);
2583
+ for (const entry of response.pages) {
2584
+ assert(
2585
+ !entry.url.startsWith('http://'),
2586
+ `URL should be path-only via externalFront, got: ${entry.url}`
2587
+ );
2588
+ }
2589
+ });
2590
+ });
2591
+
2592
+ describe('without staticBaseUrl (header still sets aposStaticBuild)', function () {
2593
+ let apos;
2594
+
2595
+ before(async function () {
2596
+ apos = await t.create({
2597
+ root: module,
2598
+ baseUrl: 'http://localhost:3000',
2599
+ modules: {
2600
+ article: {
2601
+ extend: '@apostrophecms/piece-type',
2602
+ options: {
2603
+ name: 'article',
2604
+ label: 'Article',
2605
+ alias: 'article',
2606
+ publicApiProjection: {
2607
+ title: 1,
2608
+ _url: 1
2609
+ }
2610
+ }
2611
+ },
2612
+ 'article-page': {
2613
+ extend: '@apostrophecms/piece-page-type',
2614
+ options: {
2615
+ name: 'articlePage',
2616
+ label: 'Articles',
2617
+ alias: 'articlePage'
2618
+ }
2619
+ },
2620
+ '@apostrophecms/page': {
2621
+ options: {
2622
+ park: [
2623
+ {
2624
+ title: 'Articles',
2625
+ type: 'articlePage',
2626
+ slug: '/articles',
2627
+ parkedId: 'articles'
2628
+ }
2629
+ ]
2630
+ }
2631
+ }
2632
+ }
2633
+ });
2634
+
2635
+ const req = apos.task.getReq();
2636
+ await apos.article.insert(req, {
2637
+ title: 'No StaticBaseUrl Article',
2638
+ visibility: 'public'
2639
+ });
2640
+ });
2641
+
2642
+ after(async function () {
2643
+ await t.destroy(apos);
2644
+ apos = null;
2645
+ });
2646
+
2647
+ it('sets aposStaticBuild even without staticBaseUrl (matches externalFront behavior)', async function () {
2648
+ const jar = apos.http.jar();
2649
+ const response = await apos.http.get('/api/v1/article', {
2650
+ jar,
2651
+ headers: {
2652
+ 'x-apos-static-base-url': '1'
2653
+ }
2654
+ });
2655
+ assert(response);
2656
+ assert(response.results);
2657
+ assert(response.results.length > 0);
2658
+ // With aposStaticBuild=true and no staticBaseUrl, getBaseUrl returns ''
2659
+ // so _url should be path-only
2660
+ for (const piece of response.results) {
2661
+ assert(
2662
+ !piece._url.startsWith('http://'),
2663
+ `_url should be path-only when aposStaticBuild is true, got: ${piece._url}`
2664
+ );
2665
+ }
2666
+ });
2667
+ });
2668
+
2669
+ describe('non-API routes ignore the header', function () {
2670
+ let apos;
2671
+
2672
+ before(async function () {
2673
+ apos = await t.create({
2674
+ root: module,
2675
+ baseUrl: 'http://localhost:3000',
2676
+ staticBaseUrl: 'https://www.example.com',
2677
+ modules: {}
2678
+ });
2679
+ });
2680
+
2681
+ after(async function () {
2682
+ await t.destroy(apos);
2683
+ apos = null;
2684
+ });
2685
+
2686
+ it('non-API request with the header does not cause errors', async function () {
2687
+ const jar = apos.http.jar();
2688
+ // Request the home page (non-API route) with the static header
2689
+ // — should be ignored and not cause issues
2690
+ const response = await apos.http.get('/', {
2691
+ jar,
2692
+ headers: {
2693
+ 'x-apos-static-base-url': '1'
2694
+ },
2695
+ fullResponse: true
2696
+ });
2697
+ assert.strictEqual(response.status, 200);
2698
+ });
2699
+ });
2700
+ });
2701
+ });