expressa 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +4 -3
  2. package/controllers/collections.js +53 -32
  3. package/db/cached.js +3 -0
  4. package/db/file.js +3 -0
  5. package/db/memory.js +3 -0
  6. package/db/mongo.js +7 -8
  7. package/db/postgres.js +13 -0
  8. package/index.js +3 -1
  9. package/modules/admin/dist/index.html +1 -1
  10. package/modules/admin/dist/static/css/chunk-318c.ffb0d81e.css +1 -0
  11. package/modules/admin/dist/static/css/{chunk-d0cd.97323951.css → chunk-621e.97323951.css} +0 -0
  12. package/modules/admin/dist/static/css/{chunk-418a.5ee55031.css → chunk-6644.69ccf1a7.css} +1 -1
  13. package/modules/admin/dist/static/css/{chunk-787e.1d849865.css → chunk-e375.1d849865.css} +0 -0
  14. package/modules/admin/dist/static/js/{app.4c984719.js → app.03e8afa3.js} +1 -1
  15. package/modules/admin/dist/static/js/chunk-318c.03fa0032.js +1 -0
  16. package/modules/admin/dist/static/js/chunk-621e.a9ed86e2.js +1 -0
  17. package/modules/admin/dist/static/js/chunk-6644.9c6f5c7b.js +1 -0
  18. package/modules/admin/dist/static/js/chunk-e375.089d85cd.js +1 -0
  19. package/modules/admin/src/components/JSONEditor.vue +180 -181
  20. package/modules/admin/src/views/ListDocuments2.vue +309 -309
  21. package/package.json +1 -1
  22. package/test/collections.js +2 -3
  23. package/test/collections.querying.js +18 -4
  24. package/test/test.js +4 -0
  25. package/util.js +19 -0
  26. package/modules/admin/dist/static/css/chunk-1059.220d4af5.css +0 -1
  27. package/modules/admin/dist/static/js/chunk-1059.e6aa7b61.js +0 -1
  28. package/modules/admin/dist/static/js/chunk-418a.e0987b3c.js +0 -1
  29. package/modules/admin/dist/static/js/chunk-787e.3ee96fce.js +0 -1
  30. package/modules/admin/dist/static/js/chunk-d0cd.1781b9dd.js +0 -1
