@terreno/api 0.0.1
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/LICENSE +202 -0
- package/README.md +170 -0
- package/biome.jsonc +22 -0
- package/bunfig.toml +4 -0
- package/dist/api.d.ts +227 -0
- package/dist/api.js +1024 -0
- package/dist/api.test.d.ts +1 -0
- package/dist/api.test.js +2143 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +512 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +778 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.js +216 -0
- package/dist/example.d.ts +1 -0
- package/dist/example.js +118 -0
- package/dist/expressServer.d.ts +35 -0
- package/dist/expressServer.js +436 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +30 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.js +249 -0
- package/dist/middleware.d.ts +10 -0
- package/dist/middleware.js +52 -0
- package/dist/notifiers/googleChatNotifier.d.ts +5 -0
- package/dist/notifiers/googleChatNotifier.js +130 -0
- package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
- package/dist/notifiers/googleChatNotifier.test.js +260 -0
- package/dist/notifiers/index.d.ts +3 -0
- package/dist/notifiers/index.js +19 -0
- package/dist/notifiers/slackNotifier.d.ts +5 -0
- package/dist/notifiers/slackNotifier.js +130 -0
- package/dist/notifiers/slackNotifier.test.d.ts +1 -0
- package/dist/notifiers/slackNotifier.test.js +259 -0
- package/dist/notifiers/zoomNotifier.d.ts +34 -0
- package/dist/notifiers/zoomNotifier.js +181 -0
- package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
- package/dist/notifiers/zoomNotifier.test.js +370 -0
- package/dist/openApi.d.ts +60 -0
- package/dist/openApi.js +441 -0
- package/dist/openApi.test.d.ts +1 -0
- package/dist/openApi.test.js +445 -0
- package/dist/openApiBuilder.d.ts +419 -0
- package/dist/openApiBuilder.js +424 -0
- package/dist/openApiBuilder.test.d.ts +1 -0
- package/dist/openApiBuilder.test.js +509 -0
- package/dist/openApiEtag.d.ts +7 -0
- package/dist/openApiEtag.js +38 -0
- package/dist/permissions.d.ts +26 -0
- package/dist/permissions.js +331 -0
- package/dist/permissions.test.d.ts +1 -0
- package/dist/permissions.test.js +413 -0
- package/dist/plugins.d.ts +67 -0
- package/dist/plugins.js +315 -0
- package/dist/plugins.test.d.ts +1 -0
- package/dist/plugins.test.js +639 -0
- package/dist/populate.d.ts +14 -0
- package/dist/populate.js +315 -0
- package/dist/populate.test.d.ts +1 -0
- package/dist/populate.test.js +133 -0
- package/dist/response.d.ts +0 -0
- package/dist/response.js +1 -0
- package/dist/tests/bunSetup.d.ts +1 -0
- package/dist/tests/bunSetup.js +297 -0
- package/dist/tests/index.d.ts +1 -0
- package/dist/tests/index.js +17 -0
- package/dist/tests.d.ts +99 -0
- package/dist/tests.js +273 -0
- package/dist/transformers.d.ts +25 -0
- package/dist/transformers.js +217 -0
- package/dist/transformers.test.d.ts +1 -0
- package/dist/transformers.test.js +370 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +143 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +14 -0
- package/index.ts +1 -0
- package/package.json +88 -0
- package/src/__snapshots__/openApi.test.ts.snap +4814 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
- package/src/api.test.ts +1661 -0
- package/src/api.ts +1036 -0
- package/src/auth.test.ts +550 -0
- package/src/auth.ts +408 -0
- package/src/errors.ts +225 -0
- package/src/example.ts +99 -0
- package/src/express.d.ts +5 -0
- package/src/expressServer.ts +387 -0
- package/src/index.ts +14 -0
- package/src/logger.ts +190 -0
- package/src/middleware.ts +18 -0
- package/src/notifiers/googleChatNotifier.test.ts +114 -0
- package/src/notifiers/googleChatNotifier.ts +47 -0
- package/src/notifiers/index.ts +3 -0
- package/src/notifiers/slackNotifier.test.ts +113 -0
- package/src/notifiers/slackNotifier.ts +55 -0
- package/src/notifiers/zoomNotifier.test.ts +207 -0
- package/src/notifiers/zoomNotifier.ts +111 -0
- package/src/openApi.test.ts +331 -0
- package/src/openApi.ts +494 -0
- package/src/openApiBuilder.test.ts +442 -0
- package/src/openApiBuilder.ts +636 -0
- package/src/openApiEtag.ts +40 -0
- package/src/permissions.test.ts +219 -0
- package/src/permissions.ts +228 -0
- package/src/plugins.test.ts +390 -0
- package/src/plugins.ts +289 -0
- package/src/populate.test.ts +65 -0
- package/src/populate.ts +258 -0
- package/src/response.ts +0 -0
- package/src/tests/bunSetup.ts +234 -0
- package/src/tests/index.ts +1 -0
- package/src/tests.ts +218 -0
- package/src/transformers.test.ts +202 -0
- package/src/transformers.ts +170 -0
- package/src/utils.test.ts +14 -0
- package/src/utils.ts +47 -0
- package/tsconfig.json +60 -0
- package/types.d.ts +17 -0
package/src/api.test.ts
ADDED
|
@@ -0,0 +1,1661 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import * as Sentry from "@sentry/node";
|
|
3
|
+
import type express from "express";
|
|
4
|
+
import sortBy from "lodash/sortBy";
|
|
5
|
+
import type mongoose from "mongoose";
|
|
6
|
+
import qs from "qs";
|
|
7
|
+
import supertest from "supertest";
|
|
8
|
+
import type TestAgent from "supertest/lib/agent";
|
|
9
|
+
|
|
10
|
+
import {modelRouter} from "./api";
|
|
11
|
+
import {addAuthRoutes, setupAuth} from "./auth";
|
|
12
|
+
import {APIError} from "./errors";
|
|
13
|
+
import {logRequests} from "./expressServer";
|
|
14
|
+
import {Permissions} from "./permissions";
|
|
15
|
+
import {
|
|
16
|
+
authAsUser,
|
|
17
|
+
type Food,
|
|
18
|
+
FoodModel,
|
|
19
|
+
getBaseServer,
|
|
20
|
+
type StaffUser,
|
|
21
|
+
StaffUserModel,
|
|
22
|
+
type SuperUser,
|
|
23
|
+
SuperUserModel,
|
|
24
|
+
setupDb,
|
|
25
|
+
UserModel,
|
|
26
|
+
} from "./tests";
|
|
27
|
+
|
|
28
|
+
describe("@terreno/api", () => {
|
|
29
|
+
let server: TestAgent;
|
|
30
|
+
let app: express.Application;
|
|
31
|
+
|
|
32
|
+
describe("pre and post hooks", () => {
|
|
33
|
+
let agent: TestAgent;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
await setupDb();
|
|
37
|
+
app = getBaseServer();
|
|
38
|
+
setupAuth(app, UserModel as any);
|
|
39
|
+
addAuthRoutes(app, UserModel as any);
|
|
40
|
+
agent = await authAsUser(app, "notAdmin");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("pre hooks change data", async () => {
|
|
44
|
+
let deleteCalled = false;
|
|
45
|
+
app.use(
|
|
46
|
+
"/food",
|
|
47
|
+
modelRouter(FoodModel, {
|
|
48
|
+
allowAnonymous: true,
|
|
49
|
+
permissions: {
|
|
50
|
+
create: [Permissions.IsAny],
|
|
51
|
+
delete: [Permissions.IsAny],
|
|
52
|
+
list: [Permissions.IsAny],
|
|
53
|
+
read: [Permissions.IsAny],
|
|
54
|
+
update: [Permissions.IsAny],
|
|
55
|
+
},
|
|
56
|
+
preCreate: (data: any) => {
|
|
57
|
+
data.calories = 14;
|
|
58
|
+
return data;
|
|
59
|
+
},
|
|
60
|
+
preDelete: (data: any) => {
|
|
61
|
+
deleteCalled = true;
|
|
62
|
+
return data;
|
|
63
|
+
},
|
|
64
|
+
preUpdate: (data: any) => {
|
|
65
|
+
data.calories = 15;
|
|
66
|
+
return data;
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
server = supertest(app);
|
|
71
|
+
|
|
72
|
+
let res = await server
|
|
73
|
+
.post("/food")
|
|
74
|
+
.send({
|
|
75
|
+
calories: 15,
|
|
76
|
+
name: "Broccoli",
|
|
77
|
+
})
|
|
78
|
+
.expect(201);
|
|
79
|
+
const broccoli = await FoodModel.findById(res.body.data._id);
|
|
80
|
+
if (!broccoli) {
|
|
81
|
+
throw new Error("Broccoli was not created");
|
|
82
|
+
}
|
|
83
|
+
expect(broccoli.name).toBe("Broccoli");
|
|
84
|
+
// Overwritten by the pre create hook
|
|
85
|
+
expect(broccoli.calories).toBe(14);
|
|
86
|
+
|
|
87
|
+
res = await server
|
|
88
|
+
.patch(`/food/${broccoli._id}`)
|
|
89
|
+
.send({
|
|
90
|
+
name: "Broccoli2",
|
|
91
|
+
})
|
|
92
|
+
.expect(200);
|
|
93
|
+
expect(res.body.data.name).toBe("Broccoli2");
|
|
94
|
+
// Updated by the pre update hook
|
|
95
|
+
expect(res.body.data.calories).toBe(15);
|
|
96
|
+
|
|
97
|
+
await agent.delete(`/food/${broccoli._id}`).expect(204);
|
|
98
|
+
expect(deleteCalled).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("pre hooks return null", async () => {
|
|
102
|
+
const notAdmin = await UserModel.findOne({
|
|
103
|
+
email: "notAdmin@example.com",
|
|
104
|
+
});
|
|
105
|
+
const spinach = await FoodModel.create({
|
|
106
|
+
calories: 1,
|
|
107
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
108
|
+
hidden: false,
|
|
109
|
+
name: "Spinach",
|
|
110
|
+
ownerId: (notAdmin as any)._id,
|
|
111
|
+
source: {
|
|
112
|
+
name: "Brand",
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
app.use(
|
|
117
|
+
"/food",
|
|
118
|
+
modelRouter(FoodModel, {
|
|
119
|
+
allowAnonymous: true,
|
|
120
|
+
permissions: {
|
|
121
|
+
create: [Permissions.IsAny],
|
|
122
|
+
delete: [Permissions.IsAny],
|
|
123
|
+
list: [Permissions.IsAny],
|
|
124
|
+
read: [Permissions.IsAny],
|
|
125
|
+
update: [Permissions.IsAny],
|
|
126
|
+
},
|
|
127
|
+
preCreate: () => null,
|
|
128
|
+
preDelete: () => null,
|
|
129
|
+
preUpdate: () => null,
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
server = supertest(app);
|
|
133
|
+
|
|
134
|
+
const res = await server
|
|
135
|
+
.post("/food")
|
|
136
|
+
.send({
|
|
137
|
+
calories: 15,
|
|
138
|
+
name: "Broccoli",
|
|
139
|
+
})
|
|
140
|
+
.expect(403);
|
|
141
|
+
const broccoli = await FoodModel.findById(res.body._id);
|
|
142
|
+
expect(broccoli).toBeNull();
|
|
143
|
+
|
|
144
|
+
await server
|
|
145
|
+
.patch(`/food/${spinach._id}`)
|
|
146
|
+
.send({
|
|
147
|
+
name: "Broccoli",
|
|
148
|
+
})
|
|
149
|
+
.expect(403);
|
|
150
|
+
await server.delete(`/food/${spinach._id}`).expect(403);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("post hooks succeed", async () => {
|
|
154
|
+
let deleteCalled = false;
|
|
155
|
+
app.use(
|
|
156
|
+
"/food",
|
|
157
|
+
modelRouter(FoodModel as any, {
|
|
158
|
+
allowAnonymous: true,
|
|
159
|
+
permissions: {
|
|
160
|
+
create: [Permissions.IsAny],
|
|
161
|
+
delete: [Permissions.IsAny],
|
|
162
|
+
list: [Permissions.IsAny],
|
|
163
|
+
read: [Permissions.IsAny],
|
|
164
|
+
update: [Permissions.IsAny],
|
|
165
|
+
},
|
|
166
|
+
postCreate: async (data: any) => {
|
|
167
|
+
data.calories = 14;
|
|
168
|
+
await data.save();
|
|
169
|
+
return data;
|
|
170
|
+
},
|
|
171
|
+
postDelete: (data: any) => {
|
|
172
|
+
deleteCalled = true;
|
|
173
|
+
return data;
|
|
174
|
+
},
|
|
175
|
+
postUpdate: async (data: any) => {
|
|
176
|
+
data.calories = 15;
|
|
177
|
+
await data.save();
|
|
178
|
+
return data;
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
server = supertest(app);
|
|
183
|
+
|
|
184
|
+
let res = await server
|
|
185
|
+
.post("/food")
|
|
186
|
+
.send({
|
|
187
|
+
calories: 15,
|
|
188
|
+
name: "Broccoli",
|
|
189
|
+
})
|
|
190
|
+
.expect(201);
|
|
191
|
+
let broccoli = await FoodModel.findById(res.body.data._id);
|
|
192
|
+
if (!broccoli) {
|
|
193
|
+
throw new Error("Broccoli was not created");
|
|
194
|
+
}
|
|
195
|
+
expect(broccoli.name).toBe("Broccoli");
|
|
196
|
+
// Overwritten by the pre create hook
|
|
197
|
+
expect(broccoli.calories).toBe(14);
|
|
198
|
+
|
|
199
|
+
res = await server
|
|
200
|
+
.patch(`/food/${broccoli._id}`)
|
|
201
|
+
.send({
|
|
202
|
+
name: "Broccoli2",
|
|
203
|
+
})
|
|
204
|
+
.expect(200);
|
|
205
|
+
broccoli = await FoodModel.findById(res.body.data._id);
|
|
206
|
+
if (!broccoli) {
|
|
207
|
+
throw new Error("Broccoli was not update");
|
|
208
|
+
}
|
|
209
|
+
expect(broccoli.name).toBe("Broccoli2");
|
|
210
|
+
// Updated by the post update hook
|
|
211
|
+
expect(broccoli.calories).toBe(15);
|
|
212
|
+
|
|
213
|
+
await agent.delete(`/food/${broccoli._id}`).expect(204);
|
|
214
|
+
expect(deleteCalled).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("preCreate hook preserves disableExternalErrorTracking on APIError", async () => {
|
|
218
|
+
app.use(
|
|
219
|
+
"/food",
|
|
220
|
+
modelRouter(FoodModel, {
|
|
221
|
+
allowAnonymous: true,
|
|
222
|
+
permissions: {
|
|
223
|
+
create: [Permissions.IsAny],
|
|
224
|
+
delete: [Permissions.IsAny],
|
|
225
|
+
list: [Permissions.IsAny],
|
|
226
|
+
read: [Permissions.IsAny],
|
|
227
|
+
update: [Permissions.IsAny],
|
|
228
|
+
},
|
|
229
|
+
preCreate: () => {
|
|
230
|
+
throw new APIError({
|
|
231
|
+
disableExternalErrorTracking: true,
|
|
232
|
+
status: 400,
|
|
233
|
+
title: "Custom preCreate error",
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
server = supertest(app);
|
|
239
|
+
|
|
240
|
+
const res = await server
|
|
241
|
+
.post("/food")
|
|
242
|
+
.send({
|
|
243
|
+
calories: 15,
|
|
244
|
+
name: "Broccoli",
|
|
245
|
+
})
|
|
246
|
+
.expect(400);
|
|
247
|
+
|
|
248
|
+
expect(res.body.title).toBe("Custom preCreate error");
|
|
249
|
+
expect(res.body.disableExternalErrorTracking).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("preCreate hook preserves disableExternalErrorTracking on non-APIError", async () => {
|
|
253
|
+
app.use(
|
|
254
|
+
"/food",
|
|
255
|
+
modelRouter(FoodModel, {
|
|
256
|
+
allowAnonymous: true,
|
|
257
|
+
permissions: {
|
|
258
|
+
create: [Permissions.IsAny],
|
|
259
|
+
delete: [Permissions.IsAny],
|
|
260
|
+
list: [Permissions.IsAny],
|
|
261
|
+
read: [Permissions.IsAny],
|
|
262
|
+
update: [Permissions.IsAny],
|
|
263
|
+
},
|
|
264
|
+
preCreate: () => {
|
|
265
|
+
const error: any = new Error("Some custom error");
|
|
266
|
+
error.disableExternalErrorTracking = true;
|
|
267
|
+
throw error;
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
server = supertest(app);
|
|
272
|
+
|
|
273
|
+
const res = await server
|
|
274
|
+
.post("/food")
|
|
275
|
+
.send({
|
|
276
|
+
calories: 15,
|
|
277
|
+
name: "Broccoli",
|
|
278
|
+
})
|
|
279
|
+
.expect(400);
|
|
280
|
+
|
|
281
|
+
expect(res.body.title).toContain("preCreate hook error");
|
|
282
|
+
expect(res.body.disableExternalErrorTracking).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("preUpdate hook preserves disableExternalErrorTracking on APIError", async () => {
|
|
286
|
+
const notAdmin = await UserModel.findOne({
|
|
287
|
+
email: "notAdmin@example.com",
|
|
288
|
+
});
|
|
289
|
+
const spinach = await FoodModel.create({
|
|
290
|
+
calories: 1,
|
|
291
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
292
|
+
hidden: false,
|
|
293
|
+
name: "Spinach",
|
|
294
|
+
ownerId: (notAdmin as any)._id,
|
|
295
|
+
source: {
|
|
296
|
+
name: "Brand",
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
app.use(
|
|
301
|
+
"/food",
|
|
302
|
+
modelRouter(FoodModel, {
|
|
303
|
+
allowAnonymous: true,
|
|
304
|
+
permissions: {
|
|
305
|
+
create: [Permissions.IsAny],
|
|
306
|
+
delete: [Permissions.IsAny],
|
|
307
|
+
list: [Permissions.IsAny],
|
|
308
|
+
read: [Permissions.IsAny],
|
|
309
|
+
update: [Permissions.IsAny],
|
|
310
|
+
},
|
|
311
|
+
preUpdate: () => {
|
|
312
|
+
throw new APIError({
|
|
313
|
+
disableExternalErrorTracking: true,
|
|
314
|
+
status: 400,
|
|
315
|
+
title: "Custom preUpdate error",
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
);
|
|
320
|
+
server = supertest(app);
|
|
321
|
+
|
|
322
|
+
const res = await server
|
|
323
|
+
.patch(`/food/${spinach._id}`)
|
|
324
|
+
.send({
|
|
325
|
+
name: "Broccoli",
|
|
326
|
+
})
|
|
327
|
+
.expect(400);
|
|
328
|
+
|
|
329
|
+
expect(res.body.title).toBe("Custom preUpdate error");
|
|
330
|
+
expect(res.body.disableExternalErrorTracking).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("preUpdate hook preserves disableExternalErrorTracking on non-APIError", async () => {
|
|
334
|
+
const notAdmin = await UserModel.findOne({
|
|
335
|
+
email: "notAdmin@example.com",
|
|
336
|
+
});
|
|
337
|
+
const spinach = await FoodModel.create({
|
|
338
|
+
calories: 1,
|
|
339
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
340
|
+
hidden: false,
|
|
341
|
+
name: "Spinach",
|
|
342
|
+
ownerId: (notAdmin as any)._id,
|
|
343
|
+
source: {
|
|
344
|
+
name: "Brand",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
app.use(
|
|
349
|
+
"/food",
|
|
350
|
+
modelRouter(FoodModel, {
|
|
351
|
+
allowAnonymous: true,
|
|
352
|
+
permissions: {
|
|
353
|
+
create: [Permissions.IsAny],
|
|
354
|
+
delete: [Permissions.IsAny],
|
|
355
|
+
list: [Permissions.IsAny],
|
|
356
|
+
read: [Permissions.IsAny],
|
|
357
|
+
update: [Permissions.IsAny],
|
|
358
|
+
},
|
|
359
|
+
preUpdate: () => {
|
|
360
|
+
const error: any = new Error("Some custom error");
|
|
361
|
+
error.disableExternalErrorTracking = true;
|
|
362
|
+
throw error;
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
);
|
|
366
|
+
server = supertest(app);
|
|
367
|
+
|
|
368
|
+
const res = await server
|
|
369
|
+
.patch(`/food/${spinach._id}`)
|
|
370
|
+
.send({
|
|
371
|
+
name: "Broccoli",
|
|
372
|
+
})
|
|
373
|
+
.expect(400);
|
|
374
|
+
|
|
375
|
+
expect(res.body.title).toContain("preUpdate hook error");
|
|
376
|
+
expect(res.body.disableExternalErrorTracking).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("preDelete hook preserves disableExternalErrorTracking on non-APIError", async () => {
|
|
380
|
+
const notAdmin = await UserModel.findOne({
|
|
381
|
+
email: "notAdmin@example.com",
|
|
382
|
+
});
|
|
383
|
+
const spinach = await FoodModel.create({
|
|
384
|
+
calories: 1,
|
|
385
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
386
|
+
hidden: false,
|
|
387
|
+
name: "Spinach",
|
|
388
|
+
ownerId: (notAdmin as any)._id,
|
|
389
|
+
source: {
|
|
390
|
+
name: "Brand",
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
app.use(
|
|
395
|
+
"/food",
|
|
396
|
+
modelRouter(FoodModel, {
|
|
397
|
+
allowAnonymous: true,
|
|
398
|
+
permissions: {
|
|
399
|
+
create: [Permissions.IsAny],
|
|
400
|
+
delete: [Permissions.IsAny],
|
|
401
|
+
list: [Permissions.IsAny],
|
|
402
|
+
read: [Permissions.IsAny],
|
|
403
|
+
update: [Permissions.IsAny],
|
|
404
|
+
},
|
|
405
|
+
preDelete: () => {
|
|
406
|
+
const error: any = new Error("Some custom error");
|
|
407
|
+
error.disableExternalErrorTracking = true;
|
|
408
|
+
throw error;
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
);
|
|
412
|
+
server = supertest(app);
|
|
413
|
+
|
|
414
|
+
const res = await agent.delete(`/food/${spinach._id}`).expect(403);
|
|
415
|
+
|
|
416
|
+
expect(res.body.title).toContain("preDelete hook error");
|
|
417
|
+
expect(res.body.disableExternalErrorTracking).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("model array operations", () => {
|
|
422
|
+
let admin: any;
|
|
423
|
+
let spinach: Food;
|
|
424
|
+
let apple: Food;
|
|
425
|
+
let agent: TestAgent;
|
|
426
|
+
|
|
427
|
+
beforeEach(async () => {
|
|
428
|
+
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
429
|
+
|
|
430
|
+
[admin] = await setupDb();
|
|
431
|
+
|
|
432
|
+
[spinach, apple] = await Promise.all([
|
|
433
|
+
FoodModel.create({
|
|
434
|
+
calories: 1,
|
|
435
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
436
|
+
hidden: false,
|
|
437
|
+
name: "Spinach",
|
|
438
|
+
ownerId: admin._id,
|
|
439
|
+
source: {
|
|
440
|
+
name: "Brand",
|
|
441
|
+
},
|
|
442
|
+
}),
|
|
443
|
+
FoodModel.create({
|
|
444
|
+
calories: 100,
|
|
445
|
+
categories: [
|
|
446
|
+
{
|
|
447
|
+
name: "Fruit",
|
|
448
|
+
show: true,
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "Popular",
|
|
452
|
+
show: false,
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
created: new Date("2021-12-03T00:00:30.000Z"),
|
|
456
|
+
hidden: false,
|
|
457
|
+
name: "Apple",
|
|
458
|
+
ownerId: admin._id,
|
|
459
|
+
tags: ["healthy", "cheap"],
|
|
460
|
+
}),
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
app = getBaseServer();
|
|
464
|
+
setupAuth(app, UserModel as any);
|
|
465
|
+
addAuthRoutes(app, UserModel as any);
|
|
466
|
+
app.use(
|
|
467
|
+
"/food",
|
|
468
|
+
modelRouter(FoodModel, {
|
|
469
|
+
allowAnonymous: true,
|
|
470
|
+
permissions: {
|
|
471
|
+
create: [Permissions.IsAdmin],
|
|
472
|
+
delete: [Permissions.IsAdmin],
|
|
473
|
+
list: [Permissions.IsAdmin],
|
|
474
|
+
read: [Permissions.IsAdmin],
|
|
475
|
+
update: [Permissions.IsAdmin],
|
|
476
|
+
},
|
|
477
|
+
queryFields: ["hidden", "calories", "created", "source.name"],
|
|
478
|
+
sort: {created: "descending"},
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
server = supertest(app);
|
|
482
|
+
agent = await authAsUser(app, "admin");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("add array sub-schema item", async () => {
|
|
486
|
+
// Incorrect way, should have "categories" as a top level key.
|
|
487
|
+
let res = await agent
|
|
488
|
+
.post(`/food/${apple._id}/categories`)
|
|
489
|
+
.send({name: "Good Seller", show: false})
|
|
490
|
+
.expect(400);
|
|
491
|
+
expect(res.body.title).toBe(
|
|
492
|
+
"Malformed body, array operations should have a single, top level key, got: name,show"
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
res = await agent
|
|
496
|
+
.post(`/food/${apple._id}/categories`)
|
|
497
|
+
.send({categories: {name: "Good Seller", show: false}})
|
|
498
|
+
.expect(200);
|
|
499
|
+
expect(res.body.data.categories).toHaveLength(3);
|
|
500
|
+
expect(res.body.data.categories[2].name).toBe("Good Seller");
|
|
501
|
+
|
|
502
|
+
res = await agent
|
|
503
|
+
.post(`/food/${spinach._id}/categories`)
|
|
504
|
+
.send({categories: {name: "Good Seller", show: false}})
|
|
505
|
+
.expect(200);
|
|
506
|
+
expect(res.body.data.categories).toHaveLength(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("update array sub-schema item", async () => {
|
|
510
|
+
let res = await agent
|
|
511
|
+
.patch(`/food/${apple._id}/categories/xyz`)
|
|
512
|
+
.send({categories: {name: "Good Seller", show: false}})
|
|
513
|
+
.expect(404);
|
|
514
|
+
expect(res.body.title).toBe("Could not find categories/xyz");
|
|
515
|
+
res = await agent
|
|
516
|
+
.patch(`/food/${apple._id}/categories/${apple.categories[1]._id}`)
|
|
517
|
+
.send({categories: {name: "Good Seller", show: false}})
|
|
518
|
+
.expect(200);
|
|
519
|
+
expect(res.body.data.categories).toHaveLength(2);
|
|
520
|
+
expect(res.body.data.categories[1].name).toBe("Good Seller");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("delete array sub-schema item", async () => {
|
|
524
|
+
let res = await agent.delete(`/food/${apple._id}/categories/xyz`).expect(404);
|
|
525
|
+
expect(res.body.title).toBe("Could not find categories/xyz");
|
|
526
|
+
res = await agent
|
|
527
|
+
.delete(`/food/${apple._id}/categories/${apple.categories[0]._id}`)
|
|
528
|
+
.expect(200);
|
|
529
|
+
expect(res.body.data.categories).toHaveLength(1);
|
|
530
|
+
expect(res.body.data.categories[0].name).toBe("Popular");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("add array item", async () => {
|
|
534
|
+
let res = await agent.post(`/food/${apple._id}/tags`).send({tags: "popular"}).expect(200);
|
|
535
|
+
expect(res.body.data.tags).toHaveLength(3);
|
|
536
|
+
expect(res.body.data.tags).toEqual(["healthy", "cheap", "popular"]);
|
|
537
|
+
|
|
538
|
+
res = await agent.post(`/food/${spinach._id}/tags`).send({tags: "popular"}).expect(200);
|
|
539
|
+
expect(res.body.data.tags).toEqual(["popular"]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("update array item", async () => {
|
|
543
|
+
let res = await agent
|
|
544
|
+
.patch(`/food/${apple._id}/tags/xyz`)
|
|
545
|
+
.send({tags: "unhealthy"})
|
|
546
|
+
.expect(404);
|
|
547
|
+
expect(res.body.title).toBe("Could not find tags/xyz");
|
|
548
|
+
res = await agent
|
|
549
|
+
.patch(`/food/${apple._id}/tags/healthy`)
|
|
550
|
+
.send({tags: "unhealthy"})
|
|
551
|
+
.expect(200);
|
|
552
|
+
expect(res.body.data.tags).toEqual(["unhealthy", "cheap"]);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("delete array item", async () => {
|
|
556
|
+
let res = await agent.delete(`/food/${apple._id}/tags/xyz`).expect(404);
|
|
557
|
+
expect(res.body.title).toBe("Could not find tags/xyz");
|
|
558
|
+
res = await agent.delete(`/food/${apple._id}/tags/healthy`).expect(200);
|
|
559
|
+
expect(res.body.data.tags).toEqual(["cheap"]);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("updates timestamps on array subdocuments", async () => {
|
|
563
|
+
// Create a food with categories that have timestamps
|
|
564
|
+
const foodWithTimestamps = await FoodModel.create({
|
|
565
|
+
calories: 100,
|
|
566
|
+
categories: [
|
|
567
|
+
{
|
|
568
|
+
name: "Category 1",
|
|
569
|
+
show: true,
|
|
570
|
+
updated: new Date("2024-01-01T00:00:00.000Z"),
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
name: "Category 2",
|
|
574
|
+
show: true,
|
|
575
|
+
updated: new Date("2024-01-01T00:00:00.000Z"),
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
created: new Date(),
|
|
579
|
+
name: "Food with Timestamps",
|
|
580
|
+
ownerId: admin._id,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const firstCategoryId = foodWithTimestamps.categories?.[0]?._id?.toString();
|
|
584
|
+
const secondCategoryId = foodWithTimestamps.categories?.[1]?._id?.toString();
|
|
585
|
+
|
|
586
|
+
if (!firstCategoryId || !secondCategoryId) {
|
|
587
|
+
throw new Error("Failed to create food with categories");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Wait a moment to ensure timestamp difference
|
|
591
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
592
|
+
|
|
593
|
+
// Update one of the categories
|
|
594
|
+
const res = await agent
|
|
595
|
+
.patch(`/food/${foodWithTimestamps._id}/categories/${firstCategoryId}`)
|
|
596
|
+
.send({categories: {name: "Updated Category"}})
|
|
597
|
+
.expect(200);
|
|
598
|
+
|
|
599
|
+
// Verify the updated category has a newer timestamp
|
|
600
|
+
const updatedCategory = res.body.data.categories.find((c: any) => c._id === firstCategoryId);
|
|
601
|
+
const unchangedCategory = res.body.data.categories.find(
|
|
602
|
+
(c: any) => c._id === secondCategoryId
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
if (!updatedCategory || !unchangedCategory) {
|
|
606
|
+
throw new Error("Failed to find categories in response");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
expect(updatedCategory.updated).not.toBe(updatedCategory.created);
|
|
610
|
+
expect(unchangedCategory.updated).toBe(unchangedCategory.created);
|
|
611
|
+
expect(updatedCategory.name).toBe("Updated Category");
|
|
612
|
+
// Unchanged.
|
|
613
|
+
expect(updatedCategory.show).toBe(true);
|
|
614
|
+
expect(unchangedCategory.show).toBe(true);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("array operations call postUpdate with different copy of document", async () => {
|
|
618
|
+
let postUpdateDoc: any;
|
|
619
|
+
let postUpdatePrevDoc: any;
|
|
620
|
+
let postUpdateCalled = false;
|
|
621
|
+
|
|
622
|
+
app = getBaseServer();
|
|
623
|
+
setupAuth(app, UserModel as any);
|
|
624
|
+
addAuthRoutes(app, UserModel as any);
|
|
625
|
+
app.use(
|
|
626
|
+
"/food",
|
|
627
|
+
modelRouter(FoodModel, {
|
|
628
|
+
allowAnonymous: true,
|
|
629
|
+
permissions: {
|
|
630
|
+
create: [Permissions.IsAdmin],
|
|
631
|
+
delete: [Permissions.IsAdmin],
|
|
632
|
+
list: [Permissions.IsAdmin],
|
|
633
|
+
read: [Permissions.IsAdmin],
|
|
634
|
+
update: [Permissions.IsAdmin],
|
|
635
|
+
},
|
|
636
|
+
postUpdate: async (doc: any, _cleanedBody: any, _request: any, prevValue: any) => {
|
|
637
|
+
postUpdateDoc = doc;
|
|
638
|
+
postUpdatePrevDoc = prevValue;
|
|
639
|
+
postUpdateCalled = true;
|
|
640
|
+
},
|
|
641
|
+
})
|
|
642
|
+
);
|
|
643
|
+
server = supertest(app);
|
|
644
|
+
agent = await authAsUser(app, "admin");
|
|
645
|
+
|
|
646
|
+
// Test POST operation (add to array)
|
|
647
|
+
await agent
|
|
648
|
+
.post(`/food/${apple._id}/categories`)
|
|
649
|
+
.send({categories: {name: "New Category", show: true}})
|
|
650
|
+
.expect(200);
|
|
651
|
+
|
|
652
|
+
expect(postUpdateCalled).toBe(true);
|
|
653
|
+
expect(postUpdateDoc).toBeDefined();
|
|
654
|
+
expect(postUpdatePrevDoc).toBeDefined();
|
|
655
|
+
|
|
656
|
+
// Verify they are different object references
|
|
657
|
+
expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
|
|
658
|
+
|
|
659
|
+
// Verify the content is different (new category added)
|
|
660
|
+
expect(postUpdateDoc.categories).toHaveLength(3);
|
|
661
|
+
expect(postUpdatePrevDoc.categories).toHaveLength(2);
|
|
662
|
+
|
|
663
|
+
// Reset for next test
|
|
664
|
+
postUpdateCalled = false;
|
|
665
|
+
postUpdateDoc = undefined;
|
|
666
|
+
postUpdatePrevDoc = undefined;
|
|
667
|
+
|
|
668
|
+
// Test PATCH operation (update array item)
|
|
669
|
+
const categoryId = apple.categories[0]._id;
|
|
670
|
+
if (!categoryId) {
|
|
671
|
+
throw new Error("Category ID is undefined");
|
|
672
|
+
}
|
|
673
|
+
await agent
|
|
674
|
+
.patch(`/food/${apple._id}/categories/${categoryId}`)
|
|
675
|
+
.send({categories: {name: "Updated Category", show: false}})
|
|
676
|
+
.expect(200);
|
|
677
|
+
|
|
678
|
+
expect(postUpdateCalled).toBe(true);
|
|
679
|
+
expect(postUpdateDoc).toBeDefined();
|
|
680
|
+
expect(postUpdatePrevDoc).toBeDefined();
|
|
681
|
+
|
|
682
|
+
// Verify they are different object references
|
|
683
|
+
expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
|
|
684
|
+
|
|
685
|
+
// Verify the content is different (category updated)
|
|
686
|
+
const updatedCategory = postUpdateDoc.categories.find(
|
|
687
|
+
(c: any) => c._id.toString() === categoryId.toString()
|
|
688
|
+
);
|
|
689
|
+
const prevCategory = postUpdatePrevDoc.categories.find(
|
|
690
|
+
(c: any) => c._id.toString() === categoryId.toString()
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
expect(updatedCategory.name).toBe("Updated Category");
|
|
694
|
+
expect(prevCategory.name).toBe("Fruit");
|
|
695
|
+
|
|
696
|
+
// Reset for next test
|
|
697
|
+
postUpdateCalled = false;
|
|
698
|
+
postUpdateDoc = undefined;
|
|
699
|
+
postUpdatePrevDoc = undefined;
|
|
700
|
+
|
|
701
|
+
// Test DELETE operation (remove from array)
|
|
702
|
+
await agent.delete(`/food/${apple._id}/categories/${categoryId}`).expect(200);
|
|
703
|
+
|
|
704
|
+
expect(postUpdateCalled).toBe(true);
|
|
705
|
+
expect(postUpdateDoc).toBeDefined();
|
|
706
|
+
expect(postUpdatePrevDoc).toBeDefined();
|
|
707
|
+
|
|
708
|
+
// Verify they are different object references
|
|
709
|
+
expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
|
|
710
|
+
|
|
711
|
+
// Verify the content is different (category removed)
|
|
712
|
+
const remainingCategories = postUpdateDoc.categories.filter(
|
|
713
|
+
(c: any) => c._id.toString() === categoryId.toString()
|
|
714
|
+
);
|
|
715
|
+
const prevCategories = postUpdatePrevDoc.categories.filter(
|
|
716
|
+
(c: any) => c._id.toString() === categoryId.toString()
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
expect(remainingCategories).toHaveLength(0);
|
|
720
|
+
expect(prevCategories).toHaveLength(1);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("array operations with string arrays call postUpdate with different copy", async () => {
|
|
724
|
+
let postUpdateDoc: any;
|
|
725
|
+
let postUpdatePrevDoc: any;
|
|
726
|
+
let postUpdateCalled = false;
|
|
727
|
+
|
|
728
|
+
app = getBaseServer();
|
|
729
|
+
setupAuth(app, UserModel as any);
|
|
730
|
+
addAuthRoutes(app, UserModel as any);
|
|
731
|
+
app.use(
|
|
732
|
+
"/food",
|
|
733
|
+
modelRouter(FoodModel, {
|
|
734
|
+
allowAnonymous: true,
|
|
735
|
+
permissions: {
|
|
736
|
+
create: [Permissions.IsAdmin],
|
|
737
|
+
delete: [Permissions.IsAdmin],
|
|
738
|
+
list: [Permissions.IsAdmin],
|
|
739
|
+
read: [Permissions.IsAdmin],
|
|
740
|
+
update: [Permissions.IsAdmin],
|
|
741
|
+
},
|
|
742
|
+
postUpdate: async (doc: any, _cleanedBody: any, _request: any, prevValue: any) => {
|
|
743
|
+
postUpdateDoc = doc;
|
|
744
|
+
postUpdatePrevDoc = prevValue;
|
|
745
|
+
postUpdateCalled = true;
|
|
746
|
+
},
|
|
747
|
+
})
|
|
748
|
+
);
|
|
749
|
+
server = supertest(app);
|
|
750
|
+
agent = await authAsUser(app, "admin");
|
|
751
|
+
|
|
752
|
+
// Test POST operation with string array (add tag)
|
|
753
|
+
await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(200);
|
|
754
|
+
|
|
755
|
+
expect(postUpdateCalled).toBe(true);
|
|
756
|
+
expect(postUpdateDoc).toBeDefined();
|
|
757
|
+
expect(postUpdatePrevDoc).toBeDefined();
|
|
758
|
+
|
|
759
|
+
// Verify they are different object references
|
|
760
|
+
expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
|
|
761
|
+
|
|
762
|
+
// Verify the content is different (new tag added)
|
|
763
|
+
expect(postUpdateDoc.tags).toHaveLength(3);
|
|
764
|
+
expect(postUpdatePrevDoc.tags).toHaveLength(2);
|
|
765
|
+
expect(postUpdateDoc.tags).toContain("organic");
|
|
766
|
+
expect(postUpdatePrevDoc.tags).not.toContain("organic");
|
|
767
|
+
|
|
768
|
+
// Reset for next test
|
|
769
|
+
postUpdateCalled = false;
|
|
770
|
+
postUpdateDoc = undefined;
|
|
771
|
+
postUpdatePrevDoc = undefined;
|
|
772
|
+
|
|
773
|
+
// Test PATCH operation with string array (update tag)
|
|
774
|
+
await agent
|
|
775
|
+
.patch(`/food/${apple._id}/tags/healthy`)
|
|
776
|
+
.send({tags: "super-healthy"})
|
|
777
|
+
.expect(200);
|
|
778
|
+
|
|
779
|
+
expect(postUpdateCalled).toBe(true);
|
|
780
|
+
expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
|
|
781
|
+
|
|
782
|
+
// Verify the content is different (tag updated)
|
|
783
|
+
expect(postUpdateDoc.tags).toContain("super-healthy");
|
|
784
|
+
expect(postUpdatePrevDoc.tags).toContain("healthy");
|
|
785
|
+
expect(postUpdateDoc.tags).not.toContain("healthy");
|
|
786
|
+
expect(postUpdatePrevDoc.tags).not.toContain("super-healthy");
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
describe("standard methods", () => {
|
|
791
|
+
let notAdmin: any;
|
|
792
|
+
let admin: any;
|
|
793
|
+
let adminOther: any;
|
|
794
|
+
let agent: TestAgent;
|
|
795
|
+
|
|
796
|
+
let spinach: Food;
|
|
797
|
+
let apple: Food;
|
|
798
|
+
let carrots: Food;
|
|
799
|
+
let pizza: Food;
|
|
800
|
+
|
|
801
|
+
beforeEach(async () => {
|
|
802
|
+
[admin, notAdmin, adminOther] = await setupDb();
|
|
803
|
+
|
|
804
|
+
const results = (await Promise.all([
|
|
805
|
+
FoodModel.create({
|
|
806
|
+
calories: 1,
|
|
807
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
808
|
+
eatenBy: [admin._id],
|
|
809
|
+
hidden: false,
|
|
810
|
+
lastEatenWith: {
|
|
811
|
+
dressing: new Date("2021-12-03T19:00:30.000Z"),
|
|
812
|
+
},
|
|
813
|
+
name: "Spinach",
|
|
814
|
+
ownerId: notAdmin._id,
|
|
815
|
+
source: {
|
|
816
|
+
dateAdded: "2023-12-13T12:30:00.000Z",
|
|
817
|
+
href: "https://www.google.com",
|
|
818
|
+
name: "Brand",
|
|
819
|
+
},
|
|
820
|
+
}),
|
|
821
|
+
FoodModel.create({
|
|
822
|
+
calories: 100,
|
|
823
|
+
created: new Date("2021-12-03T00:00:30.000Z"),
|
|
824
|
+
hidden: true,
|
|
825
|
+
name: "Apple",
|
|
826
|
+
ownerId: admin._id,
|
|
827
|
+
tags: ["healthy"],
|
|
828
|
+
}),
|
|
829
|
+
FoodModel.create({
|
|
830
|
+
calories: 100,
|
|
831
|
+
created: new Date("2021-12-03T00:00:00.000Z"),
|
|
832
|
+
eatenBy: [admin._id, notAdmin._id],
|
|
833
|
+
hidden: false,
|
|
834
|
+
name: "Carrots",
|
|
835
|
+
ownerId: admin._id,
|
|
836
|
+
source: {
|
|
837
|
+
name: "USDA",
|
|
838
|
+
},
|
|
839
|
+
tags: ["healthy", "cheap"],
|
|
840
|
+
}),
|
|
841
|
+
FoodModel.create({
|
|
842
|
+
calories: 400,
|
|
843
|
+
created: new Date("2021-12-03T00:00:10.000Z"),
|
|
844
|
+
eatenBy: [adminOther._id],
|
|
845
|
+
hidden: false,
|
|
846
|
+
name: "Pizza",
|
|
847
|
+
ownerId: admin._id,
|
|
848
|
+
tags: ["cheap"],
|
|
849
|
+
}),
|
|
850
|
+
])) as [Food, Food, Food, Food];
|
|
851
|
+
[spinach, apple, carrots, pizza] = results;
|
|
852
|
+
app = getBaseServer();
|
|
853
|
+
setupAuth(app, UserModel as any);
|
|
854
|
+
addAuthRoutes(app, UserModel as any);
|
|
855
|
+
app.use(logRequests);
|
|
856
|
+
app.use(
|
|
857
|
+
"/food",
|
|
858
|
+
modelRouter(FoodModel, {
|
|
859
|
+
allowAnonymous: true,
|
|
860
|
+
defaultLimit: 2,
|
|
861
|
+
defaultQueryParams: {hidden: false},
|
|
862
|
+
maxLimit: 3,
|
|
863
|
+
permissions: {
|
|
864
|
+
create: [Permissions.IsAuthenticated],
|
|
865
|
+
delete: [Permissions.IsAdmin],
|
|
866
|
+
list: [Permissions.IsAny],
|
|
867
|
+
read: [Permissions.IsAny],
|
|
868
|
+
update: [Permissions.IsOwner],
|
|
869
|
+
},
|
|
870
|
+
populatePaths: [{path: "ownerId"}],
|
|
871
|
+
queryFields: ["hidden", "name", "calories", "created", "source.name", "tags", "eatenBy"],
|
|
872
|
+
sort: {created: "descending"},
|
|
873
|
+
})
|
|
874
|
+
);
|
|
875
|
+
server = supertest(app);
|
|
876
|
+
agent = await authAsUser(app, "notAdmin");
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("read default", async () => {
|
|
880
|
+
const res = await agent.get(`/food/${spinach._id}`).expect(200);
|
|
881
|
+
expect(res.body.data._id).toBe(spinach._id.toString());
|
|
882
|
+
// Ensure populate works
|
|
883
|
+
expect(res.body.data.ownerId._id).toBe(notAdmin.id);
|
|
884
|
+
// Ensure maps are properly transformed
|
|
885
|
+
expect(res.body.data.lastEatenWith).toEqual({
|
|
886
|
+
dressing: "2021-12-03T19:00:30.000Z",
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("list default", async () => {
|
|
891
|
+
const res = await agent.get("/food").expect(200);
|
|
892
|
+
expect(res.body.data).toHaveLength(2);
|
|
893
|
+
expect(res.body.data[0].id).toBe((spinach as any).id);
|
|
894
|
+
expect(res.body.data[0].ownerId._id).toBe(notAdmin.id);
|
|
895
|
+
expect(res.body.data[1].id).toBe((pizza as any).id);
|
|
896
|
+
expect(res.body.data[1].ownerId._id).toBe(admin.id);
|
|
897
|
+
// Check that mongoose Map is handled correctly.
|
|
898
|
+
expect(res.body.data[0].lastEatenWith).toEqual({
|
|
899
|
+
dressing: "2021-12-03T19:00:30.000Z",
|
|
900
|
+
});
|
|
901
|
+
expect(res.body.data[1].lastEatenWith).toEqual(undefined);
|
|
902
|
+
|
|
903
|
+
expect(res.body.more).toBe(true);
|
|
904
|
+
expect(res.body.total).toBe(3);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("list limit", async () => {
|
|
908
|
+
const res = await agent.get("/food?limit=1").expect(200);
|
|
909
|
+
expect(res.body.data).toHaveLength(1);
|
|
910
|
+
expect(res.body.data[0].id).toBe((spinach as any).id);
|
|
911
|
+
expect(res.body.data[0].ownerId._id).toBe(notAdmin.id);
|
|
912
|
+
expect(res.body.more).toBe(true);
|
|
913
|
+
expect(res.body.total).toBe(3);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("list limit over", async () => {
|
|
917
|
+
// This shouldn't be seen, it's the end of the list.
|
|
918
|
+
await FoodModel.create({
|
|
919
|
+
calories: 400,
|
|
920
|
+
created: new Date("2021-12-02T00:00:10.000Z"),
|
|
921
|
+
hidden: false,
|
|
922
|
+
name: "Pizza",
|
|
923
|
+
ownerId: admin._id,
|
|
924
|
+
});
|
|
925
|
+
const res = await agent.get("/food?limit=4").expect(200);
|
|
926
|
+
expect(res.body.data).toHaveLength(3);
|
|
927
|
+
expect(res.body.more).toBe(true);
|
|
928
|
+
expect(res.body.total).toBe(4);
|
|
929
|
+
expect(res.body.data[0].id).toBe((spinach as any).id);
|
|
930
|
+
expect(res.body.data[1].id).toBe((pizza as any).id);
|
|
931
|
+
expect(res.body.data[2].id).toBe((carrots as any).id);
|
|
932
|
+
|
|
933
|
+
expect(Sentry.captureMessage).toHaveBeenCalledWith(
|
|
934
|
+
'More than 3 results returned for foods without pagination, data may be silently truncated. req.query: {"limit":"4"}'
|
|
935
|
+
);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("list page", async () => {
|
|
939
|
+
// Should skip to carrots since apples are hidden
|
|
940
|
+
const res = await agent.get("/food?limit=1&page=2").expect(200);
|
|
941
|
+
expect(res.body.data).toHaveLength(1);
|
|
942
|
+
expect(res.body.more).toBe(true);
|
|
943
|
+
expect(res.body.total).toBe(3);
|
|
944
|
+
expect(res.body.data[0].id).toBe((pizza as any).id);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it("list page 0 ", async () => {
|
|
948
|
+
const res = await agent.get("/food?limit=1&page=0").expect(400);
|
|
949
|
+
expect(res.body.title).toBe("Invalid page: 0");
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("list page with garbage ", async () => {
|
|
953
|
+
const res = await agent.get("/food?limit=1&page=abc").expect(400);
|
|
954
|
+
expect(res.body.title).toBe("Invalid page: abc");
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it("list page over", async () => {
|
|
958
|
+
// Should skip to carrots since apples are hidden
|
|
959
|
+
const res = await agent.get("/food?limit=1&page=5").expect(200);
|
|
960
|
+
expect(res.body.data).toHaveLength(0);
|
|
961
|
+
expect(res.body.more).toBe(false);
|
|
962
|
+
expect(res.body.total).toBe(3);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("list query params", async () => {
|
|
966
|
+
// Should skip to carrots since apples are hidden
|
|
967
|
+
const res = await agent.get("/food?hidden=true").expect(200);
|
|
968
|
+
expect(res.body.data).toHaveLength(1);
|
|
969
|
+
expect(res.body.more).toBe(false);
|
|
970
|
+
expect(res.body.total).toBe(1);
|
|
971
|
+
expect(res.body.data[0].id).toBe((apple as any).id);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it("list query params not in list", async () => {
|
|
975
|
+
// Should skip to carrots since apples are hidden
|
|
976
|
+
const res = await agent.get(`/food?ownerId=${admin._id}`).expect(400);
|
|
977
|
+
expect(res.body.title).toBe("ownerId is not allowed as a query param.");
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("list query by nested param", async () => {
|
|
981
|
+
// Should skip to carrots since apples are hidden
|
|
982
|
+
const res = await agent.get("/food?source.name=USDA").expect(200);
|
|
983
|
+
expect(res.body.data).toHaveLength(1);
|
|
984
|
+
expect(res.body.total).toBe(1);
|
|
985
|
+
expect(res.body.data[0].id).toBe((carrots as any).id);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it("query by date", async () => {
|
|
989
|
+
const authRes = await server
|
|
990
|
+
.post("/auth/login")
|
|
991
|
+
.send({email: "admin@example.com", password: "securePassword"})
|
|
992
|
+
.expect(200);
|
|
993
|
+
const token = authRes.body.data.token;
|
|
994
|
+
|
|
995
|
+
// Inclusive
|
|
996
|
+
let res = await server
|
|
997
|
+
.get(
|
|
998
|
+
`/food?limit=3&${qs.stringify({
|
|
999
|
+
created: {
|
|
1000
|
+
$gte: "2021-12-03T00:00:00.000Z",
|
|
1001
|
+
$lte: "2021-12-03T00:00:20.000Z",
|
|
1002
|
+
},
|
|
1003
|
+
})}`
|
|
1004
|
+
)
|
|
1005
|
+
.set("authorization", `Bearer ${token}`)
|
|
1006
|
+
.expect(200);
|
|
1007
|
+
expect(res.body.data.map((d: any) => d.created)).toEqual(
|
|
1008
|
+
expect.arrayContaining([
|
|
1009
|
+
"2021-12-03T00:00:20.000Z",
|
|
1010
|
+
"2021-12-03T00:00:10.000Z",
|
|
1011
|
+
"2021-12-03T00:00:00.000Z",
|
|
1012
|
+
])
|
|
1013
|
+
);
|
|
1014
|
+
expect(res.body.data.map((d: any) => d.created)).toHaveLength(3);
|
|
1015
|
+
|
|
1016
|
+
// Inclusive one side
|
|
1017
|
+
res = await server
|
|
1018
|
+
.get(
|
|
1019
|
+
`/food?limit=3&${qs.stringify({
|
|
1020
|
+
created: {
|
|
1021
|
+
$gte: "2021-12-03T00:00:00.000Z",
|
|
1022
|
+
$lt: "2021-12-03T00:00:20.000Z",
|
|
1023
|
+
},
|
|
1024
|
+
})}`
|
|
1025
|
+
)
|
|
1026
|
+
.set("authorization", `Bearer ${token}`)
|
|
1027
|
+
.expect(200);
|
|
1028
|
+
expect(res.body.data.map((d: any) => d.created)).toEqual(
|
|
1029
|
+
expect.arrayContaining(["2021-12-03T00:00:10.000Z", "2021-12-03T00:00:00.000Z"])
|
|
1030
|
+
);
|
|
1031
|
+
expect(res.body.data.map((d: any) => d.created)).toHaveLength(2);
|
|
1032
|
+
|
|
1033
|
+
// Inclusive both sides
|
|
1034
|
+
res = await server
|
|
1035
|
+
.get(
|
|
1036
|
+
`/food?limit=3&${qs.stringify({
|
|
1037
|
+
created: {
|
|
1038
|
+
$gt: "2021-12-03T00:00:00.000Z",
|
|
1039
|
+
$lt: "2021-12-03T00:00:20.000Z",
|
|
1040
|
+
},
|
|
1041
|
+
})}`
|
|
1042
|
+
)
|
|
1043
|
+
.set("authorization", `Bearer ${token}`)
|
|
1044
|
+
.expect(200);
|
|
1045
|
+
const createdDates = res.body.data.map((d: any) => d.created);
|
|
1046
|
+
expect(createdDates).toEqual(expect.arrayContaining(["2021-12-03T00:00:10.000Z"]));
|
|
1047
|
+
expect(createdDates).toHaveLength(1);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("query with a space", async () => {
|
|
1051
|
+
const greenBeans = await FoodModel.create({
|
|
1052
|
+
calories: 102,
|
|
1053
|
+
created: Date.now() - 10,
|
|
1054
|
+
name: "Green Beans",
|
|
1055
|
+
ownerId: admin?._id,
|
|
1056
|
+
});
|
|
1057
|
+
const res = await agent.get(`/food?${qs.stringify({name: "Green Beans"})}`).expect(200);
|
|
1058
|
+
expect(res.body.data).toHaveLength(1);
|
|
1059
|
+
expect(res.body.data[0].id).toBe(greenBeans?.id);
|
|
1060
|
+
expect(res.body.data[0].name).toBe("Green Beans");
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("query with a regex", async () => {
|
|
1064
|
+
const greenBeans = await FoodModel.create({
|
|
1065
|
+
calories: 102,
|
|
1066
|
+
created: Date.now() - 10,
|
|
1067
|
+
name: "Green Beans",
|
|
1068
|
+
ownerId: admin?._id,
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// Case sensitive does match correct casing
|
|
1072
|
+
let res = await agent.get(`/food?${qs.stringify({name: {$regex: "Green"}})}`).expect(200);
|
|
1073
|
+
expect(res.body.data).toHaveLength(1);
|
|
1074
|
+
expect(res.body.data[0].id).toBe(greenBeans?.id);
|
|
1075
|
+
expect(res.body.data[0].name).toBe("Green Beans");
|
|
1076
|
+
|
|
1077
|
+
// Fails with different casing and sensitive
|
|
1078
|
+
res = await agent.get(`/food?${qs.stringify({name: {$regex: "green"}})}`).expect(200);
|
|
1079
|
+
expect(res.body.data).toHaveLength(0);
|
|
1080
|
+
|
|
1081
|
+
// Case insensitive does match different casing
|
|
1082
|
+
res = await agent
|
|
1083
|
+
.get(`/food?${qs.stringify({name: {$options: "i", $regex: "green"}})}`)
|
|
1084
|
+
.expect(200);
|
|
1085
|
+
expect(res.body.data).toHaveLength(1);
|
|
1086
|
+
expect(res.body.data[0].id).toBe(greenBeans?.id);
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it("query with an $in operator", async () => {
|
|
1090
|
+
// Query including a hidden food
|
|
1091
|
+
let res = await server
|
|
1092
|
+
.get(
|
|
1093
|
+
`/food?${qs.stringify({
|
|
1094
|
+
name: {
|
|
1095
|
+
$in: ["Apple", "Spinach"],
|
|
1096
|
+
},
|
|
1097
|
+
})}`
|
|
1098
|
+
)
|
|
1099
|
+
.expect(200);
|
|
1100
|
+
const names1 = res.body.data.map((d: any) => d.name);
|
|
1101
|
+
expect(names1).toEqual(expect.arrayContaining(["Spinach"]));
|
|
1102
|
+
expect(names1).toHaveLength(1);
|
|
1103
|
+
|
|
1104
|
+
// Query without hidden food.
|
|
1105
|
+
res = await server
|
|
1106
|
+
.get(
|
|
1107
|
+
`/food?${qs.stringify({
|
|
1108
|
+
name: {
|
|
1109
|
+
$in: ["Carrots", "Spinach"],
|
|
1110
|
+
},
|
|
1111
|
+
})}`
|
|
1112
|
+
)
|
|
1113
|
+
.expect(200);
|
|
1114
|
+
const names2 = res.body.data.map((d: any) => d.name);
|
|
1115
|
+
expect(names2).toEqual(expect.arrayContaining(["Spinach", "Carrots"]));
|
|
1116
|
+
expect(names2).toHaveLength(2);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it("query with an $in for _ids in nested object", async () => {
|
|
1120
|
+
// Query including a hidden food
|
|
1121
|
+
const res = await server
|
|
1122
|
+
.get(
|
|
1123
|
+
`/food?${qs.stringify({
|
|
1124
|
+
eatenBy: {
|
|
1125
|
+
$in: [notAdmin._id.toString(), adminOther._id.toString()],
|
|
1126
|
+
},
|
|
1127
|
+
})}`
|
|
1128
|
+
)
|
|
1129
|
+
.expect(200);
|
|
1130
|
+
expect(res.body.more).toBe(false);
|
|
1131
|
+
expect(res.body.total).toBe(2);
|
|
1132
|
+
expect(res.body.data).toHaveLength(2);
|
|
1133
|
+
const names3 = res.body.data.map((d: any) => d.name);
|
|
1134
|
+
expect(names3).toEqual(expect.arrayContaining(["Carrots", "Pizza"]));
|
|
1135
|
+
expect(names3).toHaveLength(2);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it("query $and operator on same field", async () => {
|
|
1139
|
+
const res = await agent
|
|
1140
|
+
.get(`/food?${qs.stringify({$and: [{tags: "healthy"}, {tags: "cheap"}]})}`)
|
|
1141
|
+
.expect(200);
|
|
1142
|
+
expect(res.body.data).toHaveLength(1);
|
|
1143
|
+
expect(res.body.data[0].id).toBe(carrots?._id.toString());
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("query $and operator on same field, nested objects", async () => {
|
|
1147
|
+
const res = await agent
|
|
1148
|
+
.get(
|
|
1149
|
+
`/food?${qs.stringify({
|
|
1150
|
+
$and: [{eatenBy: admin.id}, {eatenBy: notAdmin.id}],
|
|
1151
|
+
})}`
|
|
1152
|
+
)
|
|
1153
|
+
.expect(200);
|
|
1154
|
+
expect(res.body.data).toHaveLength(1);
|
|
1155
|
+
expect(res.body.data[0].id).toBe(carrots?._id.toString());
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
it("query $or operator on same field", async () => {
|
|
1159
|
+
const res = await agent
|
|
1160
|
+
.get(`/food?${qs.stringify({$or: [{name: "Carrots"}, {name: "Pizza"}]})}`)
|
|
1161
|
+
.expect(200);
|
|
1162
|
+
expect(res.body.data).toHaveLength(2);
|
|
1163
|
+
// Only carrots matches both
|
|
1164
|
+
const ids1 = res.body.data.map((d) => d.id);
|
|
1165
|
+
expect(ids1).toEqual(
|
|
1166
|
+
expect.arrayContaining([carrots?._id.toString(), pizza?._id.toString()])
|
|
1167
|
+
);
|
|
1168
|
+
expect(ids1).toHaveLength(2);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it("query $and operator on same field, nested objects", async () => {
|
|
1172
|
+
const res = await agent
|
|
1173
|
+
.get(
|
|
1174
|
+
`/food?${qs.stringify({
|
|
1175
|
+
$or: [{eatenBy: admin.id}, {eatenBy: notAdmin.id}],
|
|
1176
|
+
limit: 3,
|
|
1177
|
+
})}`
|
|
1178
|
+
)
|
|
1179
|
+
.expect(200);
|
|
1180
|
+
expect(res.body.data).toHaveLength(2);
|
|
1181
|
+
const ids2 = res.body.data.map((d) => d.id);
|
|
1182
|
+
expect(ids2).toEqual(
|
|
1183
|
+
expect.arrayContaining([carrots?._id.toString(), spinach?._id.toString()])
|
|
1184
|
+
);
|
|
1185
|
+
expect(ids2).toHaveLength(2);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it("query $and and $or are rejected if field is not in queryFields", async () => {
|
|
1189
|
+
let res = await agent
|
|
1190
|
+
.get(`/food?${qs.stringify({$and: [{ownerId: "healthy"}, {tags: "cheap"}]})}`)
|
|
1191
|
+
.expect(400);
|
|
1192
|
+
expect(res.body.title).toBe("ownerId is not allowed as a query param.");
|
|
1193
|
+
// Check in the other order
|
|
1194
|
+
res = await agent
|
|
1195
|
+
.get(`/food?${qs.stringify({$and: [{tags: "cheap"}, {ownerId: "healthy"}]})}`)
|
|
1196
|
+
.expect(400);
|
|
1197
|
+
expect(res.body.title).toBe("ownerId is not allowed as a query param.");
|
|
1198
|
+
|
|
1199
|
+
res = await agent
|
|
1200
|
+
.get(`/food?${qs.stringify({$or: [{tags: "cheap"}, {ownerId: "healthy"}]})}`)
|
|
1201
|
+
.expect(400);
|
|
1202
|
+
expect(res.body.title).toBe("ownerId is not allowed as a query param.");
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it("query with a number", async () => {
|
|
1206
|
+
const res = await agent.get("/food?calories=100").expect(200);
|
|
1207
|
+
expect(res.body.data).toHaveLength(1);
|
|
1208
|
+
expect(res.body.data[0].id).toBe(carrots?._id.toString());
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
it("update", async () => {
|
|
1212
|
+
let res = await agent.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(200);
|
|
1213
|
+
expect(res.body.data.name).toBe("Kale");
|
|
1214
|
+
expect(res.body.data.calories).toBe(1);
|
|
1215
|
+
expect(res.body.data.hidden).toBe(false);
|
|
1216
|
+
|
|
1217
|
+
// Update a Map field.
|
|
1218
|
+
res = await agent
|
|
1219
|
+
.patch(`/food/${spinach._id}`)
|
|
1220
|
+
.send({lastEatenWith: {dressing: "2023-12-03T00:00:20.000Z"}})
|
|
1221
|
+
.expect(200);
|
|
1222
|
+
expect(res.body.data.name).toBe("Kale");
|
|
1223
|
+
expect(res.body.data.calories).toBe(1);
|
|
1224
|
+
expect(res.body.data.hidden).toBe(false);
|
|
1225
|
+
expect(res.body.data.lastEatenWith).toEqual({
|
|
1226
|
+
dressing: "2023-12-03T00:00:20.000Z",
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// Update a Map field.
|
|
1230
|
+
res = await agent
|
|
1231
|
+
.patch(`/food/${spinach._id}`)
|
|
1232
|
+
.send({
|
|
1233
|
+
lastEatenWith: {
|
|
1234
|
+
cucumber: "2023-12-04T12:00:20.000Z",
|
|
1235
|
+
dressing: "2023-12-03T00:00:20.000Z",
|
|
1236
|
+
},
|
|
1237
|
+
})
|
|
1238
|
+
.expect(200);
|
|
1239
|
+
expect(res.body.data.lastEatenWith).toEqual({
|
|
1240
|
+
cucumber: "2023-12-04T12:00:20.000Z",
|
|
1241
|
+
dressing: "2023-12-03T00:00:20.000Z",
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it("update using dot notation", async () => {
|
|
1246
|
+
// Allows updating a single field in a nested object
|
|
1247
|
+
const res = await agent
|
|
1248
|
+
.patch(`/food/${spinach._id}`)
|
|
1249
|
+
.send({"source.href": "https://food.com"})
|
|
1250
|
+
.expect(200);
|
|
1251
|
+
// Assert the field was updated with dot notation.
|
|
1252
|
+
expect(res.body.data.source.href).toBe("https://food.com");
|
|
1253
|
+
// Assert these fields haven't changed.
|
|
1254
|
+
expect(res.body.data.source.name).toBe("Brand");
|
|
1255
|
+
expect(res.body.data.source.dateAdded).toBe("2023-12-13T12:30:00.000Z");
|
|
1256
|
+
|
|
1257
|
+
const dbSpinach = await FoodModel.findById(spinach._id);
|
|
1258
|
+
expect(dbSpinach?.source.href).toBe("https://food.com");
|
|
1259
|
+
expect(dbSpinach?.source.name).toBe("Brand");
|
|
1260
|
+
expect(dbSpinach?.source.dateAdded).toBe("2023-12-13T12:30:00.000Z");
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
describe("populate", () => {
|
|
1265
|
+
let admin: any;
|
|
1266
|
+
let notAdmin: any;
|
|
1267
|
+
let agent: TestAgent;
|
|
1268
|
+
|
|
1269
|
+
let spinach: Food;
|
|
1270
|
+
|
|
1271
|
+
beforeEach(async () => {
|
|
1272
|
+
[admin, notAdmin] = await setupDb();
|
|
1273
|
+
|
|
1274
|
+
[spinach] = await Promise.all([
|
|
1275
|
+
FoodModel.create({
|
|
1276
|
+
calories: 1,
|
|
1277
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
1278
|
+
hidden: false,
|
|
1279
|
+
name: "Spinach",
|
|
1280
|
+
ownerId: admin._id,
|
|
1281
|
+
source: {
|
|
1282
|
+
name: "Brand",
|
|
1283
|
+
},
|
|
1284
|
+
}),
|
|
1285
|
+
FoodModel.create({
|
|
1286
|
+
calories: 1,
|
|
1287
|
+
created: new Date("2022-12-03T00:00:20.000Z"),
|
|
1288
|
+
hidden: false,
|
|
1289
|
+
name: "Carrots",
|
|
1290
|
+
ownerId: notAdmin._id,
|
|
1291
|
+
source: {
|
|
1292
|
+
name: "User",
|
|
1293
|
+
},
|
|
1294
|
+
}),
|
|
1295
|
+
]);
|
|
1296
|
+
app = getBaseServer();
|
|
1297
|
+
setupAuth(app, UserModel as any);
|
|
1298
|
+
addAuthRoutes(app, UserModel as any);
|
|
1299
|
+
app.use(
|
|
1300
|
+
"/food",
|
|
1301
|
+
modelRouter(FoodModel, {
|
|
1302
|
+
allowAnonymous: true,
|
|
1303
|
+
permissions: {
|
|
1304
|
+
create: [Permissions.IsAny],
|
|
1305
|
+
delete: [Permissions.IsAny],
|
|
1306
|
+
list: [Permissions.IsAny],
|
|
1307
|
+
read: [Permissions.IsAny],
|
|
1308
|
+
update: [Permissions.IsAny],
|
|
1309
|
+
},
|
|
1310
|
+
populatePaths: [{fields: ["email"], path: "ownerId"}],
|
|
1311
|
+
sort: "-created",
|
|
1312
|
+
})
|
|
1313
|
+
);
|
|
1314
|
+
server = supertest(app);
|
|
1315
|
+
agent = await authAsUser(app, "notAdmin");
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
it("lists with populate", async () => {
|
|
1319
|
+
const res = await agent.get("/food").expect(200);
|
|
1320
|
+
expect(res.body.data).toHaveLength(2);
|
|
1321
|
+
const [carrots, spin] = res.body.data;
|
|
1322
|
+
expect(carrots.ownerId._id).toBe(notAdmin._id.toString());
|
|
1323
|
+
expect(carrots.ownerId.email).toBe(notAdmin.email);
|
|
1324
|
+
expect(carrots.ownerId.name).toBeUndefined();
|
|
1325
|
+
expect(spin.ownerId._id).toBe(admin._id.toString());
|
|
1326
|
+
expect(spin.ownerId.email).toBe(admin.email);
|
|
1327
|
+
expect(spin.ownerId.name).toBeUndefined();
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it("reads with populate", async () => {
|
|
1331
|
+
const res = await agent.get(`/food/${spinach._id}`).expect(200);
|
|
1332
|
+
expect(res.body.data.ownerId._id).toBe(admin._id.toString());
|
|
1333
|
+
expect(res.body.data.ownerId.email).toBe(admin.email);
|
|
1334
|
+
expect(res.body.data.ownerId.name).toBeUndefined();
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it("creates with populate", async () => {
|
|
1338
|
+
const res = await server
|
|
1339
|
+
.post("/food")
|
|
1340
|
+
.send({
|
|
1341
|
+
calories: 15,
|
|
1342
|
+
name: "Broccoli",
|
|
1343
|
+
ownerId: admin._id,
|
|
1344
|
+
})
|
|
1345
|
+
.expect(201);
|
|
1346
|
+
expect(res.body.data.ownerId._id).toBe(admin._id.toString());
|
|
1347
|
+
expect(res.body.data.ownerId.email).toBe(admin.email);
|
|
1348
|
+
expect(res.body.data.ownerId.name).toBeUndefined();
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it("updates with populate", async () => {
|
|
1352
|
+
const res = await server
|
|
1353
|
+
.patch(`/food/${spinach._id}`)
|
|
1354
|
+
.send({
|
|
1355
|
+
name: "NotSpinach",
|
|
1356
|
+
})
|
|
1357
|
+
.expect(200);
|
|
1358
|
+
expect(res.body.data.ownerId._id).toBe(admin._id.toString());
|
|
1359
|
+
expect(res.body.data.ownerId.email).toBe(admin.email);
|
|
1360
|
+
expect(res.body.data.ownerId.name).toBeUndefined();
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
describe("responseHandler", () => {
|
|
1365
|
+
let admin: any;
|
|
1366
|
+
let agent: TestAgent;
|
|
1367
|
+
|
|
1368
|
+
let spinach: Food;
|
|
1369
|
+
|
|
1370
|
+
beforeEach(async () => {
|
|
1371
|
+
[admin] = await setupDb();
|
|
1372
|
+
|
|
1373
|
+
[spinach] = await Promise.all([
|
|
1374
|
+
FoodModel.create({
|
|
1375
|
+
calories: 1,
|
|
1376
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
1377
|
+
hidden: false,
|
|
1378
|
+
name: "Spinach",
|
|
1379
|
+
ownerId: admin._id,
|
|
1380
|
+
source: {
|
|
1381
|
+
name: "Brand",
|
|
1382
|
+
},
|
|
1383
|
+
}),
|
|
1384
|
+
FoodModel.create({
|
|
1385
|
+
calories: 100,
|
|
1386
|
+
created: Date.now() - 10,
|
|
1387
|
+
hidden: true,
|
|
1388
|
+
name: "Apple",
|
|
1389
|
+
ownerId: admin?._id,
|
|
1390
|
+
}),
|
|
1391
|
+
]);
|
|
1392
|
+
app = getBaseServer();
|
|
1393
|
+
setupAuth(app, UserModel as any);
|
|
1394
|
+
addAuthRoutes(app, UserModel as any);
|
|
1395
|
+
app.use(
|
|
1396
|
+
"/food",
|
|
1397
|
+
modelRouter(FoodModel, {
|
|
1398
|
+
allowAnonymous: true,
|
|
1399
|
+
permissions: {
|
|
1400
|
+
create: [Permissions.IsAny],
|
|
1401
|
+
delete: [Permissions.IsAny],
|
|
1402
|
+
list: [Permissions.IsAny],
|
|
1403
|
+
read: [Permissions.IsAny],
|
|
1404
|
+
update: [Permissions.IsAny],
|
|
1405
|
+
},
|
|
1406
|
+
responseHandler: (data, method) => {
|
|
1407
|
+
if (method === "list") {
|
|
1408
|
+
return (data as any).map((d: any) => ({
|
|
1409
|
+
foo: "bar",
|
|
1410
|
+
id: (d as any)._id,
|
|
1411
|
+
}));
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
foo: "bar",
|
|
1415
|
+
id: (data as any)._id,
|
|
1416
|
+
};
|
|
1417
|
+
},
|
|
1418
|
+
})
|
|
1419
|
+
);
|
|
1420
|
+
server = supertest(app);
|
|
1421
|
+
agent = await authAsUser(app, "notAdmin");
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
it("reads with serialize", async () => {
|
|
1425
|
+
const res = await agent.get(`/food/${spinach._id}`).expect(200);
|
|
1426
|
+
expect(res.body.data.ownerId).toBeUndefined();
|
|
1427
|
+
expect(res.body.data.id).toBe(spinach._id.toString());
|
|
1428
|
+
expect(res.body.data.foo).toBe("bar");
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
it("list with serialize", async () => {
|
|
1432
|
+
const res = await agent.get("/food").expect(200);
|
|
1433
|
+
expect(res.body.data[0].ownerId).toBeUndefined();
|
|
1434
|
+
expect(res.body.data[1].ownerId).toBeUndefined();
|
|
1435
|
+
|
|
1436
|
+
expect(res.body.data[0].id).toBeDefined();
|
|
1437
|
+
expect(res.body.data[0].foo).toBe("bar");
|
|
1438
|
+
expect(res.body.data[1].id).toBeDefined();
|
|
1439
|
+
expect(res.body.data[1].foo).toBe("bar");
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
describe("plugins", () => {
|
|
1444
|
+
let agent: TestAgent;
|
|
1445
|
+
|
|
1446
|
+
beforeEach(async () => {
|
|
1447
|
+
await setupDb();
|
|
1448
|
+
app = getBaseServer();
|
|
1449
|
+
setupAuth(app, UserModel as any);
|
|
1450
|
+
addAuthRoutes(app, UserModel as any);
|
|
1451
|
+
app.use(
|
|
1452
|
+
"/users",
|
|
1453
|
+
modelRouter(UserModel, {
|
|
1454
|
+
allowAnonymous: true,
|
|
1455
|
+
permissions: {
|
|
1456
|
+
create: [Permissions.IsAny],
|
|
1457
|
+
delete: [Permissions.IsAny],
|
|
1458
|
+
list: [Permissions.IsAny],
|
|
1459
|
+
read: [Permissions.IsAny],
|
|
1460
|
+
update: [Permissions.IsAny],
|
|
1461
|
+
},
|
|
1462
|
+
})
|
|
1463
|
+
);
|
|
1464
|
+
server = supertest(app);
|
|
1465
|
+
agent = await authAsUser(app, "notAdmin");
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
it("check that security fields are filtered", async () => {
|
|
1469
|
+
const res = await agent.get("/users").expect(200);
|
|
1470
|
+
expect(res.body.data[0].email).toBeDefined();
|
|
1471
|
+
expect(res.body.data[0].token).toBeUndefined();
|
|
1472
|
+
expect(res.body.data[0].hash).toBeUndefined();
|
|
1473
|
+
expect(res.body.data[0].salt).toBeUndefined();
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
describe("discriminator", () => {
|
|
1478
|
+
let superUser: mongoose.Document<SuperUser>;
|
|
1479
|
+
let staffUser: mongoose.Document<StaffUser>;
|
|
1480
|
+
let notAdmin: mongoose.Document;
|
|
1481
|
+
let agent: TestAgent;
|
|
1482
|
+
|
|
1483
|
+
beforeEach(async () => {
|
|
1484
|
+
[notAdmin] = await setupDb();
|
|
1485
|
+
const [staffUserId, superUserId] = await Promise.all([
|
|
1486
|
+
StaffUserModel.create({
|
|
1487
|
+
department: "Accounting",
|
|
1488
|
+
email: "staff@example.com",
|
|
1489
|
+
}),
|
|
1490
|
+
SuperUserModel.create({
|
|
1491
|
+
email: "superuser@example.com",
|
|
1492
|
+
superTitle: "Super Man",
|
|
1493
|
+
}),
|
|
1494
|
+
]);
|
|
1495
|
+
staffUser = (await UserModel.findById(staffUserId)) as any;
|
|
1496
|
+
superUser = (await UserModel.findById(superUserId)) as any;
|
|
1497
|
+
|
|
1498
|
+
app = getBaseServer();
|
|
1499
|
+
setupAuth(app, UserModel as any);
|
|
1500
|
+
addAuthRoutes(app, UserModel as any);
|
|
1501
|
+
app.use(
|
|
1502
|
+
"/users",
|
|
1503
|
+
modelRouter(UserModel, {
|
|
1504
|
+
allowAnonymous: true,
|
|
1505
|
+
discriminatorKey: "__t",
|
|
1506
|
+
permissions: {
|
|
1507
|
+
create: [Permissions.IsAuthenticated],
|
|
1508
|
+
delete: [Permissions.IsAuthenticated],
|
|
1509
|
+
list: [Permissions.IsAuthenticated],
|
|
1510
|
+
read: [Permissions.IsAuthenticated],
|
|
1511
|
+
update: [Permissions.IsAuthenticated],
|
|
1512
|
+
},
|
|
1513
|
+
})
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
server = supertest(app);
|
|
1517
|
+
|
|
1518
|
+
agent = await authAsUser(app, "notAdmin");
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
it("gets all users", async () => {
|
|
1522
|
+
const res = await agent.get("/users").expect(200);
|
|
1523
|
+
expect(res.body.data).toHaveLength(5);
|
|
1524
|
+
|
|
1525
|
+
const data = sortBy(res.body.data, ["email"]);
|
|
1526
|
+
|
|
1527
|
+
expect(data[0].email).toBe("admin+other@example.com");
|
|
1528
|
+
expect(data[0].department).toBeUndefined();
|
|
1529
|
+
expect(data[0].supertitle).toBeUndefined();
|
|
1530
|
+
expect(data[0].__t).toBeUndefined();
|
|
1531
|
+
|
|
1532
|
+
expect(data[1].email).toBe("admin@example.com");
|
|
1533
|
+
expect(data[1].department).toBeUndefined();
|
|
1534
|
+
expect(data[1].supertitle).toBeUndefined();
|
|
1535
|
+
expect(data[1].__t).toBeUndefined();
|
|
1536
|
+
|
|
1537
|
+
expect(data[2].email).toBe("notAdmin@example.com");
|
|
1538
|
+
expect(data[2].department).toBeUndefined();
|
|
1539
|
+
expect(data[2].supertitle).toBeUndefined();
|
|
1540
|
+
expect(data[2].__t).toBeUndefined();
|
|
1541
|
+
|
|
1542
|
+
expect(data[3].email).toBe("staff@example.com");
|
|
1543
|
+
expect(data[3].department).toBe("Accounting");
|
|
1544
|
+
expect(data[3].supertitle).toBeUndefined();
|
|
1545
|
+
expect(data[3].__t).toBe("Staff");
|
|
1546
|
+
|
|
1547
|
+
expect(data[4].email).toBe("superuser@example.com");
|
|
1548
|
+
expect(data[4].department).toBeUndefined();
|
|
1549
|
+
expect(data[4].superTitle).toBe("Super Man");
|
|
1550
|
+
expect(data[4].__t).toBe("SuperUser");
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it("gets a discriminated user", async () => {
|
|
1554
|
+
const res = await agent.get(`/users/${superUser._id}`).expect(200);
|
|
1555
|
+
|
|
1556
|
+
expect(res.body.data.email).toBe("superuser@example.com");
|
|
1557
|
+
expect(res.body.data.department).toBeUndefined();
|
|
1558
|
+
expect(res.body.data.superTitle).toBe("Super Man");
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it("updates a discriminated user", async () => {
|
|
1562
|
+
// Fails without __t.
|
|
1563
|
+
await agent.patch(`/users/${superUser._id}`).send({superTitle: "Batman"}).expect(404);
|
|
1564
|
+
|
|
1565
|
+
const res = await agent
|
|
1566
|
+
.patch(`/users/${superUser._id}`)
|
|
1567
|
+
.send({__t: "SuperUser", superTitle: "Batman"})
|
|
1568
|
+
.expect(200);
|
|
1569
|
+
|
|
1570
|
+
expect(res.body.data.email).toBe("superuser@example.com");
|
|
1571
|
+
expect(res.body.data.department).toBeUndefined();
|
|
1572
|
+
expect(res.body.data.superTitle).toBe("Batman");
|
|
1573
|
+
|
|
1574
|
+
const user = await SuperUserModel.findById(superUser._id);
|
|
1575
|
+
expect(user?.superTitle).toBe("Batman");
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
it("updates a base user", async () => {
|
|
1579
|
+
const res = await agent
|
|
1580
|
+
.patch(`/users/${notAdmin._id}`)
|
|
1581
|
+
.send({email: "newemail@example.com", superTitle: "The Boss"})
|
|
1582
|
+
.expect(200);
|
|
1583
|
+
|
|
1584
|
+
expect(res.body.data.email).toBe("newemail@example.com");
|
|
1585
|
+
expect(res.body.data.superTitle).toBeUndefined();
|
|
1586
|
+
|
|
1587
|
+
const user = await SuperUserModel.findById(notAdmin._id);
|
|
1588
|
+
expect(user?.superTitle).toBeUndefined();
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
it("cannot update discriminator key", async () => {
|
|
1592
|
+
await agent
|
|
1593
|
+
.patch(`/users/${notAdmin._id}`)
|
|
1594
|
+
.send({__t: "Staff", superTitle: "Batman"})
|
|
1595
|
+
.expect(404);
|
|
1596
|
+
|
|
1597
|
+
await agent
|
|
1598
|
+
.patch(`/users/${staffUser._id}`)
|
|
1599
|
+
.send({__t: "SuperUser", superTitle: "Batman"})
|
|
1600
|
+
.expect(404);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
it("updating a field on another discriminated model does nothing", async () => {
|
|
1604
|
+
const res = await agent
|
|
1605
|
+
.patch(`/users/${superUser._id}`)
|
|
1606
|
+
.send({__t: "SuperUser", department: "Journalism"})
|
|
1607
|
+
.expect(200);
|
|
1608
|
+
|
|
1609
|
+
expect(res.body.data.department).toBeUndefined();
|
|
1610
|
+
|
|
1611
|
+
const user = await SuperUserModel.findById(superUser._id);
|
|
1612
|
+
expect((user as any)?.department).toBeUndefined();
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
it("creates a discriminated user", async () => {
|
|
1616
|
+
const res = await agent
|
|
1617
|
+
.post("/users")
|
|
1618
|
+
.send({
|
|
1619
|
+
__t: "SuperUser",
|
|
1620
|
+
department: "R&D",
|
|
1621
|
+
email: "brucewayne@example.com",
|
|
1622
|
+
superTitle: "Batman",
|
|
1623
|
+
})
|
|
1624
|
+
.expect(201);
|
|
1625
|
+
|
|
1626
|
+
expect(res.body.data.email).toBe("brucewayne@example.com");
|
|
1627
|
+
// Because we pass __t, this should create a SuperUser which has no department, so this is
|
|
1628
|
+
// dropped.
|
|
1629
|
+
expect(res.body.data.department).toBeUndefined();
|
|
1630
|
+
expect(res.body.data.superTitle).toBe("Batman");
|
|
1631
|
+
|
|
1632
|
+
const user = await SuperUserModel.findById(res.body.data._id);
|
|
1633
|
+
expect(user?.superTitle).toBe("Batman");
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it("deletes a discriminated user", async () => {
|
|
1637
|
+
// Fails without __t.
|
|
1638
|
+
await agent.delete(`/users/${superUser._id}`).expect(404);
|
|
1639
|
+
|
|
1640
|
+
await agent
|
|
1641
|
+
.delete(`/users/${superUser._id}`)
|
|
1642
|
+
.send({
|
|
1643
|
+
__t: "SuperUser",
|
|
1644
|
+
})
|
|
1645
|
+
.expect(204);
|
|
1646
|
+
|
|
1647
|
+
const user = await SuperUserModel.findById(superUser._id);
|
|
1648
|
+
expect(user).toBeNull();
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
it("deletes a base user", async () => {
|
|
1652
|
+
// Fails for base user with __t
|
|
1653
|
+
await agent.delete(`/users/${notAdmin._id}`).send({__t: "SuperUser"}).expect(404);
|
|
1654
|
+
|
|
1655
|
+
await agent.delete(`/users/${notAdmin._id}`).expect(204);
|
|
1656
|
+
|
|
1657
|
+
const user = await SuperUserModel.findById(notAdmin._id);
|
|
1658
|
+
expect(user).toBeNull();
|
|
1659
|
+
});
|
|
1660
|
+
});
|
|
1661
|
+
});
|