apostrophe 4.28.0 → 4.29.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 (88) hide show
  1. package/CHANGELOG.md +33 -3
  2. package/README.md +142 -0
  3. package/defaults.js +1 -0
  4. package/lib/safe-json-script.js +27 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  9. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  10. package/modules/@apostrophecms/attachment/index.js +43 -1
  11. package/modules/@apostrophecms/color-field/index.js +7 -1
  12. package/modules/@apostrophecms/doc/index.js +11 -1
  13. package/modules/@apostrophecms/doc-type/index.js +165 -32
  14. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  16. package/modules/@apostrophecms/file/index.js +109 -9
  17. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  18. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  19. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  20. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  21. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  22. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  23. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  24. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  25. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  26. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  27. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  28. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  29. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  30. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  31. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  32. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  33. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  34. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  35. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  36. package/modules/@apostrophecms/login/index.js +39 -40
  37. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  38. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  39. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  40. package/modules/@apostrophecms/page/index.js +2 -0
  41. package/modules/@apostrophecms/piece-type/index.js +3 -1
  42. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  43. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  44. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  45. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  46. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  47. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  48. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  49. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  50. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  51. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  52. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  57. package/modules/@apostrophecms/styles/index.js +10 -0
  58. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  59. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  60. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  61. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  62. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  63. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  64. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  65. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  66. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  67. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  68. package/modules/@apostrophecms/template/index.js +22 -6
  69. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  71. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  72. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  73. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  74. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  75. package/modules/@apostrophecms/url/index.js +38 -4
  76. package/modules/@apostrophecms/widget-type/index.js +22 -6
  77. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  78. package/package.json +19 -19
  79. package/test/files.js +129 -0
  80. package/test/layout-widget-migration.js +719 -0
  81. package/test/login-requirements.js +1 -1
  82. package/test/pieces-public-api.js +80 -0
  83. package/test/pieces.js +25 -0
  84. package/test/recently-edited.js +2311 -0
  85. package/test/schemas.js +39 -3
  86. package/test/static-build.js +642 -0
  87. package/test/styles.js +2569 -0
  88. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
