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.
- package/CHANGELOG.md +33 -3
- package/README.md +142 -0
- package/defaults.js +1 -0
- package/lib/safe-json-script.js +27 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
- package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
- package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
- package/modules/@apostrophecms/attachment/index.js +43 -1
- package/modules/@apostrophecms/color-field/index.js +7 -1
- package/modules/@apostrophecms/doc/index.js +11 -1
- package/modules/@apostrophecms/doc-type/index.js +165 -32
- package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
- package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
- package/modules/@apostrophecms/file/index.js +109 -9
- package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
- package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
- package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
- package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
- package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
- package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
- package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
- package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
- package/modules/@apostrophecms/layout-widget/index.js +7 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
- package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
- package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
- package/modules/@apostrophecms/login/index.js +39 -40
- package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
- package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
- package/modules/@apostrophecms/page/index.js +2 -0
- package/modules/@apostrophecms/piece-type/index.js +3 -1
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
- package/modules/@apostrophecms/recently-edited/index.js +831 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
- package/modules/@apostrophecms/styles/index.js +10 -0
- package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
- package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
- package/modules/@apostrophecms/styles/lib/methods.js +9 -3
- package/modules/@apostrophecms/styles/lib/presets.js +119 -0
- package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
- package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
- package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
- package/modules/@apostrophecms/template/index.js +22 -6
- package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
- package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
- package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
- package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
- package/modules/@apostrophecms/url/index.js +38 -4
- package/modules/@apostrophecms/widget-type/index.js +22 -6
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
- package/package.json +19 -19
- package/test/files.js +129 -0
- package/test/layout-widget-migration.js +719 -0
- package/test/login-requirements.js +1 -1
- package/test/pieces-public-api.js +80 -0
- package/test/pieces.js +25 -0
- package/test/recently-edited.js +2311 -0
- package/test/schemas.js +39 -3
- package/test/static-build.js +642 -0
- package/test/styles.js +2569 -0
- 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
|
+
});
|