@terreno/api 0.0.11 → 0.0.13

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/src/api.test.ts CHANGED
@@ -1,47 +1,60 @@
1
1
  import {beforeEach, describe, expect, it} from "bun:test";
2
- import * as Sentry from "@sentry/node";
3
2
  import type express from "express";
4
- import sortBy from "lodash/sortBy";
5
- import type mongoose from "mongoose";
6
- import qs from "qs";
7
3
  import supertest from "supertest";
8
4
  import type TestAgent from "supertest/lib/agent";
9
5
 
10
- import {modelRouter} from "./api";
6
+ import {addPopulateToQuery, modelRouter} from "./api";
11
7
  import {addAuthRoutes, setupAuth} from "./auth";
12
- import {APIError} from "./errors";
13
- import {logRequests} from "./expressServer";
14
8
  import {Permissions} from "./permissions";
15
9
  import {
16
10
  authAsUser,
17
11
  type Food,
18
12
  FoodModel,
19
13
  getBaseServer,
20
- type StaffUser,
21
- StaffUserModel,
22
- type SuperUser,
23
- SuperUserModel,
14
+ RequiredModel,
24
15
  setupDb,
25
16
  UserModel,
26
17
  } from "./tests";
18
+ import {AdminOwnerTransformer} from "./transformers";
27
19
 