@@ -1,309 +1,309 @@
1
- <template>
2
- <div class="px-3">
3
- <div class="d-flex align-items-center py-2">
4
- <h3 class="mb-0 text-capitalize">
5
- {{ collectionName }}
6
- </h3>
7
-
8
- <span class="mx-auto" />
9
-
10
- <el-checkbox
11
- v-model="isFiltersVisible"
12
- class="mb-0 mr-4"
13
- >
14
- Show Filters
15
- </el-checkbox>
16
-
17
- <el-pagination
18
- :page-size="pageSize"
19
- :total="count"
20
- :page-sizes="pageSizes"
21
- :current-page.sync="page"
22
- layout="sizes, prev, pager, next"
23
- @current-change="update()"
24
- @size-change="pageSize = $event, update()"
25
- />
26
- </div>
27
-
28
- <el-table
29
- :data="tableRows"
30
- element-loading-text="Loading"
31
- border
32
- fit
33
- highlight-current-row
34
- >
35
- <el-table-column v-for="(name, i) in listedProperties" :key="name" :label="name" align="center">
36
- <template slot-scope="scope">
37
- <div v-if="scope.$index === 0 && isFiltersVisible" class="text-left">
38
- <el-input
39
- v-model="searchFilters[name]"
40
- :placeholder="`Search by ${name}`"
41
- :type="getFieldType(name) === 'number' ? 'number' : 'text'"
42
- @change="update()"
43
- />
44
-
45
- <el-checkbox
46
- v-model="exactSearches[name]"
47
- :disabled="getFieldType(name) === 'number'"
48
- class="mt-3"
49
- @change="update()"
50
- >
51
- Exact match
52
- </el-checkbox>
53
- </div>
54
-
55
- <template v-else>
56
- <router-link v-if="i === 0 && collectionName !== 'requestlog'" :to="'/edit/'+collectionName+'/'+scope.row._id">
57
- {{ getPath(scope.row, name) }}
58
- </router-link>
59
- <router-link v-if="i === 0 && collectionName === 'requestlog'" :to="'/dev/viewrequest/'+scope.row._id">
60
- {{ getPath(scope.row, name) }}
61
- </router-link>
62
- <span v-if="i !== 0">{{ getPath(scope.row, name) }}</span>
63
- </template>
64
- </template>
65
- </el-table-column>
66
- </el-table>
67
-
68
- <div class="d-flex align-items-center my-3">
69
- <router-link v-if="showAddButton" :to="'/edit/' + collectionName + '/create'" class="mr-2">
70
- <button class="btn btn-primary">
71
- Add
72
- </button>
73
- </router-link>
74
-
75
- <button class="btn btn-secondary download-button mr-2" @click="downloadCSV()">
76
- Download
77
- </button>
78
-
79
- <button class="btn btn-secondary download-button" @click="downloadAllCSV()">
80
- Download All
81
- </button>
82
-
83
- <span class="mx-auto" />
84
-
85
- <el-pagination
86
- :page-size="pageSize"
87
- :total="count"
88
- :page-sizes="pageSizes"
89
- :current-page.sync="page"
90
- layout="sizes, prev, pager, next"
91
- @current-change="update()"
92
- @size-change="pageSize = $event, update()"
93
- />
94
- </div>
95
- </div>
96
- </template>
97
-
98
- <script>
99
- import request from '@/utils/request'
100
- import saveAs from 'file-saver'
101
- import objectPath from 'object-path'
102
-
103
- const pageSizes = [25, 50, 100, 500]
104
-
105
- export default {
106
- name: 'ListDocuments',
107
- props: {
108
- collectionName: {
109
- type: String,
110
- default: 'collection'
111
- },
112
- filter: {
113
- type: Object,
114
- default: () => {}
115
- },
116
- columns: {
117
- type: Array,
118
- default: () => undefined
119
- },
120
- showAddButton: {
121
- type: Boolean,
122
- default: true
123
- },
124
- showPaginateOnBottom: {
125
- type: Boolean,
126
- default: true
127
- },
128
- },
129
- data: () => ({
130
- start: 0,
131
- page: 1,
132
- count: 0,
133
- data: [],
134
- schema: {},
135
- exactSearches: {},
136
- searchFilters: {},
137
- listedProperties: [],
138
- pageSizes: pageSizes,
139
- pageSize: pageSizes[0],
140
- isFiltersVisible: true,
141
- }),
142
- computed: {
143
- tableRows() {
144
- if (!this.isFiltersVisible) {
145
- return this.data
146
- }
147
-
148
- return [
149
- // INFO: This empty row is used to display the search filters.
150
- {},
151
- ...this.data
152
- ]
153
- },
154
- appliedSearchFilters() {
155
- return Object.keys(this.searchFilters).reduce((filter, fieldName) => {
156
- const searchKeyword = this.searchFilters[fieldName]
157
-
158
- const fieldtype = this.getFieldType(fieldName)
159
-
160
- if (!searchKeyword || fieldtype === 'custom') {
161
- return filter
162
- }
163
-
164
- const queryBuilder = this.getQueryBuilder(fieldtype)
165
-
166
- return {
167
- ...filter,
168
- [fieldName]: queryBuilder({ fieldName, searchKeyword })
169
- }
170
- }, {})
171
- },
172
- allFilters() {
173
- const params = {
174
- page: this.page,
175
- limit: this.pageSize,
176
- orderby: '{"meta.created":-1}',
177
- ...this.filter,
178
- query: { ...this.appliedSearchFilters }
179
- }
180
-
181
- const columns = this.columns || (this.collection.admin && this.collection.admin.columns)
182
-
183
- if (columns) {
184
- params.fields = columns.reduce((map, field) => {
185
- map[field] = 1
186
- return map
187
- }, {})
188
- }
189
-
190
- return params
191
- },
192
- },
193
- watch: {
194
- collectionName: {
195
- handler: 'update'
196
- },
197
- '$route.params.collectionName': {
198
- handler: 'resetFilters'
199
- }
200
- },
201
- mounted() {
202
- this.update()
203
- },
204
- methods: {
205
- getPath(obj, path, defaultValue) {
206
- const result = String.prototype.split.call(path, /[,[\].]+?/)
207
- .filter(Boolean)
208
- .reduce((res, key) => (res !== null && res !== undefined) ? res[key] : res, obj)
209
- return (result === undefined || result === obj) ? defaultValue : result
210
- },
211
- async update() {
212
- if (this.$route.params.collectionName) {
213
- this.collectionName = this.$route.params.collectionName
214
- }
215
- // Avoid refetching schema info if name hasn't changed
216
- if (!this.collection || this.collection._id !== this.collectionName) {
217
- this.collection = (await request({ url: `/collection/${this.collectionName}` })).data
218
- this.schema = this.collection.schema
219
- }
220
- const params = { ...this.allFilters }
221
- const columns = this.columns || (this.collection.admin && this.collection.admin.columns)
222
- const info = (await request({ url: `/${this.collectionName}/`, params })).data
223
- this.count = info.itemsTotal
224
- this.data = this.applyCustomColumnFilter(info.data)
225
- this.listedProperties = columns || Object.keys(this.schema.properties)
226
- },
227
- applyCustomColumnFilter(tableData) {
228
- const customColumnSearches = Object.keys(this.searchFilters)
229
- .filter((fieldName) => this.getFieldType(fieldName) === 'custom')
230
-
231
- return tableData.filter((row) => {
232
- return customColumnSearches.every(fieldName => {
233
- const searchKeyword = this.searchFilters[fieldName]
234
-
235
- if (!searchKeyword) {
236
- return true
237
- }
238
-
239
- const stringified = row[fieldName].toString()
240
-
241
- if (this.exactSearches[fieldName]) {
242
- return stringified === searchKeyword
243
- } else {
244
- return stringified.toLowerCase().includes(searchKeyword.toLowerCase())
245
- }
246
- })
247
- })
248
- },
249
- downloadCSV(data = this.data) {
250
- const collection = this.collectionName
251
- let text = this.listedProperties.map((name) => '"' + name + '"').join(',') + '\n'
252
- if (data) {
253
- text += data.map((row) =>
254
- this.listedProperties.map((key) => '"' + String(objectPath.get(row, key) || '').replace(/"/g, '""') + '"')
255
- ).join('\n')
256
- console.log(text)
257
- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
258
- saveAs(blob, collection + '.csv')
259
- }
260
- },
261
- async downloadAllCSV() {
262
- let rows = []
263
- let page = 1
264
-
265
- while (this.count > rows.length) {
266
- const params = {
267
- ...this.allFilters,
268
- page: page,
269
- limit: 500,
270
- }
271
-
272
- const response = (await request({ url: `/${this.collectionName}/`, params })).data
273
- rows = [...response.data, ...rows]
274
- page += 1
275
- }
276
-
277
- this.downloadCSV(this.applyCustomColumnFilter(rows))
278
- },
279
- getFieldType(fieldName) {
280
- return (this.schema.properties[fieldName] &&
281
- this.schema.properties[fieldName].type) || 'custom'
282
- },
283
- getQueryBuilder(fieldType) {
284
- const queryBuilders = {
285
- 'string': this.getStringSearchQuery,
286
- 'number': (payload) => parseInt(payload.searchKeyword)
287
- }
288
-
289
- return queryBuilders[fieldType] || this.getStringSearchQuery
290
- },
291
- getStringSearchQuery({ fieldName, searchKeyword }) {
292
- const regexQuery = { '$regex': searchKeyword, '$options': 'i' }
293
- return this.exactSearches[fieldName] ? searchKeyword : regexQuery
294
- },
295
- resetFilters() {
296
- this.exactSearches = {}
297
- this.searchFilters = {}
298
- }
299
- }
300
- }
301
- </script>
302
-
303
- <style scoped>
304
- .el-table >>> th {
305
- background-color: #343a40 !important;
306
- border-color: #454d55;
307
- color: #fff;
308
- }
309
- </style>
1
+ <template>
2
+ <div class="px-3">
3
+ <div class="d-flex align-items-center py-2">
4
+ <h3 class="mb-0 text-capitalize">
5
+ {{ collectionName }}
6
+ </h3>
7
+
8
+ <span class="mx-auto" />
9
+
10
+ <el-checkbox
11
+ v-model="isFiltersVisible"
12
+ class="mb-0 mr-4"
13
+ >
14
+ Show Filters
15
+ </el-checkbox>
16
+
17
+ <el-pagination
18
+ :page-size="pageSize"
19
+ :total="count"
20
+ :page-sizes="pageSizes"
21
+ :current-page.sync="page"
22
+ layout="sizes, prev, pager, next"
23
+ @current-change="update()"
24
+ @size-change="pageSize = $event, update()"
25
+ />
26
+ </div>
27
+
28
+ <el-table
29
+ :data="tableRows"
30
+ element-loading-text="Loading"
31
+ border
32
+ fit
33
+ highlight-current-row
34
+ >
35
+ <el-table-column v-for="(name, i) in listedProperties" :key="name" :label="name" align="center">
36
+ <template slot-scope="scope">
37
+ <div v-if="scope.$index === 0 && isFiltersVisible" class="text-left">
38
+ <el-input
39
+ v-model="searchFilters[name]"
40
+ :placeholder="`Search by ${name}`"
41
+ :type="getFieldType(name) === 'number' ? 'number' : 'text'"
42
+ @change="update()"
43
+ />
44
+
45
+ <el-checkbox
46
+ v-model="exactSearches[name]"
47
+ :disabled="getFieldType(name) === 'number'"
48
+ class="mt-3"
49
+ @change="update()"
50
+ >
51
+ Exact match
52
+ </el-checkbox>
53
+ </div>
54
+
55
+ <template v-else>
56
+ <router-link v-if="i === 0 && collectionName !== 'requestlog'" :to="'/edit/'+collectionName+'/'+scope.row._id">
57
+ {{ getPath(scope.row, name) }}
58
+ </router-link>
59
+ <router-link v-if="i === 0 && collectionName === 'requestlog'" :to="'/dev/viewrequest/'+scope.row._id">
60
+ {{ getPath(scope.row, name) }}
61
+ </router-link>
62
+ <span v-if="i !== 0">{{ getPath(scope.row, name) }}</span>
63
+ </template>
64
+ </template>
65
+ </el-table-column>
66
+ </el-table>
67
+
68
+ <div class="d-flex align-items-center my-3">
69
+ <router-link v-if="showAddButton" :to="'/edit/' + collectionName + '/create'" class="mr-2">
70
+ <button class="btn btn-primary">
71
+ Add
72
+ </button>
73
+ </router-link>
74
+
75
+ <button class="btn btn-secondary download-button mr-2" @click="downloadCSV()">
76
+ Download
77
+ </button>
78
+
79
+ <button class="btn btn-secondary download-button" @click="downloadAllCSV()">
80
+ Download All
81
+ </button>
82
+
83
+ <span class="mx-auto" />
84
+
85
+ <el-pagination
86
+ :page-size="pageSize"
87
+ :total="count"
88
+ :page-sizes="pageSizes"
89
+ :current-page.sync="page"
90
+ layout="sizes, prev, pager, next"
91
+ @current-change="update()"
92
+ @size-change="pageSize = $event, update()"
93
+ />
94
+ </div>
95
+ </div>
96
+ </template>
97
+
98
+ <script>
99
+ import request from '@/utils/request'
100
+ import saveAs from 'file-saver'
101
+ import objectPath from 'object-path'
102
+
103
+ const pageSizes = [25, 50, 100, 500]
104
+
105
+ export default {
106
+ name: 'ListDocuments',
107
+ props: {
108
+ collectionName: {
109
+ type: String,
110
+ default: 'collection'
111
+ },
112
+ filter: {
113
+ type: Object,
114
+ default: () => {}
115
+ },
116
+ columns: {
117
+ type: Array,
118
+ default: () => undefined
119
+ },
120
+ showAddButton: {
121
+ type: Boolean,
122
+ default: true
123
+ },
124
+ showPaginateOnBottom: {
125
+ type: Boolean,
126
+ default: true
127
+ },
128
+ },
129
+ data: () => ({
130
+ start: 0,
131
+ page: 1,
132
+ count: 0,
133
+ data: [],
134
+ schema: {},
135
+ exactSearches: {},
136
+ searchFilters: {},
137
+ listedProperties: [],
138
+ pageSizes: pageSizes,
139
+ pageSize: pageSizes[0],
140
+ isFiltersVisible: true,
141
+ }),
142
+ computed: {
143
+ tableRows() {
144
+ if (!this.isFiltersVisible) {
145
+ return this.data
146
+ }
147
+
148
+ return [
149
+ // INFO: This empty row is used to display the search filters.
150
+ {},
151
+ ...this.data
152
+ ]
153
+ },
154
+ appliedSearchFilters() {
155
+ return Object.keys(this.searchFilters).reduce((filter, fieldName) => {
156
+ const searchKeyword = this.searchFilters[fieldName]
157
+
158
+ const fieldtype = this.getFieldType(fieldName)
159
+
160
+ if (!searchKeyword || fieldtype === 'custom') {
161
+ return filter
162
+ }
163
+
164
+ const queryBuilder = this.getQueryBuilder(fieldtype)
165
+
166
+ return {
167
+ ...filter,
168
+ [fieldName]: queryBuilder({ fieldName, searchKeyword })
169
+ }
170
+ }, {})
171
+ },
172
+ allFilters() {
173
+ const params = {
174
+ page: this.page,
175
+ limit: this.pageSize,
176
+ orderby: '{"meta.created":-1}',
177
+ ...this.filter,
178
+ query: { ...this.appliedSearchFilters }
179
+ }
180
+
181
+ const columns = this.columns || (this.collection.admin && this.collection.admin.columns)
182
+
183
+ if (columns) {
184
+ params.fields = columns.reduce((map, field) => {
185
+ map[field] = 1
186
+ return map
187
+ }, {})
188
+ }
189
+
190
+ return params
191
+ },
192
+ },
193
+ watch: {
194
+ collectionName: {
195
+ handler: 'update'
196
+ },
197
+ '$route.params.collectionName': {
198
+ handler: 'resetFilters'
199
+ }
200
+ },
201
+ mounted() {
202
+ this.update()
203
+ },
204
+ methods: {
205
+ getPath(obj, path, defaultValue) {
206
+ const result = String.prototype.split.call(path, /[,[\].]+?/)
207
+ .filter(Boolean)
208
+ .reduce((res, key) => (res !== null && res !== undefined) ? res[key] : res, obj)
209
+ return (result === undefined || result === obj) ? defaultValue : result
210
+ },
211
+ async update() {
212
+ if (this.$route.params.collectionName) {
213
+ this.collectionName = this.$route.params.collectionName
214
+ }
215
+ // Avoid refetching schema info if name hasn't changed
216
+ if (!this.collection || this.collection._id !== this.collectionName) {
217
+ this.collection = (await request({ url: `/collection/${this.collectionName}` })).data
218
+ this.schema = this.collection.schema
219
+ }
220
+ const params = { ...this.allFilters }
221
+ const columns = this.columns || (this.collection.admin && this.collection.admin.columns)
222
+ const info = (await request({ url: `/${this.collectionName}/`, params })).data
223
+ this.count = info.itemsTotal
224
+ this.data = this.applyCustomColumnFilter(info.data)
225
+ this.listedProperties = columns || Object.keys(this.schema.properties)
226
+ },
227
+ applyCustomColumnFilter(tableData) {
228
+ const customColumnSearches = Object.keys(this.searchFilters)
229
+ .filter((fieldName) => this.getFieldType(fieldName) === 'custom')
230
+
231
+ return tableData.filter((row) => {
232
+ return customColumnSearches.every(fieldName => {
233
+ const searchKeyword = this.searchFilters[fieldName]
234
+
235
+ if (!searchKeyword) {
236
+ return true
237
+ }
238
+
239
+ const stringified = row[fieldName].toString()
240
+
241
+ if (this.exactSearches[fieldName]) {
242
+ return stringified === searchKeyword
243
+ } else {
244
+ return stringified.toLowerCase().includes(searchKeyword.toLowerCase())
245
+ }
246
+ })
247
+ })
248
+ },
249
+ downloadCSV(data = this.data) {
250
+ const collection = this.collectionName
251
+ let text = this.listedProperties.map((name) => '"' + name + '"').join(',') + '\n'
252
+ if (data) {
253
+ text += data.map((row) =>
254
+ this.listedProperties.map((key) => '"' + String(objectPath.get(row, key) || '').replace(/"/g, '""') + '"')
255
+ ).join('\n')
256
+ console.log(text)
257
+ const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
258
+ saveAs(blob, collection + '.csv')
259
+ }
260
+ },
261
+ async downloadAllCSV() {
262
+ let rows = []
263
+ let page = 1
264
+
265
+ while (this.count > rows.length) {
266
+ const params = {
267
+ ...this.allFilters,
268
+ page: page,
269
+ limit: 500,
270
+ }
271
+
272
+ const response = (await request({ url: `/${this.collectionName}/`, params })).data
273
+ rows = [...response.data, ...rows]
274
+ page += 1
275
+ }
276
+
277
+ this.downloadCSV(this.applyCustomColumnFilter(rows))
278
+ },
279
+ getFieldType(fieldName) {
280
+ return (this.schema.properties[fieldName] &&
281
+ this.schema.properties[fieldName].type) || 'custom'
282
+ },
283
+ getQueryBuilder(fieldType) {
284
+ const queryBuilders = {
285
+ 'string': this.getStringSearchQuery,
286
+ 'number': (payload) => parseInt(payload.searchKeyword)
287
+ }
288
+
289
+ return queryBuilders[fieldType] || this.getStringSearchQuery
290
+ },
291
+ getStringSearchQuery({ fieldName, searchKeyword }) {
292
+ const regexQuery = { '$regex': searchKeyword, '$options': 'i' }
293
+ return this.exactSearches[fieldName] ? searchKeyword : regexQuery
294
+ },
295
+ resetFilters() {
296
+ this.exactSearches = {}
297
+ this.searchFilters = {}
298
+ }
299
+ }
300
+ }
301
+ </script>
302
+
303
+ <style scoped>
304
+ .el-table >>> th {
305
+ background-color: #343a40 !important;
306
+ border-color: #454d55;
307
+ color: #fff;
308
+ }
309
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expressa",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "API framework using JSON schemas",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/thomas4019/expressa",
@@ -159,11 +159,10 @@ describe('basic collections', function () {
159
159
 
160
160
  it('fail to read paged without permission', async function () {
161
161
  const token = await testutils.getUserWithPermissions(api)
162
- const res = await request(app)
162
+ await request(app)
163
163
  .get('/testdoc/?page=1')
164
164
  .set('x-access-token', token)
165
- .expect(200)
166
- expect(res.body.data.length).to.equal(0)
165
+ .expect(401)
167
166
  })
168
167
 
169
168
  it('read a specific doc', async function () {
@@ -102,11 +102,10 @@ describe('querying collections', function () {
102
102
  expect(res.body).to.have.lengthOf(1)
103
103
  })
104
104
 
105
- it('returns no documents without permission', async function () {
106
- const res = await request(app)
105
+ it('errors without permission', async function () {
106
+ await request(app)
107
107
  .get('/testdoc')
108
- .expect(200)
109
- expect(res.body).to.have.lengthOf(0)
108
+ .expect(401)
110
109
  })
111
110
 
112
111
  it('page 0 returns an error', async function () {
@@ -147,6 +146,21 @@ describe('querying collections', function () {
147
146
  expect(res.body.data[0].title).to.equal('doc3')
148
147
  })
149
148
 
149
+ it('pagemetadisable url parameter strips additional page detail', async function () {
150
+ const res = await request(app)
151
+ .get('/testdoc?limit=2&page=2&pagemetadisable=1')
152
+ .set('x-access-token', token)
153
+ .expect(200)
154
+ expect(res.body.itemsTotal).to.equal(undefined)
155
+ expect(res.body.itemsPerPage).to.equal(2)
156
+ expect(res.body.pages).to.equal(undefined)
157
+ expect(res.body.pagePrev).to.equal(1)
158
+ expect(res.body.pageNext).to.equal(undefined)
159
+ expect(res.body.data).to.have.lengthOf(1)
160
+
161
+ expect(res.body.data[0].title).to.equal('doc3')
162
+ })
163
+
150
164
  it('page 3 is empty', async function () {
151
165
  const res = await request(app)
152
166
  .get('/testdoc?limit=2&page=3')