@@ -0,0 +1,2311 @@
1
+ const assert = require('assert').strict;
2
+ const t = require('../test-lib/test.js');
3
+
4
+ describe('Recently Edited', function () {
5
+ let apos;
6
+ let jar;
7
+
8
+ this.timeout(t.timeout);
9
+
10
+ const baseUrl = 'http://localhost:3000';
11
+
12
+ before(async function () {
13
+ apos = await t.create({
14
+ root: module,
15
+ baseUrl,
16
+ modules: {
17
+ '@apostrophecms/i18n': {
18
+ options: {
19
+ locales: {
20
+ en: {
21
+ label: 'English'
22
+ },
23
+ fr: {
24
+ label: 'French',
25
+ prefix: '/fr'
26
+ },
27
+ de: {
28
+ label: 'German',
29
+ prefix: '/de'
30
+ }
31
+ }
32
+ }
33
+ },
34
+ '@apostrophecms/express': {
35
+ options: {
36
+ session: { secret: 'test' }
37
+ }
38
+ },
39
+ '@apostrophecms/recently-edited': {
40
+ options: {
41
+ excludeTypes: [ 'excluded-article' ]
42
+ }
43
+ },
44
+ article: {
45
+ extend: '@apostrophecms/piece-type',
46
+ options: {
47
+ alias: 'article',
48
+ label: 'Article',
49
+ pluralLabel: 'Articles'
50
+ },
51
+ fields: {
52
+ add: {
53
+ blurb: {
54
+ type: 'string',
55
+ label: 'Blurb'
56
+ }
57
+ }
58
+ }
59
+ },
60
+ topic: {
61
+ extend: '@apostrophecms/piece-type',
62
+ options: {
63
+ alias: 'topic',
64
+ label: 'Topic',
65
+ pluralLabel: 'Topics'
66
+ },
67
+ fields: {
68
+ add: {
69
+ description: {
70
+ type: 'string',
71
+ label: 'Description'
72
+ }
73
+ }
74
+ }
75
+ },
76
+ 'excluded-article': {
77
+ extend: '@apostrophecms/piece-type',
78
+ options: {
79
+ alias: 'excludedArticle',
80
+ label: 'Excluded Article',
81
+ pluralLabel: 'Excluded Articles'
82
+ }
83
+ },
84
+ 'non-localized': {
85
+ extend: '@apostrophecms/piece-type',
86
+ options: {
87
+ alias: 'nonLocalized',
88
+ label: 'Non-Localized',
89
+ localized: false
90
+ }
91
+ },
92
+ 'default-page': {
93
+ extend: '@apostrophecms/page-type',
94
+ options: {
95
+ label: 'Default Page'
96
+ }
97
+ },
98
+ '@apostrophecms/page': {
99
+ options: {
100
+ types: [
101
+ {
102
+ name: 'default-page',
103
+ label: 'Default Page'
104
+ },
105
+ {
106
+ name: '@apostrophecms/home-page',
107
+ label: 'Home'
108
+ }
109
+ ]
110
+ }
111
+ }
112
+ }
113
+ });
114
+ await t.createAdmin(apos);
115
+ jar = await t.loginAs(apos, 'admin');
116
+ });
117
+
118
+ after(async function () {
119
+ return t.destroy(apos);
120
+ });
121
+
122
+ // Helpers
123
+ const recentApi = () => '/api/v1/@apostrophecms/recently-edited';
124
+
125
+ async function cleanDocs() {
126
+ const protectedTypes = [
127
+ '@apostrophecms/user',
128
+ '@apostrophecms/home-page',
129
+ '@apostrophecms/archive-page',
130
+ '@apostrophecms/global'
131
+ ];
132
+ await apos.doc.db.deleteMany({
133
+ type: { $nin: protectedTypes }
134
+ });
135
+ }
136
+
137
+ async function insertPiece(type, data, options = {}) {
138
+ const manager = apos.modules[type];
139
+ const reqOptions = { mode: 'draft' };
140
+ if (options.locale) {
141
+ reqOptions.locale = options.locale;
142
+ }
143
+ const req = apos.task.getReq(reqOptions);
144
+ const instance = manager.newInstance();
145
+ const piece = await manager.insert(req, {
146
+ ...instance,
147
+ ...data
148
+ });
149
+ return piece;
150
+ }
151
+
152
+ async function insertPage(data, options = {}) {
153
+ const reqOptions = { mode: 'draft' };
154
+ if (options.locale) {
155
+ reqOptions.locale = options.locale;
156
+ }
157
+ const req = apos.task.getReq(reqOptions);
158
+ const home = await apos.page.find(req, { slug: '/' }).toObject();
159
+ const page = await apos.page.insert(req, home._id, 'lastChild', {
160
+ title: data.title || 'Test Page',
161
+ type: data.type || 'default-page',
162
+ ...data
163
+ });
164
+ return page;
165
+ }
166
+
167
+ // ───── Module Bootstrap ─────
168
+
169
+ describe('module initialization', function () {
170
+ it('is registered with the recently-edited alias', function () {
171
+ assert(apos.recentlyEdited);
172
+ });
173
+
174
+ it('extends @apostrophecms/piece-type', function () {
175
+ assert(
176
+ apos.modules['@apostrophecms/recently-edited'].__meta.chain
177
+ .some(entry => entry.name === '@apostrophecms/piece-type')
178
+ );
179
+ });
180
+
181
+ it('detects managed types (localized piece and page types)', function () {
182
+ const names = apos.recentlyEdited.managedTypeNames;
183
+ assert(names.includes('article'));
184
+ assert(names.includes('topic'));
185
+ assert(names.includes('default-page'));
186
+ assert(names.includes('@apostrophecms/home-page'));
187
+ });
188
+
189
+ it('excludes types from the internal blacklist', function () {
190
+ const names = apos.recentlyEdited.managedTypeNames;
191
+ assert(!names.includes('@apostrophecms/recently-edited'));
192
+ assert(!names.includes('@apostrophecms/submitted-draft'));
193
+ assert(!names.includes('@apostrophecms/archive-page'));
194
+ });
195
+
196
+ it('excludes developer-configured excludeTypes', function () {
197
+ const names = apos.recentlyEdited.managedTypeNames;
198
+ assert(!names.includes('excluded-article'));
199
+ });
200
+
201
+ it('excludes non-localized types', function () {
202
+ const names = apos.recentlyEdited.managedTypeNames;
203
+ assert(!names.includes('non-localized'));
204
+ });
205
+
206
+ it('excludes abstract base types', function () {
207
+ const names = apos.recentlyEdited.managedTypeNames;
208
+ assert(!names.includes('@apostrophecms/any-doc-type'));
209
+ assert(!names.includes('@apostrophecms/any-page-type'));
210
+ assert(!names.includes('@apostrophecms/polymorphic-type'));
211
+ });
212
+
213
+ it('separates page type names from piece type names', function () {
214
+ assert(apos.recentlyEdited.managedPageTypeNames.includes('default-page'));
215
+ assert(apos.recentlyEdited.managedPageTypeNames.includes('@apostrophecms/home-page'));
216
+ assert(!apos.recentlyEdited.managedPageTypeNames.includes('article'));
217
+
218
+ assert(apos.recentlyEdited.managedPieceTypeNames.includes('article'));
219
+ assert(apos.recentlyEdited.managedPieceTypeNames.includes('topic'));
220
+ assert(!apos.recentlyEdited.managedPieceTypeNames.includes('default-page'));
221
+ });
222
+
223
+ it('stores managed types with label info', function () {
224
+ const articleType = apos.recentlyEdited.managedTypes
225
+ .find(t => t.name === 'article');
226
+ assert(articleType);
227
+ assert.equal(articleType.label, 'Article');
228
+ assert.equal(articleType.pluralLabel, 'Articles');
229
+ });
230
+
231
+ it('creates the compound index for recently edited lookup', async function () {
232
+ const indexes = await apos.doc.db.indexes();
233
+ const idx = indexes.find(i => i.name === 'recentlyEditedLookup');
234
+ assert(idx);
235
+ assert.deepEqual(idx.key, {
236
+ updatedAt: -1,
237
+ _id: 1,
238
+ type: 1,
239
+ aposLocale: 1
240
+ });
241
+ });
242
+ });
243
+
244
+ // ───── Filter Choice Registry ─────
245
+
246
+ describe('filter choice registry', function () {
247
+ it('has built-in action choices', function () {
248
+ const reg = apos.recentlyEdited.filterChoiceRegistry.action;
249
+ assert(reg.created);
250
+ assert(reg.published);
251
+ assert(reg.submitted);
252
+ assert(reg.localized);
253
+ });
254
+
255
+ it('has built-in status choices', function () {
256
+ const reg = apos.recentlyEdited.filterChoiceRegistry.status;
257
+ assert(reg.live);
258
+ assert(reg.draft);
259
+ assert(reg.modified);
260
+ assert(reg.submitted);
261
+ assert(reg.archived);
262
+ });
263
+
264
+ it('addFilterChoice registers a new action choice', function () {
265
+ apos.recentlyEdited.addFilterChoice({
266
+ type: 'action',
267
+ name: 'imported',
268
+ label: 'Imported',
269
+ criteria: { importedAt: { $exists: true } }
270
+ });
271
+ assert(apos.recentlyEdited.filterChoiceRegistry.action.imported);
272
+ assert.equal(
273
+ apos.recentlyEdited.filterChoiceRegistry.action.imported.label,
274
+ 'Imported'
275
+ );
276
+ // Clean up
277
+ delete apos.recentlyEdited.filterChoiceRegistry.action.imported;
278
+ });
279
+
280
+ it('addFilterChoice registers a new status choice', function () {
281
+ apos.recentlyEdited.addFilterChoice({
282
+ type: 'status',
283
+ name: 'reviewed',
284
+ label: 'Reviewed',
285
+ criteria: { reviewed: true }
286
+ });
287
+ assert(apos.recentlyEdited.filterChoiceRegistry.status.reviewed);
288
+ // Clean up
289
+ delete apos.recentlyEdited.filterChoiceRegistry.status.reviewed;
290
+ });
291
+
292
+ it('addFilterChoice throws for invalid type', function () {
293
+ assert.throws(() => {
294
+ apos.recentlyEdited.addFilterChoice({
295
+ type: 'bogus',
296
+ name: 'x',
297
+ label: 'X',
298
+ criteria: { x: 1 }
299
+ });
300
+ }, /type must be "action" or "status"/);
301
+ });
302
+ });
303
+
304
+ // ───── Virtual Type Behavior ─────
305
+
306
+ describe('virtual type behavior', function () {
307
+ before(async function () {
308
+ await cleanDocs();
309
+ });
310
+
311
+ it('throws on insert (virtual type should never insert)', async function () {
312
+ await assert.rejects(async () => {
313
+ await apos.recentlyEdited.insert(
314
+ apos.task.getReq(),
315
+ { title: 'test' }
316
+ );
317
+ }, /Virtual piece type/);
318
+ });
319
+
320
+ it('delegates update to the actual type manager', async function () {
321
+ const article = await insertPiece('article', { title: 'Delegate Update Test' });
322
+ const updated = await apos.recentlyEdited.update(
323
+ apos.task.getReq(),
324
+ {
325
+ ...article,
326
+ title: 'Updated via delegate'
327
+ }
328
+ );
329
+ assert.equal(updated.title, 'Updated via delegate');
330
+ });
331
+
332
+ it('delegates publish to the actual type manager', async function () {
333
+ const article = await insertPiece('article', { title: 'Delegate Publish Test' });
334
+ const published = await apos.recentlyEdited.publish(
335
+ apos.task.getReq(),
336
+ article
337
+ );
338
+ assert(published);
339
+ // publish() returns the published version of the doc
340
+ // Verify the published doc exists in the DB
341
+ const publishedDoc = await apos.doc.db.findOne({
342
+ aposDocId: article.aposDocId,
343
+ aposLocale: 'en:published'
344
+ });
345
+ assert(publishedDoc);
346
+ });
347
+ });
348
+
349
+ // ───── getCutoffDate ─────
350
+
351
+ describe('getCutoffDate', function () {
352
+ it('returns a date in the past based on recentDays option', function () {
353
+ const cutoff = apos.recentlyEdited.getCutoffDate();
354
+ assert(cutoff instanceof Date);
355
+ const now = new Date();
356
+ const diffDays = (now - cutoff) / (1000 * 60 * 60 * 24);
357
+ // recentDays is 30
358
+ assert(diffDays >= 29 && diffDays <= 31);
359
+ });
360
+ });
361
+
362
+ // ───── find() Query Builder ─────
363
+
364
+ describe('find() base query', function () {
365
+ before(async function () {
366
+ await cleanDocs();
367
+ for (let i = 0; i < 3; i++) {
368
+ await insertPiece('article', {
369
+ title: `Find Article ${i}`
370
+ });
371
+ }
372
+ // Insert a topic in a different locale
373
+ await insertPiece('topic', {
374
+ title: 'French Topic'
375
+ }, { locale: 'fr' });
376
+
377
+ // Insert an excluded type
378
+ const excludedManager = apos.modules['excluded-article'];
379
+ const req = apos.task.getReq({ mode: 'draft' });
380
+ await excludedManager.insert(req, {
381
+ ...excludedManager.newInstance(),
382
+ title: 'Should Not Appear'
383
+ });
384
+ });
385
+
386
+ it('returns draft documents across types and locales', async function () {
387
+ const req = apos.task.getReq({ mode: 'draft' });
388
+ const results = await apos.recentlyEdited
389
+ .find(req)
390
+ .toArray();
391
+ // Articles + topic + home pages (from parked pages in all 3 locales)
392
+ // + any other parked pages but NOT excluded-article
393
+ assert(results.length > 0);
394
+ const types = [ ...new Set(results.map(r => r.type)) ];
395
+ assert(!types.includes('excluded-article'));
396
+ assert(!types.includes('non-localized'));
397
+ });
398
+
399
+ it('only returns drafts (aposMode: draft)', async function () {
400
+ const req = apos.task.getReq({ mode: 'draft' });
401
+ const results = await apos.recentlyEdited
402
+ .find(req)
403
+ .toArray();
404
+ for (const doc of results) {
405
+ assert(doc.aposLocale.endsWith(':draft'), `Expected draft, got ${doc.aposLocale}`);
406
+ }
407
+ });
408
+
409
+ it('returns docs from all locales (locale null)', async function () {
410
+ const req = apos.task.getReq({
411
+ mode: 'draft',
412
+ locale: 'en'
413
+ });
414
+ const results = await apos.recentlyEdited
415
+ .find(req)
416
+ .toArray();
417
+ const locales = [ ...new Set(results.map(r => r.aposLocale.split(':')[0])) ];
418
+ assert(locales.includes('en'));
419
+ assert(locales.includes('fr'));
420
+ });
421
+
422
+ it('sorts by updatedAt descending with _id tiebreaker', async function () {
423
+ const req = apos.task.getReq({ mode: 'draft' });
424
+ const results = await apos.recentlyEdited
425
+ .find(req)
426
+ .toArray();
427
+ for (let i = 1; i < results.length; i++) {
428
+ const prevDate = new Date(results[i - 1].updatedAt).getTime();
429
+ const currDate = new Date(results[i].updatedAt).getTime();
430
+ if (prevDate === currDate) {
431
+ assert(
432
+ results[i - 1]._id < results[i]._id,
433
+ 'Docs with same updatedAt should be sorted by _id ascending'
434
+ );
435
+ } else {
436
+ assert(
437
+ prevDate > currDate,
438
+ 'Results should be sorted by updatedAt descending'
439
+ );
440
+ }
441
+ }
442
+ });
443
+
444
+ it('respects the rolling time window (updatedAt >= cutoff)', async function () {
445
+ const cutoff = apos.recentlyEdited.getCutoffDate();
446
+ const req = apos.task.getReq({ mode: 'draft' });
447
+ const results = await apos.recentlyEdited
448
+ .find(req)
449
+ .toArray();
450
+ for (const doc of results) {
451
+ assert(
452
+ new Date(doc.updatedAt) >= cutoff,
453
+ `Document ${doc.title} updatedAt ${doc.updatedAt} should be >= cutoff ${cutoff}`
454
+ );
455
+ }
456
+ });
457
+
458
+ it('does not include relationships or areas', async function () {
459
+ const req = apos.task.getReq({ mode: 'draft' });
460
+ const query = apos.recentlyEdited.find(req);
461
+ assert.equal(query.get('relationships'), false);
462
+ assert.equal(query.get('areas'), false);
463
+ assert.equal(query.get('attachments'), false);
464
+ });
465
+ });
466
+
467
+ // ───── REST API Endpoint ─────
468
+
469
+ describe('REST API GET (getAll)', function () {
470
+ before(async function () {
471
+ await cleanDocs();
472
+ for (let i = 1; i <= 5; i++) {
473
+ await insertPiece('article', { title: `REST Article ${i}` });
474
+ }
475
+ for (let i = 1; i <= 3; i++) {
476
+ await insertPiece('topic', { title: `REST Topic ${i}` });
477
+ }
478
+ });
479
+
480
+ it('returns results for authenticated users', async function () {
481
+ const response = await apos.http.get(recentApi(), { jar });
482
+ assert(response.results);
483
+ assert(response.results.length > 0);
484
+ });
485
+
486
+ it('returns paginated results', async function () {
487
+ const response = await apos.http.get(
488
+ recentApi(), {
489
+ jar,
490
+ qs: {
491
+ perPage: 3,
492
+ page: 1
493
+ }
494
+ }
495
+ );
496
+ assert(response.results.length <= 3);
497
+ assert(response.pages >= 1);
498
+ assert.equal(response.currentPage, 1);
499
+ });
500
+
501
+ it('applies the manager API projection by default', async function () {
502
+ const response = await apos.http.get(recentApi(), { jar });
503
+ const doc = response.results[0];
504
+ assert(doc.title !== undefined);
505
+ assert(doc.type !== undefined);
506
+ assert(doc.slug !== undefined);
507
+ assert(doc.updatedAt !== undefined);
508
+ assert(doc.aposLocale !== undefined);
509
+ assert(doc.aposMode !== undefined);
510
+ assert(doc.aposDocId !== undefined);
511
+ assert(doc._id !== undefined);
512
+ assert(doc.updatedBy !== undefined || doc.updatedBy === null);
513
+ assert(doc.archived !== undefined);
514
+ assert(doc.modified !== undefined);
515
+ // Virtual permission flags
516
+ assert.equal(doc._edit, true);
517
+ assert.equal(doc._publish, true);
518
+ assert.equal(doc._delete, true);
519
+ });
520
+
521
+ it('does ensure projection does not leak schema-heavy fields', async function () {
522
+ const article = await insertPiece('article', {
523
+ title: 'Projection Leak Test',
524
+ blurb: 'should not be in projection'
525
+ });
526
+ const response = await apos.http.get(recentApi(), { jar });
527
+ const doc = response.results.find(r => r._id === article._id);
528
+ assert(doc);
529
+ assert.equal(doc.blurb, undefined);
530
+ });
531
+
532
+ it('allows client-provided projection to override (e.g. _id only for select-all)', async function () {
533
+ const response = await apos.http.get(
534
+ recentApi(), {
535
+ jar,
536
+ qs: { 'project[_id]': 1 }
537
+ }
538
+ );
539
+ const doc = response.results[0];
540
+ assert(doc._id);
541
+ assert.equal(doc.blurb, undefined);
542
+ });
543
+
544
+ it('lean mode disables addUrls computation', async function () {
545
+ const page = await insertPage({ title: 'Lean URL Test' });
546
+
547
+ const normal = await apos.http.get(
548
+ recentApi(), {
549
+ jar,
550
+ qs: { _docType: [ 'default-page' ] }
551
+ }
552
+ );
553
+ const withUrl = normal.results.find(r => r._id === page._id);
554
+ assert(withUrl);
555
+ assert(withUrl._url);
556
+
557
+ // Pages store _url in MongoDB, so it persists even in lean mode.
558
+ // Lean skips addUrls post-processing (cross-locale resolution overhead).
559
+ const lean = await apos.http.get(
560
+ recentApi(), {
561
+ jar,
562
+ qs: {
563
+ lean: 1,
564
+ _docType: [ 'default-page' ]
565
+ }
566
+ }
567
+ );
568
+ const leanPage = lean.results.find(r => r._id === page._id);
569
+ assert(leanPage);
570
+ assert(leanPage._url);
571
+ });
572
+
573
+ it('includes parked field in projection for pages', async function () {
574
+ const response = await apos.http.get(
575
+ recentApi(), {
576
+ jar,
577
+ qs: { _docType: [ '@apostrophecms/home-page' ] }
578
+ }
579
+ );
580
+ const home = response.results.find(
581
+ r => r.type === '@apostrophecms/home-page'
582
+ );
583
+ assert(home);
584
+ assert(home.parked !== undefined);
585
+ });
586
+ });
587
+
588
+ // ───── Query Builders (Filters) ─────
589
+
590
+ describe('query builders', function () {
591
+ // Known data setup:
592
+ // - 3 admin articles (en) via REST (updatedBy = admin)
593
+ // - 2 editor articles (en) via DB stamp (updatedBy = editor)
594
+ // - 1 admin topic (en) via REST
595
+ // - 1 french article (fr) via server-side insert
596
+ // - 6 parked managed docs (3 home-page + 3 global, all with lastPublishedAt)
597
+ // Total user-created: 7 docs, parked: 6 = 13 draft docs
598
+ let adminUser;
599
+ let editorUserId;
600
+
601
+ before(async function () {
602
+ await cleanDocs();
603
+
604
+ adminUser = await apos.doc.db.findOne({
605
+ type: '@apostrophecms/user',
606
+ username: 'admin'
607
+ });
608
+
609
+ editorUserId = 'simulated-editor-user-id';
610
+ const editorUpdatedBy = {
611
+ _id: editorUserId,
612
+ title: 'qb-editor',
613
+ username: 'qb-editor'
614
+ };
615
+
616
+ for (let i = 1; i <= 3; i++) {
617
+ await apos.http.post('/api/v1/article', {
618
+ jar,
619
+ body: {
620
+ title: `Admin Article ${i}`,
621
+ blurb: `admin article ${i}`
622
+ }
623
+ });
624
+ }
625
+
626
+ for (let i = 1; i <= 2; i++) {
627
+ const piece = await insertPiece('article', {
628
+ title: `Editor Article ${i}`
629
+ });
630
+ await apos.doc.db.updateOne(
631
+ { _id: piece._id },
632
+ { $set: { updatedBy: editorUpdatedBy } }
633
+ );
634
+ }
635
+
636
+ await apos.http.post('/api/v1/topic', {
637
+ jar,
638
+ body: { title: 'Admin Topic 1' }
639
+ });
640
+
641
+ const frReq = apos.task.getReq({
642
+ mode: 'draft',
643
+ locale: 'fr'
644
+ });
645
+ const frManager = apos.modules.article;
646
+ await frManager.insert(frReq, {
647
+ ...frManager.newInstance(),
648
+ title: 'French Article'
649
+ });
650
+ });
651
+
652
+ // ── _docType filter ──
653
+
654
+ describe('_docType filter', function () {
655
+ it('filters by a specific document type', async function () {
656
+ const response = await apos.http.get(
657
+ recentApi(), {
658
+ jar,
659
+ qs: { _docType: [ 'article' ] }
660
+ }
661
+ );
662
+ // 3 admin + 2 editor + 1 french = 6 articles
663
+ assert.equal(response.results.length, 6);
664
+ for (const doc of response.results) {
665
+ assert.equal(doc.type, 'article');
666
+ }
667
+ });
668
+
669
+ it('filters by multiple types', async function () {
670
+ const response = await apos.http.get(
671
+ recentApi(), {
672
+ jar,
673
+ qs: { _docType: [ 'article', 'topic' ] }
674
+ }
675
+ );
676
+ // 6 articles + 1 topic = 7
677
+ assert.equal(response.results.length, 7);
678
+ const types = [ ...new Set(response.results.map(r => r.type)) ];
679
+ assert.deepEqual(types.sort(), [ 'article', 'topic' ]);
680
+ });
681
+
682
+ it('filters by virtual group @apostrophecms/any-page-type', async function () {
683
+ const response = await apos.http.get(
684
+ recentApi(), {
685
+ jar,
686
+ qs: { _docType: [ '@apostrophecms/any-page-type' ] }
687
+ }
688
+ );
689
+ // 3 home pages (en, fr, de)
690
+ assert.equal(response.results.length, 3);
691
+ for (const doc of response.results) {
692
+ assert(
693
+ apos.recentlyEdited.managedPageTypeNames.includes(doc.type),
694
+ `${doc.type} should be a page type`
695
+ );
696
+ }
697
+ });
698
+
699
+ it('filters by virtual group @apostrophecms/piece-type', async function () {
700
+ const response = await apos.http.get(
701
+ recentApi(), {
702
+ jar,
703
+ qs: { _docType: [ '@apostrophecms/piece-type' ] }
704
+ }
705
+ );
706
+ // 6 articles + 1 topic + 3 globals = 10
707
+ assert.equal(response.results.length, 10);
708
+ for (const doc of response.results) {
709
+ assert(
710
+ apos.recentlyEdited.managedPieceTypeNames.includes(doc.type),
711
+ `${doc.type} should be a piece type`
712
+ );
713
+ }
714
+ });
715
+
716
+ it('launders invalid types — returns all results', async function () {
717
+ const response = await apos.http.get(
718
+ recentApi(), {
719
+ jar,
720
+ qs: { _docType: [ 'bogus-type' ] }
721
+ }
722
+ );
723
+ // Invalid filter launders to empty array, no narrowing applied
724
+ assert.equal(response.results.length, 13);
725
+ });
726
+ });
727
+
728
+ // ── _editedBy filter ──
729
+
730
+ describe('_editedBy filter', function () {
731
+ it('returns only admin-edited docs', async function () {
732
+ const response = await apos.http.get(
733
+ recentApi(), {
734
+ jar,
735
+ qs: { _editedBy: adminUser._id }
736
+ }
737
+ );
738
+ // 3 admin articles + 1 admin topic = 4
739
+ // (home pages + french article lack admin updatedBy stamp)
740
+ assert.equal(response.results.length, 4);
741
+ for (const doc of response.results) {
742
+ assert.equal(doc.updatedBy?._id, adminUser._id);
743
+ }
744
+ });
745
+
746
+ it('returns only editor-edited docs', async function () {
747
+ const response = await apos.http.get(
748
+ recentApi(), {
749
+ jar,
750
+ qs: { _editedBy: editorUserId }
751
+ }
752
+ );
753
+ // 2 editor articles
754
+ assert.equal(response.results.length, 2);
755
+ for (const doc of response.results) {
756
+ assert.equal(doc.updatedBy?._id, editorUserId);
757
+ }
758
+ });
759
+ });
760
+
761
+ // ── _locale filter ──
762
+
763
+ describe('_locale filter', function () {
764
+ it('returns only English locale docs', async function () {
765
+ const response = await apos.http.get(
766
+ recentApi(), {
767
+ jar,
768
+ qs: { _locale: 'en' }
769
+ }
770
+ );
771
+ // 3 admin articles + 2 editor articles + 1 topic + 1 en home + 1 en
772
+ // global = 8
773
+ assert.equal(response.results.length, 8);
774
+ for (const doc of response.results) {
775
+ assert(doc.aposLocale.startsWith('en:'));
776
+ }
777
+ });
778
+
779
+ it('returns only French locale docs', async function () {
780
+ const response = await apos.http.get(
781
+ recentApi(), {
782
+ jar,
783
+ qs: { _locale: 'fr' }
784
+ }
785
+ );
786
+ // 1 french article + 1 fr home + 1 fr global = 3
787
+ assert.equal(response.results.length, 3);
788
+ for (const doc of response.results) {
789
+ assert(doc.aposLocale.startsWith('fr:'));
790
+ }
791
+ });
792
+
793
+ it('returns all 3 configured locales when no filter', async function () {
794
+ const response = await apos.http.get(recentApi(), { jar });
795
+ const locales = [ ...new Set(
796
+ response.results.map(r => r.aposLocale.split(':')[0])
797
+ ) ];
798
+ assert.deepEqual(locales.sort(), [ 'de', 'en', 'fr' ]);
799
+ });
800
+ });
801
+
802
+ // ── _action filter ──
803
+
804
+ describe('_action filter', function () {
805
+ it('filters by "created" action', async function () {
806
+ const response = await apos.http.get(
807
+ recentApi(), {
808
+ jar,
809
+ qs: { _action: 'created' }
810
+ }
811
+ );
812
+ // All test docs were created within recentDays window
813
+ assert.equal(response.results.length, 13);
814
+ });
815
+
816
+ it('filters by "published" action', async function () {
817
+ const piece = await insertPiece('article', { title: 'Action Published Test' });
818
+ const req = apos.task.getReq({ mode: 'draft' });
819
+ await apos.modules.article.publish(req, piece);
820
+
821
+ const response = await apos.http.get(
822
+ recentApi(), {
823
+ jar,
824
+ qs: { _action: 'published' }
825
+ }
826
+ );
827
+ assert(response.results.length > 0);
828
+ for (const doc of response.results) {
829
+ assert(doc.lastPublishedAt, 'published docs should have lastPublishedAt');
830
+ }
831
+ });
832
+
833
+ it('launders unknown action — no filter applied, returns all', async function () {
834
+ const response = await apos.http.get(
835
+ recentApi(), {
836
+ jar,
837
+ qs: { _action: 'nonexistent' }
838
+ }
839
+ );
840
+ // unknown action launders to null, no narrowing
841
+ assert(response.results.length >= 10);
842
+ });
843
+ });
844
+
845
+ // ── _status filter ──
846
+
847
+ describe('_status filter', function () {
848
+ before(async function () {
849
+ await cleanDocs();
850
+
851
+ // 2 published articles
852
+ for (let i = 1; i <= 2; i++) {
853
+ const piece = await insertPiece('article', { title: `Published Article ${i}` });
854
+ const req = apos.task.getReq({ mode: 'draft' });
855
+ await apos.modules.article.publish(req, piece);
856
+ }
857
+ // 3 draft-only articles
858
+ for (let i = 1; i <= 3; i++) {
859
+ await insertPiece('article', { title: `Draft Article ${i}` });
860
+ }
861
+ // 1 archived article
862
+ const archived = await insertPiece('article', { title: 'Archived Article' });
863
+ await apos.doc.db.updateOne(
864
+ { _id: archived._id },
865
+ { $set: { archived: true } }
866
+ );
867
+ });
868
+
869
+ it('filters by "live" status (has lastPublishedAt)', async function () {
870
+ const response = await apos.http.get(
871
+ recentApi(), {
872
+ jar,
873
+ qs: { _status: 'live' }
874
+ }
875
+ );
876
+ // 2 published articles + 6 parked (3 home + 3 global,
877
+ // all have lastPublishedAt) = 8
878
+ assert.equal(response.results.length, 8);
879
+ for (const doc of response.results) {
880
+ assert(doc.lastPublishedAt, 'live docs should have lastPublishedAt');
881
+ }
882
+ });
883
+
884
+ it('filters by "draft" status (no lastPublishedAt)', async function () {
885
+ const response = await apos.http.get(
886
+ recentApi(), {
887
+ jar,
888
+ qs: { _status: 'draft' }
889
+ }
890
+ );
891
+ for (const doc of response.results) {
892
+ assert(!doc.lastPublishedAt, 'draft-only docs should lack lastPublishedAt');
893
+ }
894
+ // 3 draft-only articles (no lastPublishedAt)
895
+ // archived article excluded by default archived builder
896
+ assert.equal(response.results.length, 3);
897
+ });
898
+
899
+ it('includes unpublished documents in "draft" status (lastPublishedAt set to null)', async function () {
900
+ // Publish then unpublish an article to set lastPublishedAt to null
901
+ const piece = await insertPiece('article', { title: 'Unpublished Article' });
902
+ const req = apos.task.getReq({ mode: 'draft' });
903
+ await apos.modules.article.publish(req, piece);
904
+ await apos.modules.article.unpublish(req, piece);
905
+
906
+ const draftResponse = await apos.http.get(
907
+ recentApi(), {
908
+ jar,
909
+ qs: { _status: 'draft' }
910
+ }
911
+ );
912
+ const found = draftResponse.results.find(d => d.title === 'Unpublished Article');
913
+ assert(found, 'unpublished doc (lastPublishedAt: null) should appear under "draft" status');
914
+ assert(!found.lastPublishedAt, 'unpublished doc should have falsy lastPublishedAt');
915
+
916
+ // It should NOT appear under "live" status
917
+ const liveResponse = await apos.http.get(
918
+ recentApi(), {
919
+ jar,
920
+ qs: { _status: 'live' }
921
+ }
922
+ );
923
+ const foundInLive = liveResponse.results.find(d => d.title === 'Unpublished Article');
924
+ assert(!foundInLive, 'unpublished doc should not appear under "live" status');
925
+
926
+ // Clean up to avoid affecting subsequent test counts
927
+ await apos.doc.db.deleteMany({ aposDocId: piece.aposDocId });
928
+ });
929
+
930
+ it('filters by "archived" status', async function () {
931
+ const response = await apos.http.get(
932
+ recentApi(), {
933
+ jar,
934
+ qs: { _status: 'archived' }
935
+ }
936
+ );
937
+ assert.equal(response.results.length, 1);
938
+ assert.equal(response.results[0].title, 'Archived Article');
939
+ assert.equal(response.results[0].archived, true);
940
+ });
941
+
942
+ it('launders unknown status — no filter applied, returns all', async function () {
943
+ const response = await apos.http.get(
944
+ recentApi(), {
945
+ jar,
946
+ qs: { _status: 'nonexistent' }
947
+ }
948
+ );
949
+ // 2 published + 3 draft + 6 parked = 11
950
+ // (archived article excluded by default archived builder)
951
+ assert.equal(response.results.length, 11);
952
+ });
953
+ });
954
+
955
+ // ── Search ──
956
+
957
+ describe('search filter', function () {
958
+ before(async function () {
959
+ await cleanDocs();
960
+ await insertPiece('article', { title: 'Searchable French Pastry' });
961
+ await insertPiece('article', { title: 'English Breakfast' });
962
+ await insertPiece('topic', { title: 'French Cuisine' });
963
+ });
964
+
965
+ it('filters results by search text', async function () {
966
+ const response = await apos.http.get(
967
+ recentApi(), {
968
+ jar,
969
+ qs: { autocomplete: 'French' }
970
+ }
971
+ );
972
+ assert.equal(response.results.length, 2);
973
+ const titles = response.results.map(r => r.title).sort();
974
+ assert.deepEqual(titles, [ 'French Cuisine', 'Searchable French Pastry' ]);
975
+ });
976
+
977
+ it('returns empty results for non-matching search', async function () {
978
+ const response = await apos.http.get(
979
+ recentApi(), {
980
+ jar,
981
+ qs: { autocomplete: 'zzzznonexistent99999' }
982
+ }
983
+ );
984
+ assert.equal(response.results.length, 0);
985
+ });
986
+ });
987
+ });
988
+
989
+ // ───── Filter Choices ─────
990
+
991
+ describe('filter choices', function () {
992
+ // Known data:
993
+ // - 3 admin articles (en) via REST (updatedBy = admin)
994
+ // - 1 editor topic (en) via DB stamp (updatedBy = editor)
995
+ // - 1 french article (fr) via server-side insert
996
+ // - 6 parked managed docs (3 home-page + 3 global)
997
+ // Total: 5 user-created + 6 parked = 11 draft docs
998
+ // Distinct types: article, topic, @apostrophecms/home-page, @apostrophecms/global
999
+ let adminUser;
1000
+ let editorUserId;
1001
+
1002
+ before(async function () {
1003
+ await cleanDocs();
1004
+
1005
+ editorUserId = 'simulated-choice-editor-id';
1006
+ const editorUpdatedBy = {
1007
+ _id: editorUserId,
1008
+ title: 'choice-editor',
1009
+ username: 'choice-editor'
1010
+ };
1011
+
1012
+ adminUser = await apos.doc.db.findOne({
1013
+ type: '@apostrophecms/user',
1014
+ username: 'admin'
1015
+ });
1016
+
1017
+ for (let i = 1; i <= 3; i++) {
1018
+ await apos.http.post('/api/v1/article', {
1019
+ jar,
1020
+ body: { title: `Choice Article ${i}` }
1021
+ });
1022
+ }
1023
+
1024
+ const topic = await insertPiece('topic', { title: 'Choice Topic Editor' });
1025
+ await apos.doc.db.updateOne(
1026
+ { _id: topic._id },
1027
+ { $set: { updatedBy: editorUpdatedBy } }
1028
+ );
1029
+
1030
+ const frReq = apos.task.getReq({
1031
+ mode: 'draft',
1032
+ locale: 'fr'
1033
+ });
1034
+ const articleMgr = apos.modules.article;
1035
+ await articleMgr.insert(frReq, {
1036
+ ...articleMgr.newInstance(),
1037
+ title: 'French Choice Art'
1038
+ });
1039
+ });
1040
+
1041
+ it('returns _docType choices matching distinct types in data', async function () {
1042
+ const response = await apos.http.get(
1043
+ recentApi(), {
1044
+ jar,
1045
+ qs: { choices: '_docType' }
1046
+ }
1047
+ );
1048
+ const values = response.choices._docType.map(c => c.value).sort();
1049
+ // 4 concrete types + 1 virtual group (@apostrophecms/piece-type)
1050
+ // @apostrophecms/piece-type appears because multiple piece types
1051
+ // (article, topic, global) have data
1052
+ // No @apostrophecms/any-page-type because only 1 page type (home-page) has data
1053
+ assert.deepEqual(values, [
1054
+ '@apostrophecms/global',
1055
+ '@apostrophecms/home-page',
1056
+ '@apostrophecms/piece-type',
1057
+ 'article',
1058
+ 'topic'
1059
+ ]);
1060
+ // Each concrete choice must have a label
1061
+ const articleChoice = response.choices._docType.find(c => c.value === 'article');
1062
+ assert.equal(articleChoice.label, 'Article');
1063
+ const topicChoice = response.choices._docType.find(c => c.value === 'topic');
1064
+ assert.equal(topicChoice.label, 'Topic');
1065
+ });
1066
+
1067
+ it('returns _editedBy choices for all editors in data', async function () {
1068
+ const response = await apos.http.get(
1069
+ recentApi(), {
1070
+ jar,
1071
+ qs: { choices: '_editedBy' }
1072
+ }
1073
+ );
1074
+ // admin created 3 articles, editor created 1 topic
1075
+ // home pages and french article lack REST-stamped updatedBy
1076
+ const choiceValues = response.choices._editedBy.map(c => c.value).sort();
1077
+ assert(choiceValues.includes(adminUser._id));
1078
+ assert(choiceValues.includes(editorUserId));
1079
+ // Verify labels
1080
+ const adminChoice = response.choices._editedBy.find(c => c.value === adminUser._id);
1081
+ assert(adminChoice.label);
1082
+ const editorChoice = response.choices._editedBy.find(c => c.value === editorUserId);
1083
+ assert.equal(editorChoice.label, 'choice-editor');
1084
+ });
1085
+
1086
+ it('returns _locale choices for all 3 configured locales', async function () {
1087
+ const response = await apos.http.get(
1088
+ recentApi(), {
1089
+ jar,
1090
+ qs: { choices: '_locale' }
1091
+ }
1092
+ );
1093
+ const values = response.choices._locale.map(c => c.value).sort();
1094
+ assert.deepEqual(values, [ 'de', 'en', 'fr' ]);
1095
+ // Labels include locale name
1096
+ const enChoice = response.choices._locale.find(c => c.value === 'en');
1097
+ assert(enChoice.label.includes('English'));
1098
+ const frChoice = response.choices._locale.find(c => c.value === 'fr');
1099
+ assert(frChoice.label.includes('French'));
1100
+ });
1101
+
1102
+ it('returns _action choices — full registry (4 static entries)', async function () {
1103
+ const response = await apos.http.get(
1104
+ recentApi(), {
1105
+ jar,
1106
+ qs: { choices: '_action' }
1107
+ }
1108
+ );
1109
+ const values = response.choices._action.map(c => c.value).sort();
1110
+ assert.deepEqual(values, [ 'created', 'localized', 'published', 'submitted' ]);
1111
+ // Each has a label
1112
+ for (const choice of response.choices._action) {
1113
+ assert(choice.label, `_action choice "${choice.value}" must have a label`);
1114
+ }
1115
+ });
1116
+
1117
+ it('returns _status choices — full registry (5 static entries)', async function () {
1118
+ const response = await apos.http.get(
1119
+ recentApi(), {
1120
+ jar,
1121
+ qs: { choices: '_status' }
1122
+ }
1123
+ );
1124
+ const values = response.choices._status.map(c => c.value).sort();
1125
+ assert.deepEqual(values, [ 'archived', 'draft', 'live', 'modified', 'submitted' ]);
1126
+ for (const choice of response.choices._status) {
1127
+ assert(choice.label, `_status choice "${choice.value}" must have a label`);
1128
+ }
1129
+ });
1130
+
1131
+ it('returns all 5 choice types in a single request', async function () {
1132
+ const response = await apos.http.get(
1133
+ recentApi(), {
1134
+ jar,
1135
+ qs: { choices: '_docType,_editedBy,_locale,_action,_status' }
1136
+ }
1137
+ );
1138
+ assert.equal(response.choices._docType.length, 5);
1139
+ assert(response.choices._editedBy.length >= 2);
1140
+ assert.equal(response.choices._locale.length, 3);
1141
+ assert.equal(response.choices._action.length, 4);
1142
+ assert.equal(response.choices._status.length, 5);
1143
+ });
1144
+
1145
+ it('returns only requested choice types — omitted ones are absent', async function () {
1146
+ const response = await apos.http.get(
1147
+ recentApi(), {
1148
+ jar,
1149
+ qs: { choices: '_editedBy,_action' }
1150
+ }
1151
+ );
1152
+ assert(response.choices._editedBy);
1153
+ assert(response.choices._action);
1154
+ assert.equal(response.choices._docType, undefined);
1155
+ assert.equal(response.choices._locale, undefined);
1156
+ assert.equal(response.choices._status, undefined);
1157
+ });
1158
+
1159
+ it('cross-filtering: _docType narrows _editedBy choices', async function () {
1160
+ const response = await apos.http.get(
1161
+ recentApi(), {
1162
+ jar,
1163
+ qs: {
1164
+ _docType: [ 'topic' ],
1165
+ choices: '_editedBy'
1166
+ }
1167
+ }
1168
+ );
1169
+ // Only the editor created the topic
1170
+ assert.equal(response.choices._editedBy.length, 1);
1171
+ assert.equal(response.choices._editedBy[0].value, editorUserId);
1172
+ });
1173
+
1174
+ it('cross-filtering: _locale narrows _docType choices', async function () {
1175
+ const response = await apos.http.get(
1176
+ recentApi(), {
1177
+ jar,
1178
+ qs: {
1179
+ _locale: 'fr',
1180
+ choices: '_docType'
1181
+ }
1182
+ }
1183
+ );
1184
+ // French locale has: 1 french article + 1 fr home page + 1 fr global
1185
+ // = 3 concrete types + @apostrophecms/piece-type virtual (2+ pieces)
1186
+ const values = response.choices._docType.map(c => c.value).sort();
1187
+ assert.deepEqual(values, [
1188
+ '@apostrophecms/global',
1189
+ '@apostrophecms/home-page',
1190
+ '@apostrophecms/piece-type',
1191
+ 'article'
1192
+ ]);
1193
+ });
1194
+
1195
+ it('ignores bogus filter names in choices param', async function () {
1196
+ const response = await apos.http.get(
1197
+ recentApi(), {
1198
+ jar,
1199
+ qs: { choices: '_docType,bogusFilter,_action' }
1200
+ }
1201
+ );
1202
+ assert(response.choices._docType);
1203
+ assert(response.choices._action);
1204
+ assert.equal(response.choices.bogusFilter, undefined);
1205
+ });
1206
+ });
1207
+
1208
+ // ───── Cross-Locale URL Resolution ─────
1209
+
1210
+ describe('cross-locale URL resolution', function () {
1211
+ // Known data:
1212
+ // - 1 English default-page, 1 French default-page
1213
+ // - 3 home pages (en, fr, de) from parked pages
1214
+ // Total: 2 default-pages + 3 home pages = 5
1215
+
1216
+ before(async function () {
1217
+ await cleanDocs();
1218
+
1219
+ await insertPage({
1220
+ title: 'English Test Page',
1221
+ type: 'default-page'
1222
+ });
1223
+
1224
+ await insertPage(
1225
+ {
1226
+ title: 'French Test Page',
1227
+ type: 'default-page'
1228
+ },
1229
+ { locale: 'fr' }
1230
+ );
1231
+ });
1232
+
1233
+ it('generates correct _url for pages in their native locale', async function () {
1234
+ const response = await apos.http.get(
1235
+ recentApi(), {
1236
+ jar,
1237
+ qs: { _docType: [ 'default-page' ] }
1238
+ }
1239
+ );
1240
+ // 1 en default-page + 1 fr default-page = 2
1241
+ assert.equal(response.results.length, 2);
1242
+
1243
+ const enPage = response.results.find(
1244
+ p => p.aposLocale.startsWith('en:')
1245
+ );
1246
+ const frPage = response.results.find(
1247
+ p => p.aposLocale.startsWith('fr:')
1248
+ );
1249
+
1250
+ assert(enPage, 'English page must exist');
1251
+ assert(frPage, 'French page must exist');
1252
+
1253
+ assert(
1254
+ !enPage._url.includes('/fr/'),
1255
+ `English page URL should not have /fr prefix: ${enPage._url}`
1256
+ );
1257
+ assert(
1258
+ frPage._url.includes('/fr'),
1259
+ `French page URL should have /fr prefix: ${frPage._url}`
1260
+ );
1261
+ });
1262
+
1263
+ it('home pages get correct locale-prefixed URLs', async function () {
1264
+ const response = await apos.http.get(
1265
+ recentApi(), {
1266
+ jar,
1267
+ qs: { _docType: [ '@apostrophecms/home-page' ] }
1268
+ }
1269
+ );
1270
+ // 3 home pages: en, fr, de
1271
+ assert.equal(response.results.length, 3);
1272
+
1273
+ const frHome = response.results.find(h => h.aposLocale.startsWith('fr:'));
1274
+ assert(frHome, 'French home page must exist');
1275
+ assert(
1276
+ frHome._url.includes('/fr'),
1277
+ `French home page URL should contain /fr, got ${frHome._url}`
1278
+ );
1279
+
1280
+ const enHome = response.results.find(h => h.aposLocale.startsWith('en:'));
1281
+ assert(enHome, 'English home page must exist');
1282
+ assert(
1283
+ !enHome._url.includes('/fr') && !enHome._url.includes('/de'),
1284
+ `English home page URL should not contain locale prefix, got ${enHome._url}`
1285
+ );
1286
+
1287
+ const deHome = response.results.find(h => h.aposLocale.startsWith('de:'));
1288
+ assert(deHome, 'German home page must exist');
1289
+ assert(
1290
+ deHome._url.includes('/de'),
1291
+ `German home page URL should contain /de, got ${deHome._url}`
1292
+ );
1293
+ });
1294
+
1295
+ it('annotates _url with correct locale prefix in unfiltered cross-locale response', async function () {
1296
+ const response = await apos.http.get(
1297
+ recentApi(), { jar }
1298
+ );
1299
+ // Without any locale or type filter, results span all locales.
1300
+ // Every page-type result (pages store _url in DB) should carry
1301
+ // the correct locale prefix: none for en, /fr for fr, /de for de.
1302
+ const pages = response.results.filter(doc => doc._url);
1303
+ assert(
1304
+ pages.length >= 5,
1305
+ `Expected at least 5 docs with _url (2 default-page + 3 home), got ${pages.length}`
1306
+ );
1307
+
1308
+ for (const doc of pages) {
1309
+ if (doc.aposLocale.startsWith('en:')) {
1310
+ assert(
1311
+ !doc._url.includes('/fr/') && !doc._url.includes('/de/'),
1312
+ `English doc "${doc.title}" should have no locale prefix, got ${doc._url}`
1313
+ );
1314
+ } else if (doc.aposLocale.startsWith('fr:')) {
1315
+ assert(
1316
+ doc._url.includes('/fr/') || doc._url.endsWith('/fr'),
1317
+ `French doc "${doc.title}" should contain /fr, got ${doc._url}`
1318
+ );
1319
+ } else if (doc.aposLocale.startsWith('de:')) {
1320
+ assert(
1321
+ doc._url.includes('/de/') || doc._url.endsWith('/de'),
1322
+ `German doc "${doc.title}" should contain /de, got ${doc._url}`
1323
+ );
1324
+ }
1325
+ }
1326
+ });
1327
+ });
1328
+
1329
+ // ───── localizedAt timestamp ─────
1330
+
1331
+ describe('localizedAt timestamp (core doc-type change)', function () {
1332
+ // Known data:
1333
+ // - 1 article created in en, then localized to fr
1334
+ // - 3 home pages (en, fr, de)
1335
+ // The fr-localized article has localizedAt set
1336
+ let localizedDoc;
1337
+
1338
+ before(async function () {
1339
+ await cleanDocs();
1340
+
1341
+ const article = await insertPiece('article', {
1342
+ title: 'Localize Timestamp Test'
1343
+ });
1344
+
1345
+ const req = apos.task.getReq({
1346
+ mode: 'draft',
1347
+ locale: 'en'
1348
+ });
1349
+ const manager = apos.modules.article;
1350
+ const draft = await manager.find(req, { _id: article._id }).toObject();
1351
+ localizedDoc = await manager.localize(req, draft, 'fr');
1352
+ });
1353
+
1354
+ it('sets localizedAt on a document after localize', async function () {
1355
+ assert(localizedDoc.localizedAt, 'localizedAt should be set after localizing');
1356
+ assert(localizedDoc.localizedAt instanceof Date);
1357
+
1358
+ // Verify it persists in the DB
1359
+ const dbDoc = await apos.doc.db.findOne({ _id: localizedDoc._id });
1360
+ assert(dbDoc.localizedAt);
1361
+ });
1362
+
1363
+ it('localizedAt action filter returns all localized docs', async function () {
1364
+ const response = await apos.http.get(
1365
+ recentApi(), {
1366
+ jar,
1367
+ qs: { _action: 'localized' }
1368
+ }
1369
+ );
1370
+ // 1 fr article (just localized) + 4 parked fr/de docs with localizedAt
1371
+ // (2 home-page + 2 global, both fr and de localized from en during parking)
1372
+ assert.equal(response.results.length, 5);
1373
+ const localized = response.results.find(
1374
+ r => r.title === 'Localize Timestamp Test'
1375
+ );
1376
+ assert(localized, 'Our localized article should be in results');
1377
+ });
1378
+ });
1379
+
1380
+ // ───── getBrowserData ─────
1381
+
1382
+ describe('getBrowserData', function () {
1383
+ it('includes managedTypes in browser data', function () {
1384
+ const req = apos.task.getReq({ mode: 'draft' });
1385
+ const data = apos.recentlyEdited.getBrowserData(req);
1386
+ assert(Array.isArray(data.managedTypes));
1387
+ assert(data.managedTypes.length > 0);
1388
+ const articleType = data.managedTypes.find(t => t.name === 'article');
1389
+ assert(articleType);
1390
+ assert.equal(articleType.label, 'Article');
1391
+ });
1392
+
1393
+ it('includes perPage and rollingWindowDays', function () {
1394
+ const req = apos.task.getReq({ mode: 'draft' });
1395
+ const data = apos.recentlyEdited.getBrowserData(req);
1396
+ assert.equal(data.perPage, 50);
1397
+ assert.equal(data.rollingWindowDays, 30);
1398
+ });
1399
+
1400
+ it('has empty batchOperations', function () {
1401
+ const req = apos.task.getReq({ mode: 'draft' });
1402
+ const data = apos.recentlyEdited.getBrowserData(req);
1403
+ assert(Array.isArray(data.batchOperations));
1404
+ assert.equal(data.batchOperations.length, 0);
1405
+ });
1406
+
1407
+ it('overrides managerModal component', function () {
1408
+ const req = apos.task.getReq({ mode: 'draft' });
1409
+ const data = apos.recentlyEdited.getBrowserData(req);
1410
+ assert.equal(data.components.managerModal, 'AposRecentlyEditedManager');
1411
+ });
1412
+
1413
+ it('does not include removed show* options in browser data', function () {
1414
+ const req = apos.task.getReq({ mode: 'draft' });
1415
+ const data = apos.recentlyEdited.getBrowserData(req);
1416
+ // These were removed; piece-type defaults (undefined) let
1417
+ // AposDocContextMenu prop defaults (true) take over.
1418
+ assert.equal(data.showRestore, undefined);
1419
+ assert.equal(data.showUnpublish, undefined);
1420
+ });
1421
+ });
1422
+
1423
+ // ───── Admin Bar Registration ─────
1424
+
1425
+ describe('admin bar registration', function () {
1426
+ it('registers in the admin bar as contextUtility', function () {
1427
+ const item = apos.adminBar.items.find(
1428
+ i => i.action === '@apostrophecms/recently-edited:manager'
1429
+ );
1430
+ assert(item);
1431
+ assert.equal(item.options.contextUtility, true);
1432
+ assert.equal(item.options.component, 'AposRecentlyEditedIcon');
1433
+ });
1434
+ });
1435
+
1436
+ // ───── Modal Registration ─────
1437
+
1438
+ describe('modal registration', function () {
1439
+ it('registers the manager modal', function () {
1440
+ const modals = apos.modal.modals || [];
1441
+ const found = modals.find(
1442
+ m => m.itemName === '@apostrophecms/recently-edited:manager'
1443
+ );
1444
+ assert(found);
1445
+ assert.equal(found.componentName, 'AposRecentlyEditedManager');
1446
+ });
1447
+ });
1448
+
1449
+ // ───── addContextOperation crossLocale validation (core doc change) ─────
1450
+
1451
+ describe('addContextOperation crossLocale validation', function () {
1452
+ it('accepts crossLocale as a boolean (true)', function () {
1453
+ assert.doesNotThrow(() => {
1454
+ apos.doc.addContextOperation({
1455
+ action: 'test-cross-locale-true',
1456
+ context: 'update',
1457
+ label: 'Test CL True',
1458
+ modal: 'AposTestModal',
1459
+ crossLocale: true
1460
+ });
1461
+ });
1462
+ });
1463
+
1464
+ it('accepts crossLocale as a boolean (false)', function () {
1465
+ assert.doesNotThrow(() => {
1466
+ apos.doc.addContextOperation({
1467
+ action: 'test-cross-locale-false',
1468
+ context: 'update',
1469
+ label: 'Test CL False',
1470
+ modal: 'AposTestModal',
1471
+ crossLocale: false
1472
+ });
1473
+ });
1474
+ });
1475
+
1476
+ it('accepts operation without crossLocale (undefined)', function () {
1477
+ assert.doesNotThrow(() => {
1478
+ apos.doc.addContextOperation({
1479
+ action: 'test-no-cross-locale',
1480
+ context: 'update',
1481
+ label: 'Test No CL',
1482
+ modal: 'AposTestModal'
1483
+ });
1484
+ });
1485
+ });
1486
+
1487
+ it('rejects non-boolean crossLocale', function () {
1488
+ assert.throws(() => {
1489
+ apos.doc.addContextOperation({
1490
+ action: 'test-cross-locale-bad',
1491
+ context: 'update',
1492
+ label: 'Test CL Bad',
1493
+ modal: 'AposTestModal',
1494
+ crossLocale: 'yes'
1495
+ });
1496
+ }, /crossLocale.*must be a boolean/);
1497
+ });
1498
+
1499
+ it('stores crossLocale in contextOperations for browser data', function () {
1500
+ const op = apos.doc.contextOperations.find(
1501
+ o => o.action === 'test-cross-locale-true'
1502
+ );
1503
+ assert(op);
1504
+ assert.equal(op.crossLocale, true);
1505
+
1506
+ // Clean up test operations
1507
+ apos.doc.contextOperations = apos.doc.contextOperations.filter(
1508
+ o => !o.action.startsWith('test-')
1509
+ );
1510
+ });
1511
+ });
1512
+
1513
+ // ───── Pagination & Infinite Scroll ─────
1514
+
1515
+ describe('pagination for infinite scroll', function () {
1516
+ // Known data:
1517
+ // - 8 articles (en)
1518
+ // - 6 parked managed docs (3 home-page + 3 global)
1519
+ // Total: 14 docs. With perPage=3 → 5 pages (3 + 3 + 3 + 3 + 2)
1520
+
1521
+ before(async function () {
1522
+ await cleanDocs();
1523
+ for (let i = 1; i <= 8; i++) {
1524
+ await insertPiece('article', { title: `Scroll Article ${i}` });
1525
+ }
1526
+ });
1527
+
1528
+ it('returns correct pagination metadata', async function () {
1529
+ const response = await apos.http.get(
1530
+ recentApi(), {
1531
+ jar,
1532
+ qs: {
1533
+ perPage: 3,
1534
+ page: 1
1535
+ }
1536
+ }
1537
+ );
1538
+ assert.equal(response.currentPage, 1);
1539
+ assert.equal(response.pages, 5);
1540
+ assert.equal(response.results.length, 3);
1541
+ });
1542
+
1543
+ it('returns subsequent pages with no overlap', async function () {
1544
+ const page1 = await apos.http.get(
1545
+ recentApi(), {
1546
+ jar,
1547
+ qs: {
1548
+ perPage: 3,
1549
+ page: 1
1550
+ }
1551
+ }
1552
+ );
1553
+ const page2 = await apos.http.get(
1554
+ recentApi(), {
1555
+ jar,
1556
+ qs: {
1557
+ perPage: 3,
1558
+ page: 2
1559
+ }
1560
+ }
1561
+ );
1562
+ assert.equal(page1.results.length, 3);
1563
+ assert.equal(page2.results.length, 3);
1564
+ const page1Ids = new Set(page1.results.map(r => r._id));
1565
+ for (const doc of page2.results) {
1566
+ assert(!page1Ids.has(doc._id), 'Page 2 should not overlap with page 1');
1567
+ }
1568
+ });
1569
+
1570
+ it('last page returns remaining items', async function () {
1571
+ const response = await apos.http.get(
1572
+ recentApi(), {
1573
+ jar,
1574
+ qs: {
1575
+ perPage: 3,
1576
+ page: 5
1577
+ }
1578
+ }
1579
+ );
1580
+ assert.equal(response.results.length, 2);
1581
+ assert.equal(response.currentPage, 5);
1582
+ });
1583
+
1584
+ it('returns empty results for page beyond total', async function () {
1585
+ const response = await apos.http.get(
1586
+ recentApi(), {
1587
+ jar,
1588
+ qs: {
1589
+ perPage: 3,
1590
+ page: 999
1591
+ }
1592
+ }
1593
+ );
1594
+ assert.equal(response.results.length, 0);
1595
+ });
1596
+ });
1597
+
1598
+ // ───── Combined Filters + Choices ─────
1599
+
1600
+ describe('combined filters with choices', function () {
1601
+ // Known data:
1602
+ // - 3 admin articles (en) via insertPiece (updatedBy stamped to admin), 1 published
1603
+ // - 2 editor topics (en) via insertPiece (updatedBy stamped to combo-editor)
1604
+ // - 6 parked docs (3 home + 3 global)
1605
+ // Total: 11 draft docs (non-archived)
1606
+ let comboEditorId;
1607
+ let adminUser;
1608
+
1609
+ before(async function () {
1610
+ await cleanDocs();
1611
+
1612
+ comboEditorId = 'simulated-combo-editor-id';
1613
+ const comboEditorUpdatedBy = {
1614
+ _id: comboEditorId,
1615
+ title: 'combo-editor',
1616
+ username: 'combo-editor'
1617
+ };
1618
+
1619
+ adminUser = await apos.doc.db.findOne({
1620
+ type: '@apostrophecms/user',
1621
+ username: 'admin'
1622
+ });
1623
+ const adminUpdatedBy = {
1624
+ _id: adminUser._id,
1625
+ title: adminUser.title,
1626
+ username: adminUser.username
1627
+ };
1628
+
1629
+ for (let i = 1; i <= 3; i++) {
1630
+ const article = await insertPiece('article', {
1631
+ title: `Combo Article ${i}`
1632
+ });
1633
+ await apos.doc.db.updateOne(
1634
+ { _id: article._id },
1635
+ { $set: { updatedBy: adminUpdatedBy } }
1636
+ );
1637
+ }
1638
+
1639
+ for (let i = 1; i <= 2; i++) {
1640
+ const topic = await insertPiece('topic', {
1641
+ title: `Combo Topic ${i}`
1642
+ });
1643
+ await apos.doc.db.updateOne(
1644
+ { _id: topic._id },
1645
+ { $set: { updatedBy: comboEditorUpdatedBy } }
1646
+ );
1647
+ }
1648
+
1649
+ // Publish one article
1650
+ const req = apos.task.getReq({ mode: 'draft' });
1651
+ const draft = await apos.modules.article
1652
+ .find(req, {})
1653
+ .sort({ createdAt: 1 })
1654
+ .toObject();
1655
+ await apos.modules.article.publish(req, draft);
1656
+ });
1657
+
1658
+ it('_docType filter narrows _editedBy choices to matching editors', async function () {
1659
+ const response = await apos.http.get(
1660
+ recentApi(), {
1661
+ jar,
1662
+ qs: {
1663
+ _docType: [ 'topic' ],
1664
+ choices: '_editedBy'
1665
+ }
1666
+ }
1667
+ );
1668
+ // 2 topics, both by combo-editor
1669
+ assert.equal(response.results.length, 2);
1670
+ assert.equal(response.choices._editedBy.length, 1);
1671
+ assert.equal(response.choices._editedBy[0].value, comboEditorId);
1672
+ });
1673
+
1674
+ it('_editedBy filter narrows _docType choices to matching types', async function () {
1675
+ const response = await apos.http.get(
1676
+ recentApi(), {
1677
+ jar,
1678
+ qs: {
1679
+ _editedBy: adminUser._id,
1680
+ choices: '_docType'
1681
+ }
1682
+ }
1683
+ );
1684
+ // Admin created only articles (3), so only 'article' type appears
1685
+ assert.equal(response.results.length, 3);
1686
+ const values = response.choices._docType.map(c => c.value);
1687
+ assert.deepEqual(values.sort(), [ 'article' ]);
1688
+ });
1689
+
1690
+ it('_docType + _status combined: article AND live', async function () {
1691
+ const response = await apos.http.get(
1692
+ recentApi(), {
1693
+ jar,
1694
+ qs: {
1695
+ _docType: [ 'article' ],
1696
+ _status: 'live',
1697
+ choices: '_editedBy,_locale'
1698
+ }
1699
+ }
1700
+ );
1701
+ // 1 published article
1702
+ assert.equal(response.results.length, 1);
1703
+ assert.equal(response.results[0].type, 'article');
1704
+ assert(response.results[0].lastPublishedAt);
1705
+ // Choices reflect the filtered results
1706
+ assert(response.choices._editedBy.length >= 1);
1707
+ assert(response.choices._locale.length >= 1);
1708
+ });
1709
+ });
1710
+
1711
+ // ───── distinctFromQuery helper ─────
1712
+
1713
+ describe('distinctFromQuery', function () {
1714
+ before(async function () {
1715
+ await cleanDocs();
1716
+ await insertPiece('article', { title: 'Distinct Test A' });
1717
+ await insertPiece('topic', { title: 'Distinct Test B' });
1718
+ });
1719
+
1720
+ it('returns distinct values for a property', async function () {
1721
+ const req = apos.task.getReq({ mode: 'draft' });
1722
+ const query = apos.recentlyEdited.find(req);
1723
+ const types = await apos.recentlyEdited.distinctFromQuery(query, 'type');
1724
+ assert(types.includes('article'));
1725
+ assert(types.includes('topic'));
1726
+ });
1727
+
1728
+ it('does not inherit pagination from the parent query', async function () {
1729
+ const req = apos.task.getReq({ mode: 'draft' });
1730
+ const query = apos.recentlyEdited.find(req).perPage(1).page(1);
1731
+ const types = await apos.recentlyEdited.distinctFromQuery(query, 'type');
1732
+ // Even with perPage=1, distinct should return all types
1733
+ assert(types.length >= 2);
1734
+ });
1735
+ });
1736
+
1737
+ // ───── Edge Cases ─────
1738
+
1739
+ describe('edge cases', function () {
1740
+ describe('empty state', function () {
1741
+ before(async function () {
1742
+ await cleanDocs();
1743
+ });
1744
+
1745
+ it('returns only parked docs when no user documents exist', async function () {
1746
+ const response = await apos.http.get(recentApi(), { jar });
1747
+ // 6 parked docs: 3 home-page + 3 global (en, fr, de)
1748
+ assert.equal(response.results.length, 6);
1749
+ const types = [ ...new Set(response.results.map(r => r.type)) ].sort();
1750
+ assert.deepEqual(types, [
1751
+ '@apostrophecms/global',
1752
+ '@apostrophecms/home-page'
1753
+ ]);
1754
+ const allTypes = response.results.map(r => r.type);
1755
+ assert(!allTypes.includes('@apostrophecms/archive-page'));
1756
+ });
1757
+ });
1758
+
1759
+ describe('draft-only results', function () {
1760
+ before(async function () {
1761
+ await cleanDocs();
1762
+ const piece = await insertPiece('article', { title: 'Mode Test' });
1763
+ const req = apos.task.getReq({ mode: 'draft' });
1764
+ await apos.modules.article.publish(req, piece);
1765
+ });
1766
+
1767
+ it('never returns published-mode docs', async function () {
1768
+ const response = await apos.http.get(recentApi(), { jar });
1769
+ for (const doc of response.results) {
1770
+ assert(
1771
+ doc.aposLocale.endsWith(':draft'),
1772
+ `Expected draft mode, got ${doc.aposLocale}`
1773
+ );
1774
+ }
1775
+ });
1776
+ });
1777
+
1778
+ describe('lean mode', function () {
1779
+ let pageId;
1780
+
1781
+ before(async function () {
1782
+ await cleanDocs();
1783
+ const page = await insertPage({
1784
+ title: 'Lean Page Edge Test',
1785
+ type: 'default-page'
1786
+ });
1787
+ pageId = page._id;
1788
+ });
1789
+
1790
+ it('getRestQuery applies lean mode to disable addUrls', async function () {
1791
+ const response = await apos.http.get(
1792
+ recentApi(), {
1793
+ jar,
1794
+ qs: {
1795
+ lean: 1,
1796
+ _docType: [ 'default-page' ]
1797
+ }
1798
+ }
1799
+ );
1800
+ assert.equal(response.results.length, 1);
1801
+ assert.equal(response.results[0]._id, pageId);
1802
+ // Pages store _url in MongoDB — lean skips addUrls post-processing
1803
+ // but the stored value persists via projection
1804
+ assert(response.results[0]._url);
1805
+ });
1806
+ });
1807
+ });
1808
+
1809
+ // ───── Array (Multiselect) Filter Support ─────
1810
+
1811
+ describe('array (multiselect) filter support', function () {
1812
+ before(async function () {
1813
+ await cleanDocs();
1814
+
1815
+ // English articles
1816
+ await insertPiece('article', { title: 'EN Article 1' });
1817
+ await insertPiece('article', { title: 'EN Article 2' });
1818
+
1819
+ // French article
1820
+ await insertPiece('article', { title: 'FR Article' }, { locale: 'fr' });
1821
+
1822
+ // German topic
1823
+ await insertPiece('topic', { title: 'DE Topic' }, { locale: 'de' });
1824
+
1825
+ // Published article (for status tests)
1826
+ const published = await insertPiece('article', { title: 'Published Article' });
1827
+ const req = apos.task.getReq({ mode: 'draft' });
1828
+ await apos.modules.article.publish(req, published);
1829
+
1830
+ // Archived article (for status tests)
1831
+ const archived = await insertPiece('article', { title: 'Archived Article' });
1832
+ await apos.doc.db.updateOne(
1833
+ { _id: archived._id },
1834
+ { $set: { archived: true } }
1835
+ );
1836
+ });
1837
+
1838
+ describe('_locale array', function () {
1839
+ it('filters by multiple locales', async function () {
1840
+ const response = await apos.http.get(
1841
+ recentApi(), {
1842
+ jar,
1843
+ qs: { _locale: [ 'en', 'fr' ] }
1844
+ }
1845
+ );
1846
+ const locales = [ ...new Set(
1847
+ response.results.map(r => r.aposLocale.split(':')[0])
1848
+ ) ];
1849
+ assert(locales.includes('en'));
1850
+ assert(locales.includes('fr'));
1851
+ assert(!locales.includes('de'));
1852
+ });
1853
+
1854
+ it('filters to single locale when array has one element', async function () {
1855
+ const response = await apos.http.get(
1856
+ recentApi(), {
1857
+ jar,
1858
+ qs: { _locale: [ 'de' ] }
1859
+ }
1860
+ );
1861
+ for (const doc of response.results) {
1862
+ assert(doc.aposLocale.startsWith('de:'));
1863
+ }
1864
+ });
1865
+
1866
+ it('strips invalid locales from array', async function () {
1867
+ const response = await apos.http.get(
1868
+ recentApi(), {
1869
+ jar,
1870
+ qs: { _locale: [ 'fr', 'nonexistent' ] }
1871
+ }
1872
+ );
1873
+ for (const doc of response.results) {
1874
+ assert(doc.aposLocale.startsWith('fr:'));
1875
+ }
1876
+ });
1877
+ });
1878
+
1879
+ describe('_editedBy array', function () {
1880
+ it('filters by multiple user IDs', async function () {
1881
+ // Create a second editor
1882
+ const secondEditor = await apos.user.insert(apos.task.getReq(), {
1883
+ username: 'arraytest-editor',
1884
+ password: 'test',
1885
+ title: 'Array Test Editor',
1886
+ role: 'editor'
1887
+ });
1888
+ const editorReq = apos.task.getReq({
1889
+ mode: 'draft',
1890
+ user: secondEditor
1891
+ });
1892
+ const editorPiece = apos.modules.article.newInstance();
1893
+ await apos.modules.article.insert(editorReq, {
1894
+ ...editorPiece,
1895
+ title: 'Editor Piece'
1896
+ });
1897
+
1898
+ const adminUser = await apos.doc.db.findOne({
1899
+ type: '@apostrophecms/user',
1900
+ role: 'admin'
1901
+ });
1902
+
1903
+ const response = await apos.http.get(
1904
+ recentApi(), {
1905
+ jar,
1906
+ qs: { _editedBy: [ adminUser._id, secondEditor._id ] }
1907
+ }
1908
+ );
1909
+ const editorIds = [ ...new Set(
1910
+ response.results.map(r => r.updatedBy?._id).filter(Boolean)
1911
+ ) ];
1912
+ for (const id of editorIds) {
1913
+ assert(
1914
+ id === adminUser._id || id === secondEditor._id,
1915
+ `unexpected editor ${id}`
1916
+ );
1917
+ }
1918
+ assert(response.results.length > 0);
1919
+ });
1920
+ });
1921
+
1922
+ describe('_status array', function () {
1923
+ it('combines statuses with $or (live + archived)', async function () {
1924
+ const response = await apos.http.get(
1925
+ recentApi(), {
1926
+ jar,
1927
+ qs: { _status: [ 'live', 'archived' ] }
1928
+ }
1929
+ );
1930
+ const hasLive = response.results.some(r => r.lastPublishedAt && !r.archived);
1931
+ const hasArchived = response.results.some(r => r.archived);
1932
+ assert(hasLive, 'should include live docs');
1933
+ assert(hasArchived, 'should include archived docs');
1934
+ });
1935
+
1936
+ it('custom status choice with static criteria, scalar request', async function () {
1937
+ apos.recentlyEdited.addFilterChoice({
1938
+ type: 'status',
1939
+ name: 'test-enonly',
1940
+ label: 'EN Articles Only',
1941
+ criteria: { title: 'EN Article 1' }
1942
+ });
1943
+ try {
1944
+ const response = await apos.http.get(
1945
+ recentApi(), {
1946
+ jar,
1947
+ qs: { _status: 'test-enonly' }
1948
+ }
1949
+ );
1950
+ assert.equal(response.results.length, 1);
1951
+ assert.equal(response.results[0].title, 'EN Article 1');
1952
+ } finally {
1953
+ delete apos.recentlyEdited.filterChoiceRegistry.status['test-enonly'];
1954
+ }
1955
+ });
1956
+
1957
+ it('custom status choice combined with built-in via array', async function () {
1958
+ // Register a custom status matching a specific title
1959
+ apos.recentlyEdited.addFilterChoice({
1960
+ type: 'status',
1961
+ name: 'test-published-article',
1962
+ label: 'Published Article',
1963
+ criteria: { title: 'Published Article' }
1964
+ });
1965
+ try {
1966
+ // Request both the custom choice and the built-in 'archived'
1967
+ const response = await apos.http.get(
1968
+ recentApi(), {
1969
+ jar,
1970
+ qs: { _status: [ 'test-published-article', 'archived' ] }
1971
+ }
1972
+ );
1973
+ const titles = response.results.map(r => r.title);
1974
+ assert(
1975
+ titles.includes('Published Article'),
1976
+ 'should include the custom-matched doc'
1977
+ );
1978
+ assert(
1979
+ titles.includes('Archived Article'),
1980
+ 'should include archived doc via $or'
1981
+ );
1982
+ // Must not include unrelated docs (e.g. plain drafts)
1983
+ for (const doc of response.results) {
1984
+ assert(
1985
+ doc.title === 'Published Article' || doc.archived,
1986
+ `unexpected doc: ${doc.title}`
1987
+ );
1988
+ }
1989
+ } finally {
1990
+ delete apos.recentlyEdited.filterChoiceRegistry.status['test-published-article'];
1991
+ }
1992
+ });
1993
+
1994
+ it('custom status with archived: true and criteria, scalar request', async function () {
1995
+ apos.recentlyEdited.addFilterChoice({
1996
+ type: 'status',
1997
+ name: 'test-archived-titled',
1998
+ label: 'Archived & Titled',
1999
+ archived: true,
2000
+ criteria: { title: 'Archived Article' }
2001
+ });
2002
+ try {
2003
+ const response = await apos.http.get(
2004
+ recentApi(), {
2005
+ jar,
2006
+ qs: { _status: 'test-archived-titled' }
2007
+ }
2008
+ );
2009
+ // Scalar archived choice: query.archived(true) + criteria
2010
+ assert.equal(response.results.length, 1);
2011
+ assert.equal(response.results[0].title, 'Archived Article');
2012
+ assert.equal(response.results[0].archived, true);
2013
+ } finally {
2014
+ delete apos.recentlyEdited.filterChoiceRegistry.status['test-archived-titled'];
2015
+ }
2016
+ });
2017
+
2018
+ it('custom status with archived: true and criteria, combined via array', async function () {
2019
+ // Custom archived choice narrowed to a specific title
2020
+ apos.recentlyEdited.addFilterChoice({
2021
+ type: 'status',
2022
+ name: 'test-archived-titled',
2023
+ label: 'Archived & Titled',
2024
+ archived: true,
2025
+ criteria: { title: 'Archived Article' }
2026
+ });
2027
+ // Custom non-archived choice matching a specific title
2028
+ apos.recentlyEdited.addFilterChoice({
2029
+ type: 'status',
2030
+ name: 'test-en1',
2031
+ label: 'EN1',
2032
+ criteria: { title: 'EN Article 1' }
2033
+ });
2034
+ try {
2035
+ const response = await apos.http.get(
2036
+ recentApi(), {
2037
+ jar,
2038
+ qs: { _status: [ 'test-archived-titled', 'test-en1' ] }
2039
+ }
2040
+ );
2041
+ const titles = response.results.map(r => r.title);
2042
+ assert(
2043
+ titles.includes('Archived Article'),
2044
+ 'should include archived doc matched by criteria'
2045
+ );
2046
+ assert(
2047
+ titles.includes('EN Article 1'),
2048
+ 'should include non-archived doc matched by criteria'
2049
+ );
2050
+ // Must not include unrelated docs
2051
+ for (const doc of response.results) {
2052
+ assert(
2053
+ doc.title === 'Archived Article' || doc.title === 'EN Article 1',
2054
+ `unexpected doc: ${doc.title}`
2055
+ );
2056
+ }
2057
+ } finally {
2058
+ delete apos.recentlyEdited.filterChoiceRegistry.status['test-archived-titled'];
2059
+ delete apos.recentlyEdited.filterChoiceRegistry.status['test-en1'];
2060
+ }
2061
+ });
2062
+
2063
+ it('custom status with criteria function receives cutoffDate', async function () {
2064
+ let receivedCutoff;
2065
+ apos.recentlyEdited.addFilterChoice({
2066
+ type: 'status',
2067
+ name: 'test-cutoff',
2068
+ label: 'Cutoff Test',
2069
+ criteria({ cutoffDate }) {
2070
+ receivedCutoff = cutoffDate;
2071
+ // Match everything — just testing the argument
2072
+ return { updatedAt: { $gte: cutoffDate } };
2073
+ }
2074
+ });
2075
+ try {
2076
+ await apos.http.get(
2077
+ recentApi(), {
2078
+ jar,
2079
+ qs: { _status: [ 'test-cutoff' ] }
2080
+ }
2081
+ );
2082
+ assert(receivedCutoff instanceof Date, 'cutoffDate should be a Date');
2083
+ // Should be roughly `recentDays` ago (30 days default)
2084
+ const expectedCutoff = new Date();
2085
+ expectedCutoff.setDate(expectedCutoff.getDate() - 30);
2086
+ const diffMs = Math.abs(receivedCutoff.getTime() - expectedCutoff.getTime());
2087
+ assert(diffMs < 5000, 'cutoffDate should be ~30 days ago');
2088
+ } finally {
2089
+ delete apos.recentlyEdited.filterChoiceRegistry.status['test-cutoff'];
2090
+ }
2091
+ });
2092
+
2093
+ it('strips invalid statuses from array', async function () {
2094
+ const response = await apos.http.get(
2095
+ recentApi(), {
2096
+ jar,
2097
+ qs: { _status: [ 'live', 'hacked' ] }
2098
+ }
2099
+ );
2100
+ for (const doc of response.results) {
2101
+ assert(doc.lastPublishedAt, 'should only have live docs');
2102
+ }
2103
+ });
2104
+
2105
+ it('returns all when array is entirely invalid', async function () {
2106
+ const response = await apos.http.get(
2107
+ recentApi(), {
2108
+ jar,
2109
+ qs: { _status: [ 'fake1', 'fake2' ] }
2110
+ }
2111
+ );
2112
+ assert(response.results.length > 0);
2113
+ });
2114
+ });
2115
+
2116
+ describe('_action array', function () {
2117
+ it('combines actions with $or (created + published)', async function () {
2118
+ const response = await apos.http.get(
2119
+ recentApi(), {
2120
+ jar,
2121
+ qs: { _action: [ 'created', 'published' ] }
2122
+ }
2123
+ );
2124
+ assert(response.results.length > 0);
2125
+ });
2126
+
2127
+ it('custom action choice with static criteria, scalar request', async function () {
2128
+ apos.recentlyEdited.addFilterChoice({
2129
+ type: 'action',
2130
+ name: 'test-titled',
2131
+ label: 'Titled FR',
2132
+ criteria: { title: 'FR Article' }
2133
+ });
2134
+ try {
2135
+ const response = await apos.http.get(
2136
+ recentApi(), {
2137
+ jar,
2138
+ qs: { _action: 'test-titled' }
2139
+ }
2140
+ );
2141
+ assert.equal(response.results.length, 1);
2142
+ assert.equal(response.results[0].title, 'FR Article');
2143
+ } finally {
2144
+ delete apos.recentlyEdited.filterChoiceRegistry.action['test-titled'];
2145
+ }
2146
+ });
2147
+
2148
+ it('custom action choice combined with built-in via array', async function () {
2149
+ // Register custom action matching DE Topic only
2150
+ apos.recentlyEdited.addFilterChoice({
2151
+ type: 'action',
2152
+ name: 'test-de-topic',
2153
+ label: 'DE Topic',
2154
+ criteria: { title: 'DE Topic' }
2155
+ });
2156
+ try {
2157
+ // Combine with built-in 'published' — should get DE Topic + published docs
2158
+ const response = await apos.http.get(
2159
+ recentApi(), {
2160
+ jar,
2161
+ qs: { _action: [ 'test-de-topic', 'published' ] }
2162
+ }
2163
+ );
2164
+ const titles = response.results.map(r => r.title);
2165
+ assert(
2166
+ titles.includes('DE Topic'),
2167
+ 'should include custom-matched doc'
2168
+ );
2169
+ assert(
2170
+ titles.includes('Published Article'),
2171
+ 'should include built-in published doc'
2172
+ );
2173
+ } finally {
2174
+ delete apos.recentlyEdited.filterChoiceRegistry.action['test-de-topic'];
2175
+ }
2176
+ });
2177
+
2178
+ it('strips invalid actions from array', async function () {
2179
+ const response = await apos.http.get(
2180
+ recentApi(), {
2181
+ jar,
2182
+ qs: { _action: [ 'published', 'nonexistent' ] }
2183
+ }
2184
+ );
2185
+ for (const doc of response.results) {
2186
+ assert(doc.lastPublishedAt, 'should only have published docs');
2187
+ }
2188
+ });
2189
+ });
2190
+ });
2191
+
2192
+ // ───── Security & Laundering ─────
2193
+
2194
+ describe('security and laundering', function () {
2195
+ before(async function () {
2196
+ await cleanDocs();
2197
+ await insertPiece('article', { title: 'Security Test Article' });
2198
+ await insertPiece('topic', { title: 'Security Test Topic' });
2199
+ });
2200
+
2201
+ it('never returns @apostrophecms/user docs even when explicitly requested', async function () {
2202
+ const response = await apos.http.get(
2203
+ recentApi(), {
2204
+ jar,
2205
+ qs: { _docType: [ '@apostrophecms/user' ] }
2206
+ }
2207
+ );
2208
+ for (const doc of response.results) {
2209
+ assert.notEqual(
2210
+ doc.type,
2211
+ '@apostrophecms/user',
2212
+ 'User docs must never appear in recently-edited results'
2213
+ );
2214
+ }
2215
+ });
2216
+
2217
+ it('never returns @apostrophecms/recently-edited docs (self-type)', async function () {
2218
+ const response = await apos.http.get(
2219
+ recentApi(), {
2220
+ jar,
2221
+ qs: { _docType: [ '@apostrophecms/recently-edited' ] }
2222
+ }
2223
+ );
2224
+ for (const doc of response.results) {
2225
+ assert.notEqual(doc.type, '@apostrophecms/recently-edited');
2226
+ }
2227
+ });
2228
+
2229
+ it('never returns excluded types even when explicitly requested', async function () {
2230
+ // Insert an excluded article directly in DB to bypass type checks
2231
+ await apos.doc.db.insertOne({
2232
+ _id: 'excluded-test-id:en:draft',
2233
+ aposDocId: 'excluded-test-id',
2234
+ aposLocale: 'en:draft',
2235
+ aposMode: 'draft',
2236
+ type: 'excluded-article',
2237
+ slug: 'excluded-test',
2238
+ title: 'Should Not Appear',
2239
+ visibility: 'public',
2240
+ updatedAt: new Date()
2241
+ });
2242
+
2243
+ const response = await apos.http.get(
2244
+ recentApi(), {
2245
+ jar,
2246
+ qs: { _docType: [ 'excluded-article' ] }
2247
+ }
2248
+ );
2249
+ for (const doc of response.results) {
2250
+ assert.notEqual(
2251
+ doc.type,
2252
+ 'excluded-article',
2253
+ 'Excluded types must never appear'
2254
+ );
2255
+ }
2256
+ });
2257
+
2258
+ it('launders non-managed types out of _docType filter', async function () {
2259
+ const response = await apos.http.get(
2260
+ recentApi(), {
2261
+ jar,
2262
+ qs: {
2263
+ _docType: [
2264
+ '@apostrophecms/archive-page',
2265
+ '@apostrophecms/submitted-draft'
2266
+ ]
2267
+ }
2268
+ }
2269
+ );
2270
+ // Both types are non-managed → laundered out → empty filter → all results
2271
+ // 2 user docs + 6 parked = 8
2272
+ assert.equal(response.results.length, 8);
2273
+ });
2274
+
2275
+ it('launders unknown _status values to null (no filter)', async function () {
2276
+ const response = await apos.http.get(
2277
+ recentApi(), {
2278
+ jar,
2279
+ qs: { _status: 'hacked' }
2280
+ }
2281
+ );
2282
+ // Unknown status launders to null, returns all
2283
+ // 2 user docs + 6 parked = 8
2284
+ assert.equal(response.results.length, 8);
2285
+ });
2286
+
2287
+ it('launders unknown _action values to null (no filter)', async function () {
2288
+ const response = await apos.http.get(
2289
+ recentApi(), {
2290
+ jar,
2291
+ qs: { _action: 'DROP TABLE docs' }
2292
+ }
2293
+ );
2294
+ // Unknown action launders to null, returns all
2295
+ // 2 user docs + 6 parked = 8
2296
+ assert.equal(response.results.length, 8);
2297
+ });
2298
+
2299
+ it('launders _locale with non-configured locale to null (no filter)', async function () {
2300
+ const response = await apos.http.get(
2301
+ recentApi(), {
2302
+ jar,
2303
+ qs: { _locale: 'xx' }
2304
+ }
2305
+ );
2306
+ // Unknown locale launders to null, returns all
2307
+ // 2 user docs + 6 parked = 8
2308
+ assert.equal(response.results.length, 8);
2309
+ });
2310
+ });
2311
+ });