28
20
  describe("@terreno/api", () => {
29
21
  let server: TestAgent;
30
22
  let app: express.Application;
31
23
 
32
- describe("pre and post hooks", () => {
24
+ describe("populate", () => {
25
+ let admin: any;
26
+ let notAdmin: any;
33
27
  let agent: TestAgent;
28
+ let spinach: Food;
34
29
 
35
30
  beforeEach(async () => {
36
- await setupDb();
31
+ [admin, notAdmin] = await setupDb();
32
+
33
+ [spinach] = await Promise.all([
34
+ FoodModel.create({
35
+ calories: 1,
36
+ created: new Date("2021-12-03T00:00:20.000Z"),
37
+ hidden: false,
38
+ name: "Spinach",
39
+ ownerId: admin._id,
40
+ source: {
41
+ name: "Brand",
42
+ },
43
+ }),
44
+ FoodModel.create({
45
+ calories: 1,
46
+ created: new Date("2022-12-03T00:00:20.000Z"),
47
+ hidden: false,
48
+ name: "Carrots",
49
+ ownerId: notAdmin._id,
50
+ source: {
51
+ name: "User",
52
+ },
53
+ }),
54
+ ]);
37
55
  app = getBaseServer();
38
56
  setupAuth(app, UserModel as any);
39
57
  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
58
  app.use(
46
59
  "/food",
47
60
  modelRouter(FoodModel, {
@@ -53,66 +66,90 @@ describe("@terreno/api", () => {
53
66
  read: [Permissions.IsAny],
54
67
  update: [Permissions.IsAny],
55
68
  },
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
- },
69
+ populatePaths: [{fields: ["email"], path: "ownerId"}],
70
+ sort: "-created",
68
71
  })
69
72
  );
70
73
  server = supertest(app);
74
+ agent = await authAsUser(app, "notAdmin");
75
+ });
76
+
77
+ it("lists with populate", async () => {
78
+ const res = await agent.get("/food").expect(200);
79
+ expect(res.body.data).toHaveLength(2);
80
+ const [carrots, spin] = res.body.data;
81
+ expect(carrots.ownerId._id).toBe(notAdmin._id.toString());
82
+ expect(carrots.ownerId.email).toBe(notAdmin.email);
83
+ expect(carrots.ownerId.name).toBeUndefined();
84
+ expect(spin.ownerId._id).toBe(admin._id.toString());
85
+ expect(spin.ownerId.email).toBe(admin.email);
86
+ expect(spin.ownerId.name).toBeUndefined();
87
+ });
88
+
89
+ it("reads with populate", async () => {
90
+ const res = await agent.get(`/food/${spinach._id}`).expect(200);
91
+ expect(res.body.data.ownerId._id).toBe(admin._id.toString());
92
+ expect(res.body.data.ownerId.email).toBe(admin.email);
93
+ expect(res.body.data.ownerId.name).toBeUndefined();
94
+ });
71
95
 
72
- let res = await server
96
+ it("creates with populate", async () => {
97
+ const res = await server
73
98
  .post("/food")
74
99
  .send({
75
100
  calories: 15,
76
101
  name: "Broccoli",
102
+ ownerId: admin._id,
77
103
  })
78
104
  .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);
105
+ expect(res.body.data.ownerId._id).toBe(admin._id.toString());
106
+ expect(res.body.data.ownerId.email).toBe(admin.email);
107
+ expect(res.body.data.ownerId.name).toBeUndefined();
108
+ });
86
109
 
87
- res = await server
88
- .patch(`/food/${broccoli._id}`)
110
+ it("updates with populate", async () => {
111
+ const res = await server
112
+ .patch(`/food/${spinach._id}`)
89
113
  .send({
90
- name: "Broccoli2",
114
+ name: "NotSpinach",
91
115
  })
92
116
  .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);
117
+ expect(res.body.data.ownerId._id).toBe(admin._id.toString());
118
+ expect(res.body.data.ownerId.email).toBe(admin.email);
119
+ expect(res.body.data.ownerId.name).toBeUndefined();
99
120
  });
121
+ });
100
122
 
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
- });
123
+ describe("responseHandler", () => {
124
+ let admin: any;
125
+ let agent: TestAgent;
126
+ let spinach: Food;
115
127
 
128
+ beforeEach(async () => {
129
+ [admin] = await setupDb();
130
+
131
+ [spinach] = await Promise.all([
132
+ FoodModel.create({
133
+ calories: 1,
134
+ created: new Date("2021-12-03T00:00:20.000Z"),
135
+ hidden: false,
136
+ name: "Spinach",
137
+ ownerId: admin._id,
138
+ source: {
139
+ name: "Brand",
140
+ },
141
+ }),
142
+ FoodModel.create({
143
+ calories: 100,
144
+ created: Date.now() - 10,
145
+ hidden: true,
146
+ name: "Apple",
147
+ ownerId: admin?._id,
148
+ }),
149
+ ]);
150
+ app = getBaseServer();
151
+ setupAuth(app, UserModel as any);
152
+ addAuthRoutes(app, UserModel as any);
116
153
  app.use(
117
154
  "/food",
118
155
  modelRouter(FoodModel, {
@@ -124,37 +161,54 @@ describe("@terreno/api", () => {
124
161
  read: [Permissions.IsAny],
125
162
  update: [Permissions.IsAny],
126
163
  },
127
- preCreate: () => null,
128
- preDelete: () => null,
129
- preUpdate: () => null,
164
+ responseHandler: (data, method) => {
165
+ if (method === "list") {
166
+ return (data as any).map((d: any) => ({
167
+ foo: "bar",
168
+ id: (d as any)._id,
169
+ }));
170
+ }
171
+ return {
172
+ foo: "bar",
173
+ id: (data as any)._id,
174
+ };
175
+ },
130
176
  })
131
177
  );
132
178
  server = supertest(app);
179
+ agent = await authAsUser(app, "notAdmin");
180
+ });
133
181
 
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();
182
+ it("reads with serialize", async () => {
183
+ const res = await agent.get(`/food/${spinach._id}`).expect(200);
184
+ expect(res.body.data.ownerId).toBeUndefined();
185
+ expect(res.body.data.id).toBe(spinach._id.toString());
186
+ expect(res.body.data.foo).toBe("bar");
187
+ });
143
188
 
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);
189
+ it("list with serialize", async () => {
190
+ const res = await agent.get("/food").expect(200);
191
+ expect(res.body.data[0].ownerId).toBeUndefined();
192
+ expect(res.body.data[1].ownerId).toBeUndefined();
193
+
194
+ expect(res.body.data[0].id).toBeDefined();
195
+ expect(res.body.data[0].foo).toBe("bar");
196
+ expect(res.body.data[1].id).toBeDefined();
197
+ expect(res.body.data[1].foo).toBe("bar");
151
198
  });
199
+ });
200
+
201
+ describe("plugins", () => {
202
+ let agent: TestAgent;
152
203
 
153
- it("post hooks succeed", async () => {
154
- let deleteCalled = false;
204
+ beforeEach(async () => {
205
+ await setupDb();
206
+ app = getBaseServer();
207
+ setupAuth(app, UserModel as any);
208
+ addAuthRoutes(app, UserModel as any);
155
209
  app.use(
156
- "/food",
157
- modelRouter(FoodModel as any, {
210
+ "/users",
211
+ modelRouter(UserModel, {
158
212
  allowAnonymous: true,
159
213
  permissions: {
160
214
  create: [Permissions.IsAny],
@@ -163,58 +217,45 @@ describe("@terreno/api", () => {
163
217
  read: [Permissions.IsAny],
164
218
  update: [Permissions.IsAny],
165
219
  },
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
220
  })
181
221
  );
182
222
  server = supertest(app);
223
+ agent = await authAsUser(app, "notAdmin");
224
+ });
183
225
 
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);
226
+ it("check that security fields are filtered", async () => {
227
+ const res = await agent.get("/users").expect(200);
228
+ expect(res.body.data[0].email).toBeDefined();
229
+ expect(res.body.data[0].token).toBeUndefined();
230
+ expect(res.body.data[0].hash).toBeUndefined();
231
+ expect(res.body.data[0].salt).toBeUndefined();
232
+ });
233
+ });
198
234
 
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);
235
+ describe("error handling", () => {
236
+ let admin: any;
237
+ let spinach: Food;
238
+
239
+ beforeEach(async () => {
240
+ [admin] = await setupDb();
241
+
242
+ spinach = await FoodModel.create({
243
+ calories: 1,
244
+ created: new Date("2021-12-03T00:00:20.000Z"),
245
+ hidden: false,
246
+ name: "Spinach",
247
+ ownerId: admin._id,
248
+ source: {
249
+ name: "Brand",
250
+ },
251
+ });
212
252
 
213
- await agent.delete(`/food/${broccoli._id}`).expect(204);
214
- expect(deleteCalled).toBe(true);
253
+ app = getBaseServer();
254
+ setupAuth(app, UserModel as any);
255
+ addAuthRoutes(app, UserModel as any);
215
256
  });
216
257
 
217
- it("preCreate hook preserves disableExternalErrorTracking on APIError", async () => {
258
+ it("PUT returns 500 not supported", async () => {
218
259
  app.use(
219
260
  "/food",
220
261
  modelRouter(FoodModel, {
@@ -226,30 +267,15 @@ describe("@terreno/api", () => {
226
267
  read: [Permissions.IsAny],
227
268
  update: [Permissions.IsAny],
228
269
  },
229
- preCreate: () => {
230
- throw new APIError({
231
- disableExternalErrorTracking: true,
232
- status: 400,
233
- title: "Custom preCreate error",
234
- });
235
- },
236
270
  })
237
271
  );
238
272
  server = supertest(app);
239
273
 
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);
274
+ const res = await server.put(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
275
+ expect(res.body.title).toBe("PUT is not supported.");
250
276
  });
251
277
 
252
- it("preCreate hook preserves disableExternalErrorTracking on non-APIError", async () => {
278
+ it("responseHandler error in read is handled", async () => {
253
279
  app.use(
254
280
  "/food",
255
281
  modelRouter(FoodModel, {
@@ -261,42 +287,21 @@ describe("@terreno/api", () => {
261
287
  read: [Permissions.IsAny],
262
288
  update: [Permissions.IsAny],
263
289
  },
264
- preCreate: () => {
265
- const error: any = new Error("Some custom error");
266
- error.disableExternalErrorTracking = true;
267
- throw error;
290
+ responseHandler: (_data, method) => {
291
+ if (method === "read") {
292
+ throw new Error("responseHandler read failed");
293
+ }
294
+ return {} as any;
268
295
  },
269
296
  })
270
297
  );
271
298
  server = supertest(app);
272
299
 
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);
300
+ const res = await server.get(`/food/${spinach._id}`).expect(500);
301
+ expect(res.body.title).toContain("responseHandler error");
283
302
  });
284
303
 
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
-
304
+ it("responseHandler error in create is handled", async () => {
300
305
  app.use(
301
306
  "/food",
302
307
  modelRouter(FoodModel, {
@@ -308,43 +313,21 @@ describe("@terreno/api", () => {
308
313
  read: [Permissions.IsAny],
309
314
  update: [Permissions.IsAny],
310
315
  },
311
- preUpdate: () => {
312
- throw new APIError({
313
- disableExternalErrorTracking: true,
314
- status: 400,
315
- title: "Custom preUpdate error",
316
- });
316
+ responseHandler: (_data, method) => {
317
+ if (method === "create") {
318
+ throw new Error("responseHandler create failed");
319
+ }
320
+ return {} as any;
317
321
  },
318
322
  })
319
323
  );
320
324
  server = supertest(app);
321
325
 
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);
326
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(500);
327
+ expect(res.body.title).toContain("responseHandler error");
331
328
  });
332
329
 
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
-
330
+ it("responseHandler error in update is handled", async () => {
348
331
  app.use(
349
332
  "/food",
350
333
  modelRouter(FoodModel, {
@@ -356,41 +339,21 @@ describe("@terreno/api", () => {
356
339
  read: [Permissions.IsAny],
357
340
  update: [Permissions.IsAny],
358
341
  },
359
- preUpdate: () => {
360
- const error: any = new Error("Some custom error");
361
- error.disableExternalErrorTracking = true;
362
- throw error;
342
+ responseHandler: (_data, method) => {
343
+ if (method === "update") {
344
+ throw new Error("responseHandler update failed");
345
+ }
346
+ return {} as any;
363
347
  },
364
348
  })
365
349
  );
366
350
  server = supertest(app);
367
351
 
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);
352
+ const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
353
+ expect(res.body.title).toContain("responseHandler error");
377
354
  });
378
355
 
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
-
356
+ it("responseHandler error in list is handled", async () => {
394
357
  app.use(
395
358
  "/food",
396
359
  modelRouter(FoodModel, {
@@ -402,900 +365,220 @@ describe("@terreno/api", () => {
402
365
  read: [Permissions.IsAny],
403
366
  update: [Permissions.IsAny],
404
367
  },
405
- preDelete: () => {
406
- const error: any = new Error("Some custom error");
407
- error.disableExternalErrorTracking = true;
408
- throw error;
368
+ responseHandler: (_data, method) => {
369
+ if (method === "list") {
370
+ throw new Error("responseHandler list failed");
371
+ }
372
+ return {} as any;
409
373
  },
410
374
  })
411
375
  );
412
376
  server = supertest(app);
413
377
 
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);
378
+ const res = await server.get("/food").expect(500);
379
+ expect(res.body.title).toContain("responseHandler error");
418
380
  });
419
- });
420
381
 
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);
382
+ it("list with non-array responseHandler returns data directly", async () => {
466
383
  app.use(
467
384
  "/food",
468
385
  modelRouter(FoodModel, {
469
386
  allowAnonymous: true,
470
387
  permissions: {
471
- create: [Permissions.IsAdmin],
472
- delete: [Permissions.IsAdmin],
473
- list: [Permissions.IsAdmin],
474
- read: [Permissions.IsAdmin],
475
- update: [Permissions.IsAdmin],
388
+ create: [Permissions.IsAny],
389
+ delete: [Permissions.IsAny],
390
+ list: [Permissions.IsAny],
391
+ read: [Permissions.IsAny],
392
+ update: [Permissions.IsAny],
393
+ },
394
+ responseHandler: (_data, method) => {
395
+ if (method === "list") {
396
+ return {custom: "response"} as any;
397
+ }
398
+ return {} as any;
476
399
  },
477
- queryFields: ["hidden", "calories", "created", "source.name"],
478
- sort: {created: "descending"},
479
400
  })
480
401
  );
481
402
  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
403
 
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"]);
404
+ const res = await server.get("/food").expect(200);
405
+ expect(res.body.data).toEqual({custom: "response"});
406
+ expect(res.body.more).toBeUndefined();
407
+ expect(res.body.total).toBeUndefined();
560
408
  });
561
409
 
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",
410
+ it("list with query sort param", async () => {
411
+ await FoodModel.create({
412
+ calories: 200,
413
+ created: new Date("2021-12-04T00:00:20.000Z"),
414
+ hidden: false,
415
+ name: "Apple",
580
416
  ownerId: admin._id,
581
417
  });
582
418
 
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
419
  app.use(
626
420
  "/food",
627
421
  modelRouter(FoodModel, {
628
422
  allowAnonymous: true,
629
423
  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;
424
+ create: [Permissions.IsAny],
425
+ delete: [Permissions.IsAny],
426
+ list: [Permissions.IsAny],
427
+ read: [Permissions.IsAny],
428
+ update: [Permissions.IsAny],
640
429
  },
430
+ queryFields: ["name"],
641
431
  })
642
432
  );
643
433
  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
434
 
708
- // Verify they are different object references
709
- expect(postUpdateDoc).not.toBe(postUpdatePrevDoc);
435
+ let res = await server.get("/food?sort=name").expect(200);
436
+ expect(res.body.data[0].name).toBe("Apple");
437
+ expect(res.body.data[1].name).toBe("Spinach");
710
438
 
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);
439
+ res = await server.get("/food?sort=-name").expect(200);
440
+ expect(res.body.data[0].name).toBe("Spinach");
441
+ expect(res.body.data[1].name).toBe("Apple");
721
442
  });
722
443
 
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);
444
+ it("queryFilter error is handled", async () => {
731
445
  app.use(
732
446
  "/food",
733
447
  modelRouter(FoodModel, {
734
448
  allowAnonymous: true,
735
449
  permissions: {
736
- create: [Permissions.IsAdmin],
737
- delete: [Permissions.IsAdmin],
738
- list: [Permissions.IsAdmin],
739
- read: [Permissions.IsAdmin],
740
- update: [Permissions.IsAdmin],
450
+ create: [Permissions.IsAny],
451
+ delete: [Permissions.IsAny],
452
+ list: [Permissions.IsAny],
453
+ read: [Permissions.IsAny],
454
+ update: [Permissions.IsAny],
741
455
  },
742
- postUpdate: async (doc: any, _cleanedBody: any, _request: any, prevValue: any) => {
743
- postUpdateDoc = doc;
744
- postUpdatePrevDoc = prevValue;
745
- postUpdateCalled = true;
456
+ queryFilter: () => {
457
+ throw new Error("queryFilter failed");
746
458
  },
747
459
  })
748
460
  );
749
461
  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
462
 
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");
463
+ const res = await server.get("/food").expect(400);
464
+ expect(res.body.title).toContain("Query filter error");
787
465
  });
788
- });
789
-
790
- describe("standard methods", () => {
791
- let notAdmin: any;
792
- let admin: any;
793
- let adminOther: any;
794
- let agent: TestAgent;
795
466
 
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);
467
+ it("custom endpoints take priority", async () => {
856
468
  app.use(
857
469
  "/food",
858
470
  modelRouter(FoodModel, {
859
471
  allowAnonymous: true,
860
- defaultLimit: 2,
861
- defaultQueryParams: {hidden: false},
862
- maxLimit: 3,
472
+ endpoints: (router: any) => {
473
+ router.get("/custom", (_req: any, res: any) => {
474
+ res.json({custom: true});
475
+ });
476
+ },
863
477
  permissions: {
864
- create: [Permissions.IsAuthenticated],
865
- delete: [Permissions.IsAdmin],
478
+ create: [Permissions.IsAny],
479
+ delete: [Permissions.IsAny],
866
480
  list: [Permissions.IsAny],
867
481
  read: [Permissions.IsAny],
868
- update: [Permissions.IsOwner],
482
+ update: [Permissions.IsAny],
869
483
  },
870
- populatePaths: [{path: "ownerId"}],
871
- queryFields: ["hidden", "name", "calories", "created", "source.name", "tags", "eatenBy"],
872
- sort: {created: "descending"},
873
484
  })
874
485
  );
875
486
  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
487
 
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);
488
+ const res = await server.get("/food/custom").expect(200);
489
+ expect(res.body.custom).toBe(true);
986
490
  });
987
491
 
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()])
492
+ it("disallowed query param returns 400", async () => {
493
+ app.use(
494
+ "/food",
495
+ modelRouter(FoodModel, {
496
+ allowAnonymous: true,
497
+ permissions: {
498
+ create: [Permissions.IsAny],
499
+ delete: [Permissions.IsAny],
500
+ list: [Permissions.IsAny],
501
+ read: [Permissions.IsAny],
502
+ update: [Permissions.IsAny],
503
+ },
504
+ queryFields: ["name"],
505
+ })
1184
506
  );
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
- });
507
+ server = supertest(app);
1204
508
 
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());
509
+ const res = await server.get("/food?calories=100").expect(400);
510
+ expect(res.body.title).toContain("calories is not allowed as a query param");
1209
511
  });
1210
512
 
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",
513
+ it("queryFilter returning null returns empty array", async () => {
514
+ app.use(
515
+ "/food",
516
+ modelRouter(FoodModel, {
517
+ allowAnonymous: true,
518
+ permissions: {
519
+ create: [Permissions.IsAny],
520
+ delete: [Permissions.IsAny],
521
+ list: [Permissions.IsAny],
522
+ read: [Permissions.IsAny],
523
+ update: [Permissions.IsAny],
1236
524
  },
525
+ queryFilter: () => null,
1237
526
  })
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
- });
527
+ );
528
+ server = supertest(app);
1244
529
 
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");
530
+ const res = await server.get("/food").expect(200);
531
+ expect(res.body.data).toEqual([]);
1261
532
  });
1262
533
  });
