@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.
Files changed (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +170 -0
  3. package/biome.jsonc +22 -0
  4. package/bunfig.toml +4 -0
  5. package/dist/api.d.ts +227 -0
  6. package/dist/api.js +1024 -0
  7. package/dist/api.test.d.ts +1 -0
  8. package/dist/api.test.js +2143 -0
  9. package/dist/auth.d.ts +50 -0
  10. package/dist/auth.js +512 -0
  11. package/dist/auth.test.d.ts +1 -0
  12. package/dist/auth.test.js +778 -0
  13. package/dist/errors.d.ts +75 -0
  14. package/dist/errors.js +216 -0
  15. package/dist/example.d.ts +1 -0
  16. package/dist/example.js +118 -0
  17. package/dist/expressServer.d.ts +35 -0
  18. package/dist/expressServer.js +436 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +30 -0
  21. package/dist/logger.d.ts +23 -0
  22. package/dist/logger.js +249 -0
  23. package/dist/middleware.d.ts +10 -0
  24. package/dist/middleware.js +52 -0
  25. package/dist/notifiers/googleChatNotifier.d.ts +5 -0
  26. package/dist/notifiers/googleChatNotifier.js +130 -0
  27. package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
  28. package/dist/notifiers/googleChatNotifier.test.js +260 -0
  29. package/dist/notifiers/index.d.ts +3 -0
  30. package/dist/notifiers/index.js +19 -0
  31. package/dist/notifiers/slackNotifier.d.ts +5 -0
  32. package/dist/notifiers/slackNotifier.js +130 -0
  33. package/dist/notifiers/slackNotifier.test.d.ts +1 -0
  34. package/dist/notifiers/slackNotifier.test.js +259 -0
  35. package/dist/notifiers/zoomNotifier.d.ts +34 -0
  36. package/dist/notifiers/zoomNotifier.js +181 -0
  37. package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
  38. package/dist/notifiers/zoomNotifier.test.js +370 -0
  39. package/dist/openApi.d.ts +60 -0
  40. package/dist/openApi.js +441 -0
  41. package/dist/openApi.test.d.ts +1 -0
  42. package/dist/openApi.test.js +445 -0
  43. package/dist/openApiBuilder.d.ts +419 -0
  44. package/dist/openApiBuilder.js +424 -0
  45. package/dist/openApiBuilder.test.d.ts +1 -0
  46. package/dist/openApiBuilder.test.js +509 -0
  47. package/dist/openApiEtag.d.ts +7 -0
  48. package/dist/openApiEtag.js +38 -0
  49. package/dist/permissions.d.ts +26 -0
  50. package/dist/permissions.js +331 -0
  51. package/dist/permissions.test.d.ts +1 -0
  52. package/dist/permissions.test.js +413 -0
  53. package/dist/plugins.d.ts +67 -0
  54. package/dist/plugins.js +315 -0
  55. package/dist/plugins.test.d.ts +1 -0
  56. package/dist/plugins.test.js +639 -0
  57. package/dist/populate.d.ts +14 -0
  58. package/dist/populate.js +315 -0
  59. package/dist/populate.test.d.ts +1 -0
  60. package/dist/populate.test.js +133 -0
  61. package/dist/response.d.ts +0 -0
  62. package/dist/response.js +1 -0
  63. package/dist/tests/bunSetup.d.ts +1 -0
  64. package/dist/tests/bunSetup.js +297 -0
  65. package/dist/tests/index.d.ts +1 -0
  66. package/dist/tests/index.js +17 -0
  67. package/dist/tests.d.ts +99 -0
  68. package/dist/tests.js +273 -0
  69. package/dist/transformers.d.ts +25 -0
  70. package/dist/transformers.js +217 -0
  71. package/dist/transformers.test.d.ts +1 -0
  72. package/dist/transformers.test.js +370 -0
  73. package/dist/utils.d.ts +11 -0
  74. package/dist/utils.js +143 -0
  75. package/dist/utils.test.d.ts +1 -0
  76. package/dist/utils.test.js +14 -0
  77. package/index.ts +1 -0
  78. package/package.json +88 -0
  79. package/src/__snapshots__/openApi.test.ts.snap +4814 -0
  80. package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
  81. package/src/api.test.ts +1661 -0
  82. package/src/api.ts +1036 -0
  83. package/src/auth.test.ts +550 -0
  84. package/src/auth.ts +408 -0
  85. package/src/errors.ts +225 -0
  86. package/src/example.ts +99 -0
  87. package/src/express.d.ts +5 -0
  88. package/src/expressServer.ts +387 -0
  89. package/src/index.ts +14 -0
  90. package/src/logger.ts +190 -0
  91. package/src/middleware.ts +18 -0
  92. package/src/notifiers/googleChatNotifier.test.ts +114 -0
  93. package/src/notifiers/googleChatNotifier.ts +47 -0
  94. package/src/notifiers/index.ts +3 -0
  95. package/src/notifiers/slackNotifier.test.ts +113 -0
  96. package/src/notifiers/slackNotifier.ts +55 -0
  97. package/src/notifiers/zoomNotifier.test.ts +207 -0
  98. package/src/notifiers/zoomNotifier.ts +111 -0
  99. package/src/openApi.test.ts +331 -0
  100. package/src/openApi.ts +494 -0
  101. package/src/openApiBuilder.test.ts +442 -0
  102. package/src/openApiBuilder.ts +636 -0
  103. package/src/openApiEtag.ts +40 -0
  104. package/src/permissions.test.ts +219 -0
  105. package/src/permissions.ts +228 -0
  106. package/src/plugins.test.ts +390 -0
  107. package/src/plugins.ts +289 -0
  108. package/src/populate.test.ts +65 -0
  109. package/src/populate.ts +258 -0
  110. package/src/response.ts +0 -0
  111. package/src/tests/bunSetup.ts +234 -0
  112. package/src/tests/index.ts +1 -0
  113. package/src/tests.ts +218 -0
  114. package/src/transformers.test.ts +202 -0
  115. package/src/transformers.ts +170 -0
  116. package/src/utils.test.ts +14 -0
  117. package/src/utils.ts +47 -0
  118. package/tsconfig.json +60 -0
  119. package/types.d.ts +17 -0
@@ -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
+ });