1263
534
 
1264
- describe("populate", () => {
535
+ describe("transformer errors", () => {
1265
536
  let admin: any;
1266
- let notAdmin: any;
1267
- let agent: TestAgent;
1268
-
1269
537
  let spinach: Food;
1270
538
 
1271
539
  beforeEach(async () => {
1272
- [admin, notAdmin] = await setupDb();
540
+ [admin] = await setupDb();
1273
541
 
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",
542
+ spinach = await FoodModel.create({
543
+ calories: 1,
544
+ created: new Date("2021-12-03T00:00:20.000Z"),
545
+ hidden: false,
546
+ name: "Spinach",
547
+ ownerId: admin._id,
548
+ source: {
549
+ name: "Brand",
550
+ },
551
+ });
552
+
553
+ app = getBaseServer();
554
+ setupAuth(app, UserModel as any);
555
+ addAuthRoutes(app, UserModel as any);
556
+ });
557
+
558
+ it("transform error in create is handled", async () => {
559
+ app.use(
560
+ "/food",
561
+ modelRouter(FoodModel, {
562
+ allowAnonymous: true,
563
+ permissions: {
564
+ create: [Permissions.IsAny],
565
+ delete: [Permissions.IsAny],
566
+ list: [Permissions.IsAny],
567
+ read: [Permissions.IsAny],
568
+ update: [Permissions.IsAny],
1293
569
  },
1294
- }),
1295
- ]);
1296
- app = getBaseServer();
1297
- setupAuth(app, UserModel as any);
1298
- addAuthRoutes(app, UserModel as any);
570
+ transformer: AdminOwnerTransformer({
571
+ anonWriteFields: ["name"],
572
+ }),
573
+ })
574
+ );
575
+ server = supertest(app);
576
+
577
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
578
+ expect(res.body.title).toContain("cannot write fields");
579
+ });
580
+
581
+ it("transform error in patch is handled", async () => {
1299
582
  app.use(
1300
583
  "/food",
1301
584
  modelRouter(FoodModel, {
@@ -1307,94 +590,80 @@ describe("@terreno/api", () => {
1307
590
  read: [Permissions.IsAny],
1308
591
  update: [Permissions.IsAny],
1309
592
  },
1310
- populatePaths: [{fields: ["email"], path: "ownerId"}],
1311
- sort: "-created",
593
+ transformer: AdminOwnerTransformer({
594
+ anonWriteFields: ["name"],
595
+ }),
1312
596
  })
1313
597
  );
1314
598
  server = supertest(app);
1315
- agent = await authAsUser(app, "notAdmin");
599
+
600
+ const res = await server.patch(`/food/${spinach._id}`).send({calories: 100}).expect(403);
601
+ expect(res.body.title).toContain("cannot write fields");
1316
602
  });
1317
603
 
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();
604
+ it("model.create validation error is handled", async () => {
605
+ app.use(
606
+ "/required",
607
+ modelRouter(RequiredModel, {
608
+ allowAnonymous: true,
609
+ permissions: {
610
+ create: [Permissions.IsAny],
611
+ delete: [Permissions.IsAny],
612
+ list: [Permissions.IsAny],
613
+ read: [Permissions.IsAny],
614
+ update: [Permissions.IsAny],
615
+ },
616
+ })
617
+ );
618
+ server = supertest(app);
619
+
620
+ const res = await server.post("/required").send({about: "test"}).expect(400);
621
+ expect(res.body.title).toContain("Required");
1328
622
  });
623
+ });
1329
624
 
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();
625
+ describe("addPopulateToQuery", () => {
626
+ it("returns query unchanged with no populate paths", async () => {
627
+ await setupDb();
628
+ const query = FoodModel.find({});
629
+ const result = addPopulateToQuery(query, undefined);
630
+ expect(result).toBe(query);
1335
631
  });
1336
632
 
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();
633
+ it("returns query unchanged with empty populate paths", async () => {
634
+ await setupDb();
635
+ const query = FoodModel.find({});
636
+ const result = addPopulateToQuery(query, []);
637
+ expect(result).toBe(query);
1349
638
  });
1350
639
 
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();
640
+ it("applies multiple populate paths", async () => {
641
+ await setupDb();
642
+ const query = FoodModel.find({});
643
+ const result = addPopulateToQuery(query, [
644
+ {fields: ["email"], path: "ownerId"},
645
+ {fields: ["name"], path: "eatenBy"},
646
+ ]);
647
+ expect(result).toBeDefined();
1361
648
  });
1362
649
  });
1363
650
 
1364
- describe("responseHandler", () => {
651
+ describe("soft delete with isDeleted plugin", () => {
1365
652
  let admin: any;
1366
653
  let agent: TestAgent;
1367
654
 
1368
- let spinach: Food;
1369
-
1370
655
  beforeEach(async () => {
1371
656
  [admin] = await setupDb();
1372
657
 
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
658
  app = getBaseServer();
1393
659
  setupAuth(app, UserModel as any);
1394
660
  addAuthRoutes(app, UserModel as any);
661
+ });
662
+
663
+ it("soft deletes user with deleted field", async () => {
1395
664
  app.use(
1396
- "/food",
1397
- modelRouter(FoodModel, {
665
+ "/users",
666
+ modelRouter(UserModel, {
1398
667
  allowAnonymous: true,
1399
668
  permissions: {
1400
669
  create: [Permissions.IsAny],
@@ -1403,54 +672,42 @@ describe("@terreno/api", () => {
1403
672
  read: [Permissions.IsAny],
1404
673
  update: [Permissions.IsAny],
1405
674
  },
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
675
  })
1419
676
  );
1420
677
  server = supertest(app);
1421
678
  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
679
 
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();
680
+ const res = await agent.delete(`/users/${admin._id}`).expect(204);
681
+ expect(res.body).toEqual({});
1435
682
 
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");
683
+ const deletedUser = await UserModel.findById(admin._id);
684
+ expect(deletedUser).toBeNull();
1440
685
  });
1441
686
  });
1442
687
 
1443
- describe("plugins", () => {
1444
- let agent: TestAgent;
688
+ describe("populate in create", () => {
689
+ let admin: any;
1445
690
 
1446
691
  beforeEach(async () => {
1447
- await setupDb();
692
+ [admin] = await setupDb();
693
+
694
+ await FoodModel.create({
695
+ calories: 1,
696
+ created: new Date("2021-12-03T00:00:20.000Z"),
697
+ hidden: false,
698
+ name: "Spinach",
699
+ ownerId: admin._id,
700
+ });
701
+
1448
702
  app = getBaseServer();
1449
703
  setupAuth(app, UserModel as any);
1450
704
  addAuthRoutes(app, UserModel as any);
705
+ });
706
+
707
+ it("handles populate with valid path in create", async () => {
1451
708
  app.use(
1452
- "/users",
1453
- modelRouter(UserModel, {
709
+ "/food",
710
+ modelRouter(FoodModel, {
1454
711
  allowAnonymous: true,
1455
712
  permissions: {
1456
713
  create: [Permissions.IsAny],
@@ -1459,203 +716,155 @@ describe("@terreno/api", () => {
1459
716
  read: [Permissions.IsAny],
1460
717
  update: [Permissions.IsAny],
1461
718
  },
719
+ populatePaths: [{fields: ["email"], path: "ownerId"}],
1462
720
  })
1463
721
  );
1464
722
  server = supertest(app);
1465
- agent = await authAsUser(app, "notAdmin");
1466
- });
1467
723
 
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();
724
+ const res = await server
725
+ .post("/food")
726
+ .send({calories: 15, name: "Broccoli", ownerId: admin._id})
727
+ .expect(201);
728
+ expect(res.body.data.name).toBe("Broccoli");
729
+ expect(res.body.data.ownerId.email).toBe(admin.email);
1474
730
  });
1475
731
  });
1476
732
 
1477
- describe("discriminator", () => {
1478
- let superUser: mongoose.Document<SuperUser>;
1479
- let staffUser: mongoose.Document<StaffUser>;
1480
- let notAdmin: mongoose.Document;
1481
- let agent: TestAgent;
733
+ describe("save error handling", () => {
734
+ let admin: any;
735
+ let spinach: Food;
1482
736
 
1483
737
  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;
738
+ [admin] = await setupDb();
739
+
740
+ spinach = await FoodModel.create({
741
+ calories: 1,
742
+ created: new Date("2021-12-03T00:00:20.000Z"),
743
+ hidden: false,
744
+ name: "Spinach",
745
+ ownerId: admin._id,
746
+ source: {
747
+ name: "Brand",
748
+ },
749
+ });
1497
750
 
1498
751
  app = getBaseServer();
1499
752
  setupAuth(app, UserModel as any);
1500
753
  addAuthRoutes(app, UserModel as any);
754
+ });
755
+
756
+ it("handles patch save error with validation failure", async () => {
1501
757
  app.use(
1502
- "/users",
1503
- modelRouter(UserModel, {
758
+ "/food",
759
+ modelRouter(FoodModel, {
1504
760
  allowAnonymous: true,
1505
- discriminatorKey: "__t",
1506
761
  permissions: {
1507
- create: [Permissions.IsAuthenticated],
1508
- delete: [Permissions.IsAuthenticated],
1509
- list: [Permissions.IsAuthenticated],
1510
- read: [Permissions.IsAuthenticated],
1511
- update: [Permissions.IsAuthenticated],
762
+ create: [Permissions.IsAny],
763
+ delete: [Permissions.IsAny],
764
+ list: [Permissions.IsAny],
765
+ read: [Permissions.IsAny],
766
+ update: [Permissions.IsAny],
1512
767
  },
1513
768
  })
1514
769
  );
1515
-
1516
770
  server = supertest(app);
1517
771
 
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");
772
+ const res = await server
773
+ .patch(`/food/${spinach._id}`)
774
+ .send({invalidField: "value"})
775
+ .expect(400);
776
+ expect(res.body.title).toContain("preUpdate hook save error");
1551
777
  });
778
+ });
1552
779
 
1553
- it("gets a discriminated user", async () => {
1554
- const res = await agent.get(`/users/${superUser._id}`).expect(200);
780
+ describe("body undefined after transform without preCreate", () => {
781
+ beforeEach(async () => {
782
+ await setupDb();
1555
783
 
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");
784
+ app = getBaseServer();
785
+ setupAuth(app, UserModel as any);
786
+ addAuthRoutes(app, UserModel as any);
1559
787
  });
1560
788
 
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");
789
+ it("handles undefined body after transform when no preCreate", async () => {
790
+ app.use(
791
+ "/food",
792
+ modelRouter(FoodModel, {
793
+ allowAnonymous: true,
794
+ permissions: {
795
+ create: [Permissions.IsAny],
796
+ delete: [Permissions.IsAny],
797
+ list: [Permissions.IsAny],
798
+ read: [Permissions.IsAny],
799
+ update: [Permissions.IsAny],
800
+ },
801
+ transformer: {
802
+ transform: () => undefined,
803
+ },
804
+ })
805
+ );
806
+ server = supertest(app);
1573
807
 
1574
- const user = await SuperUserModel.findById(superUser._id);
1575
- expect(user?.superTitle).toBe("Batman");
808
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
809
+ expect(res.body.title).toBe("Invalid request body");
810
+ expect(res.body.detail).toBe("Body is undefined");
1576
811
  });
812
+ });
1577
813
 
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
- });
814
+ describe("soft delete with deleted field", () => {
815
+ let _admin: any;
816
+ let agent: TestAgent;
1590
817
 
1591
- it("cannot update discriminator key", async () => {
1592
- await agent
1593
- .patch(`/users/${notAdmin._id}`)
1594
- .send({__t: "Staff", superTitle: "Batman"})
1595
- .expect(404);
818
+ beforeEach(async () => {
819
+ [_admin] = await setupDb();
1596
820
 
1597
- await agent
1598
- .patch(`/users/${staffUser._id}`)
1599
- .send({__t: "SuperUser", superTitle: "Batman"})
1600
- .expect(404);
821
+ app = getBaseServer();
822
+ setupAuth(app, UserModel as any);
823
+ addAuthRoutes(app, UserModel as any);
1601
824
  });
1602
825
 
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
- });
826
+ it("soft deletes document with deleted field using isDeletedPlugin", async () => {
827
+ const mongoose = await import("mongoose");
1614
828
 
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);
829
+ const softDeleteSchema = new mongoose.Schema({
830
+ deleted: {default: false, type: Boolean},
831
+ name: String,
832
+ });
1625
833
 
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");
834
+ let SoftDeleteModel;
835
+ try {
836
+ SoftDeleteModel = mongoose.model("SoftDeleteTest");
837
+ } catch {
838
+ SoftDeleteModel = mongoose.model("SoftDeleteTest", softDeleteSchema);
839
+ }
1631
840
 
1632
- const user = await SuperUserModel.findById(res.body.data._id);
1633
- expect(user?.superTitle).toBe("Batman");
1634
- });
841
+ await SoftDeleteModel.deleteMany({});
1635
842
 
1636
- it("deletes a discriminated user", async () => {
1637
- // Fails without __t.
1638
- await agent.delete(`/users/${superUser._id}`).expect(404);
843
+ const testDoc = await SoftDeleteModel.create({name: "TestItem"});
1639
844
 
1640
- await agent
1641
- .delete(`/users/${superUser._id}`)
1642
- .send({
1643
- __t: "SuperUser",
845
+ app.use(
846
+ "/softdelete",
847
+ modelRouter(SoftDeleteModel, {
848
+ allowAnonymous: true,
849
+ permissions: {
850
+ create: [Permissions.IsAny],
851
+ delete: [Permissions.IsAny],
852
+ list: [Permissions.IsAny],
853
+ read: [Permissions.IsAny],
854
+ update: [Permissions.IsAny],
855
+ },
1644
856
  })
1645
- .expect(204);
1646
-
1647
- const user = await SuperUserModel.findById(superUser._id);
1648
- expect(user).toBeNull();
1649
- });
857
+ );
858
+ server = supertest(app);
859
+ agent = await authAsUser(app, "notAdmin");
1650
860
 
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);
861
+ await agent.delete(`/softdelete/${testDoc._id}`).expect(204);
1654
862
 
1655
- await agent.delete(`/users/${notAdmin._id}`).expect(204);
863
+ const softDeleted = await SoftDeleteModel.findById(testDoc._id);
864
+ expect(softDeleted).not.toBeNull();
865
+ expect(softDeleted?.deleted).toBe(true);
1656
866
 
1657
- const user = await SuperUserModel.findById(notAdmin._id);
1658
- expect(user).toBeNull();
867
+ await SoftDeleteModel.deleteMany({});
1659
868
  });
1660
869
  });
1661
870
  });